diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a84af74f4..4173e7ea6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ Docs: https://docs.openclaw.ai - WhatsApp/Web: pass explicit Baileys socket timings into every WhatsApp Web socket and expose `web.whatsapp.*` keepalive, connect, and query timeout settings so unstable networks can avoid repeated 408 disconnect and opening-handshake timeout loops. Fixes #56365. (#73580) Thanks @velvet-shark. - Channels/Telegram: persist native command metadata on target sessions so topic, helper, and ACP-bound slash commands keep their session metadata attached to the routed conversation. (#57548) Thanks @GaosCode. - Channels/native commands: keep validated native slash command replies visible in group chats while preserving explicit owner allowlists for command authorization. (#73672) Thanks @obviyus. +- Pairing/doctor: bootstrap `commands.ownerAllowFrom` from the first approved DM pairing when no command owner exists, and have doctor explain missing owners so privileged slash commands are not accidentally unusable after onboarding. Thanks @pashpashpash. +- Telegram/exec: infer native exec approvers from `commands.ownerAllowFrom` and auto-enable the Telegram approval client when an owner is resolvable, so owner-only commands such as `/diagnostics` can be approved in Telegram without duplicate per-channel approver config. Thanks @pashpashpash. - Auto-reply/session: carry the tail of user/assistant turns into the freshly-rotated transcript on silent in-reply session resets (compaction failure, role-ordering conflict) so direct-chat continuity survives the rebind. Fixes #70853. (#70898) Thanks @neeravmakwana. - Config: skip malformed non-string `env.vars` entries before env-reference checks, so config loading no longer crashes on JSON values like numbers or booleans. (#42402) Thanks @MiltonHeYan. @@ -75,6 +77,8 @@ Docs: https://docs.openclaw.ai - Security/networking: add opt-in operator-managed outbound proxy routing (proxy.enabled + proxy.proxyUrl/OPENCLAW_PROXY_URL) with strict http:// forward-proxy validation, loopback-only Gateway bypass, and cleanup of proxy env/dispatcher state on exit. (#70044) Thanks @jesse-merhi and @joshavant. - Dependencies: refresh provider and tooling dependencies, including AWS SDK, PI runtime packages, AJV, Feishu SDK, Anthropic SDK, tokenjuice, and native TypeScript/oxlint tooling. Thanks @dependabot. - Matrix/QA: add live Matrix approval scenarios for exec metadata, chunked fallback, plugin approvals, deny reactions, thread targeting, and `target: "both"` delivery, with redacted artifacts preserving safe approval summaries. Thanks @gumadeiras. +- Diagnostics/Codex: add owner-only core `/diagnostics` with a sensitive-data preamble, docs link, and explicit Gateway export approval guidance; Codex harness sessions also ask before uploading Codex feedback for the attached thread and print the matching `codex resume ` inspection command after confirmed upload. Thanks @pashpashpash. +- Trajectory export: route `/export-trajectory` through per-run exec approval, send group-chat approval prompts and export results only to the owner privately, and add `openclaw sessions export-trajectory` for the approved command path. Thanks @pashpashpash. - Codex: add Computer Use setup for Codex-mode agents, including `/codex computer-use status/install`, marketplace discovery, optional auto-install, and fail-closed MCP server checks before Codex-mode turns start. Fixes #72094. (#71842) Thanks @pash-openai. - Apps: consume Peekaboo 3.0.0-beta4 and ElevenLabsKit 0.1.1, align Swabble on Commander 0.2.2, and refresh macOS/iOS SwiftPM resolutions against the released dependency graph. Thanks @Blaizzy. - Plugin SDK: expose shared channel route normalization, parser-driven target resolution, raw-target compact keys, parsed-target types, and route comparison helpers through `openclaw/plugin-sdk/channel-route`, switch native approval origin matching onto that route contract with optional delivery and match-only target normalization, and retire the internal channel-route shim behind dated compatibility aliases for legacy key/comparable-target helpers. Thanks @vincentkoc. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index c22e963bcae..1c902bd8ae9 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -4195,6 +4195,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? + public let warningtext: AnyCodable? public let agentid: AnyCodable? public let resolvedpath: AnyCodable? public let sessionkey: AnyCodable? @@ -4216,6 +4217,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, + warningtext: AnyCodable?, agentid: AnyCodable?, resolvedpath: AnyCodable?, sessionkey: AnyCodable?, @@ -4236,6 +4238,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.host = host self.security = security self.ask = ask + self.warningtext = warningtext self.agentid = agentid self.resolvedpath = resolvedpath self.sessionkey = sessionkey @@ -4258,6 +4261,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case host case security case ask + case warningtext = "warningText" case agentid = "agentId" case resolvedpath = "resolvedPath" case sessionkey = "sessionKey" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index c22e963bcae..1c902bd8ae9 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -4195,6 +4195,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? + public let warningtext: AnyCodable? public let agentid: AnyCodable? public let resolvedpath: AnyCodable? public let sessionkey: AnyCodable? @@ -4216,6 +4217,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, + warningtext: AnyCodable?, agentid: AnyCodable?, resolvedpath: AnyCodable?, sessionkey: AnyCodable?, @@ -4236,6 +4238,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.host = host self.security = security self.ask = ask + self.warningtext = warningtext self.agentid = agentid self.resolvedpath = resolvedpath self.sessionkey = sessionkey @@ -4258,6 +4261,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case host case security case ask + case warningtext = "warningText" case agentid = "agentId" case resolvedpath = "resolvedPath" case sessionkey = "sessionKey" diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 1fa12e3f65d..f5b9bf32a96 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -7,7 +7,7 @@ read_when: title: "Pairing" --- -“Pairing” is OpenClaw’s explicit **owner approval** step. +“Pairing” is OpenClaw’s explicit access approval step. It is used in two places: 1. **DM pairing** (who is allowed to talk to the bot) @@ -34,6 +34,12 @@ openclaw pairing list telegram openclaw pairing approve telegram ``` +If no command owner is configured yet, approving a DM pairing code also bootstraps +`commands.ownerAllowFrom` to the approved sender, such as `telegram:123456789`. +That gives first-time setups an explicit owner for privileged commands and exec +approval prompts. After an owner exists, later pairing approvals only grant DM +access; they do not add more owners. + Supported channels: `bluebubbles`, `discord`, `feishu`, `googlechat`, `imessage`, `irc`, `line`, `matrix`, `mattermost`, `msteams`, `nextcloud-talk`, `nostr`, `openclaw-weixin`, `signal`, `slack`, `synology-chat`, `telegram`, `twitch`, `whatsapp`, `zalo`, `zalouser`. ### Where the state lives @@ -53,7 +59,12 @@ Account scoping behavior: Treat these as sensitive (they gate access to your assistant). -This store is for DM access. Group authorization is separate. Approving a DM pairing code does not automatically allow that sender to run group commands or control the bot in groups. For group access, configure the channel's explicit group allowlists (for example `groupAllowFrom`, `groups`, or per-group or per-topic overrides depending on the channel). +The pairing allowlist store is for DM access. Group authorization is separate. +Approving a DM pairing code does not automatically allow that sender to run group +commands or control the bot in groups. First-owner bootstrap is separate config +state in `commands.ownerAllowFrom`, and group chat delivery still follows the +channel's group allowlists (for example `groupAllowFrom`, `groups`, or per-group +or per-topic overrides depending on the channel). ## 2) Node device pairing (iOS/Android/macOS/headless nodes) diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 5542403b8d6..edd9c3f962a 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -122,8 +122,9 @@ Token resolution order is account-aware. In practice, config values win over env For one-owner bots, prefer `dmPolicy: "allowlist"` with explicit numeric `allowFrom` IDs to keep access policy durable in config (instead of depending on previous pairing approvals). Common confusion: DM pairing approval does not mean "this sender is authorized everywhere". - Pairing grants DM access only. Group sender authorization still comes from explicit config allowlists. - If you want "I am authorized once and both DMs and group commands work", put your numeric Telegram user ID in `channels.telegram.allowFrom`. + Pairing grants DM access. If no command owner exists yet, the first approved pairing also sets `commands.ownerAllowFrom` so owner-only commands and exec approvals have an explicit operator account. + Group sender authorization still comes from explicit config allowlists. + If you want "I am authorized once and both DMs and group commands work", put your numeric Telegram user ID in `channels.telegram.allowFrom`; for owner-only commands, make sure `commands.ownerAllowFrom` contains `telegram:`. ### Finding your Telegram user ID @@ -777,7 +778,7 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \ Config path: - `channels.telegram.execApprovals.enabled` (auto-enables when at least one approver is resolvable) - - `channels.telegram.execApprovals.approvers` (falls back to numeric owner IDs from `allowFrom` / `defaultTo`) + - `channels.telegram.execApprovals.approvers` (falls back to numeric owner IDs from `commands.ownerAllowFrom`, `allowFrom`, or `defaultTo`) - `channels.telegram.execApprovals.target`: `dm` (default) | `channel` | `both` - `agentFilter`, `sessionFilter` diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index cb02b3d948f..0d4f946e61e 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -51,6 +51,7 @@ Notes: - Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.`. - Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order. - Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. +- Doctor warns when no command owner is configured. The command owner is the human operator account allowed to run owner-only commands and approve dangerous actions. DM pairing only lets someone talk to the bot; if you approved a sender before first-owner bootstrap existed, set `commands.ownerAllowFrom` explicitly. - If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). - If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. - If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early. diff --git a/docs/cli/pairing.md b/docs/cli/pairing.md index 9a6d00b8340..09aec794fc4 100644 --- a/docs/cli/pairing.md +++ b/docs/cli/pairing.md @@ -57,12 +57,19 @@ Options: - `--account `: account id for multi-account channels - `--notify`: send a confirmation back to the requester on the same channel +Owner bootstrap: + +- If `commands.ownerAllowFrom` is empty when you approve a pairing code, OpenClaw also records the approved sender as the command owner, using a channel-scoped entry such as `telegram:123456789`. +- This only bootstraps the first owner. Later pairing approvals do not replace or expand `commands.ownerAllowFrom`. +- The command owner is the human operator account allowed to run owner-only commands and approve dangerous actions such as `/diagnostics`, `/export-trajectory`, `/config`, and exec approvals. + ## Notes - Channel input: pass it positionally (`pairing list telegram`) or with `--channel `. - `pairing list` supports `--account ` for multi-account channels. - `pairing approve` supports `--account ` and `--notify`. - If only one pairing-capable channel is configured, `pairing approve ` is allowed. +- If you approved a sender before this bootstrap existed, run `openclaw doctor`; it warns when no command owner is configured and shows the `openclaw config set commands.ownerAllowFrom ...` command to fix it. ## Related diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 721024fc1b3..4490fdd69c7 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -26,6 +26,17 @@ Scope selection: - `--all-agents`: aggregate all configured agent stores - `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`) +Export a trajectory bundle for a stored session: + +```bash +openclaw sessions export-trajectory --session-key "agent:main:telegram:direct:123" --workspace . +openclaw sessions export-trajectory --session-key "agent:main:telegram:direct:123" --output bug-123 --json +``` + +This is the command path used by the `/export-trajectory` slash command after +the owner approves the exec request. The output directory is always resolved +inside `.openclaw/trajectory-exports/` under the selected workspace. + `openclaw sessions --all-agents` reads configured agent stores. Gateway and ACP session discovery are broader: they also include disk-only stores found under the default `agents/` root or a templated `session.store` root. Those diff --git a/docs/gateway/diagnostics.md b/docs/gateway/diagnostics.md index 02c5f8fae44..10cef77ffee 100644 --- a/docs/gateway/diagnostics.md +++ b/docs/gateway/diagnostics.md @@ -7,9 +7,13 @@ read_when: - Reviewing what diagnostics data is recorded or redacted --- -OpenClaw can create a local diagnostics zip that is safe to attach to bug -reports. It combines sanitized Gateway status, health, logs, config shape, and -recent payload-free stability events. +OpenClaw can create a local diagnostics zip for bug reports. It combines +sanitized Gateway status, health, logs, config shape, and recent payload-free +stability events. + +Treat diagnostics bundles like secrets until you have reviewed them. They are +designed to omit or redact payloads and credentials, but they still summarize +local Gateway logs and host-level runtime state. ## Quick start @@ -29,6 +33,45 @@ For automation: openclaw gateway diagnostics export --json ``` +## Chat command + +Owners can use `/diagnostics [note]` in chat to request a local Gateway export. +Use this when the bug happened in a real conversation and you want one +copy-pasteable report for support: + +1. Send `/diagnostics` in the conversation where you noticed the problem. Add a + short note if it helps, for example `/diagnostics bad tool choice`. +2. OpenClaw sends the diagnostics preamble and asks for one explicit exec + approval. The approval runs `openclaw gateway diagnostics export --json`. + Do not approve diagnostics through an allow-all rule. +3. After approval, OpenClaw replies with a pasteable report containing the local + bundle path, manifest summary, privacy notes, and relevant session ids. + +In group chats, an owner can still run `/diagnostics`, but OpenClaw does not +post the diagnostic details back into the shared chat. It sends the preamble, +approval prompts, Gateway export result, and Codex session/thread breakdown to +the owner through the private approval route. The group only gets a short notice +that the diagnostics flow was sent privately. If OpenClaw cannot find a private +owner route, the command fails closed and asks the owner to run it from a DM. + +When the active OpenClaw session is using the native OpenAI Codex harness, +the same exec approval also covers an OpenAI feedback upload for the Codex +runtime threads OpenClaw knows about. That upload is separate from the local +Gateway zip and appears only for Codex harness sessions. Before approval, the +prompt explains that approving diagnostics will also send Codex feedback, but it +does not list Codex session or thread ids. After approval, the chat reply lists +the channels, OpenClaw session ids, Codex thread ids, and local resume commands +for the threads that were sent to OpenAI servers. If you deny or ignore the +approval, OpenClaw does not run the export, does not send Codex feedback, and +does not print the Codex ids. + +That makes the common Codex debugging loop short: notice the bad behavior in +Telegram, Discord, or another channel, run `/diagnostics`, approve once, share +the report with support, then run the printed `codex resume ` command +locally if you want to inspect the native Codex thread yourself. See +[Codex harness](/plugins/codex-harness#inspect-a-codex-thread-from-the-cli) for +that inspection workflow. + ## What the export contains The zip includes: diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index fd7916d7532..b759c002eee 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -302,6 +302,8 @@ Agents should route user requests by intent, not by the word "Codex" alone: | "Bind this chat to Codex" | `/codex bind` | | "Resume Codex thread `` here" | `/codex resume ` | | "Show Codex threads" | `/codex threads` | +| "File a support report for a bad Codex run" | `/diagnostics [note]` | +| "Only send Codex feedback for this attached thread" | `/codex diagnostics [note]` | | "Use Codex as the runtime for this agent" | config change to `agentRuntime.id` | | "Use my ChatGPT/Codex subscription with normal OpenClaw" | `openai-codex/*` model refs | | "Run Codex through ACP/acpx" | ACP `sessions_spawn({ runtime: "acp", ... })` | @@ -750,17 +752,90 @@ Common forms: - `/codex resume ` attaches the current OpenClaw session to an existing Codex thread. - `/codex compact` asks Codex app-server to compact the attached thread. - `/codex review` starts Codex native review for the attached thread. +- `/codex diagnostics [note]` asks before sending Codex diagnostics feedback for the attached thread. - `/codex computer-use status` checks the configured Computer Use plugin and MCP server. - `/codex computer-use install` installs the configured Computer Use plugin and reloads MCP servers. - `/codex account` shows account and rate-limit status. - `/codex mcp` lists Codex app-server MCP server status. - `/codex skills` lists Codex app-server skills. +### Common debugging workflow + +When a Codex-backed agent does something surprising in Telegram, Discord, Slack, +or another channel, start with the conversation where the problem happened: + +1. Run `/diagnostics bad tool choice after image upload` or another short note + that describes what you saw. +2. Approve the diagnostics request once. The approval creates the local Gateway + diagnostics zip and, because the session is using the Codex harness, also + sends the relevant Codex feedback bundle to OpenAI servers. +3. Copy the completed diagnostics reply into the bug report or support thread. + It includes the local bundle path, privacy summary, OpenClaw session ids, + Codex thread ids, and an `Inspect locally` line for each Codex thread. +4. If you want to debug the run yourself, run the printed `Inspect locally` + command in a terminal. It looks like `codex resume ` and opens the + native Codex thread so you can inspect the conversation, continue it locally, + or ask Codex why it chose a particular tool or plan. + +Use `/codex diagnostics [note]` only when you specifically want the Codex +feedback upload for the currently attached thread without the full OpenClaw +Gateway diagnostics bundle. For most support reports, `/diagnostics [note]` is +the better starting point because it ties the local Gateway state and Codex +thread ids together in one reply. See [Diagnostics export](/gateway/diagnostics) +for the full privacy model and group-chat behavior. + +Core OpenClaw also exposes owner-only `/diagnostics [note]` as the general +Gateway diagnostics command. Its approval prompt shows the sensitive-data +preamble, links to [Diagnostics Export](/gateway/diagnostics), and requests +`openclaw gateway diagnostics export --json` through explicit exec approval +every time. Do not approve diagnostics with an allow-all rule. After approval, +OpenClaw sends a pasteable report with the local bundle path and manifest +summary. When the active OpenClaw session is using the Codex harness, that +same approval also authorizes sending the relevant Codex feedback bundles to +OpenAI servers. The approval prompt says that Codex feedback will be sent, but +it does not list Codex session or thread ids before approval. + +If `/diagnostics` is invoked by an owner in a group chat, OpenClaw keeps the +shared channel clean: the group receives only a short notice, while the +diagnostics preamble, approval prompts, and Codex session/thread ids are sent to +the owner through the private approval route. If there is no private owner route, +OpenClaw refuses the group request and asks the owner to run it from a DM. + +The approved Codex upload calls Codex app-server `feedback/upload` and asks +app-server to include logs for each listed thread and spawned Codex subthreads +when available. The upload goes through Codex's normal feedback path to OpenAI +servers; if Codex feedback is disabled in that app-server, the command returns +the app-server error. The completed diagnostics reply lists the channels, +OpenClaw session ids, Codex thread ids, and local `codex resume ` +commands for the threads that were sent. If you deny or ignore the approval, +OpenClaw does not print those Codex ids. This upload does not replace the local +Gateway diagnostics export. + `/codex resume` writes the same sidecar binding file that the harness uses for normal turns. On the next message, OpenClaw resumes that Codex thread, passes the currently selected OpenClaw model into app-server, and keeps extended history enabled. +### Inspect a Codex thread from the CLI + +The fastest way to understand a bad Codex run is often to open the native Codex +thread directly: + +```sh +codex resume +``` + +Use this when you notice a bug in a channel conversation and want to inspect the +problematic Codex session, continue it locally, or ask Codex why it made a +particular tool or reasoning choice. The easiest path is usually to run +`/diagnostics [note]` first: after you approve it, the completed report lists +each Codex thread and prints an `Inspect locally` command, for example +`codex resume `. You can copy that command directly into a terminal. + +You can also get a thread id from `/codex binding` for the current chat or +`/codex threads [filter]` for recent Codex app-server threads, then run the same +`codex resume` command in your shell. + The command surface requires Codex app-server `0.125.0` or newer. Individual control methods are reported as `unsupported by this Codex app-server` if a future or custom app-server does not expose that JSON-RPC method. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index d24422f7d88..8e387a9803c 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -92,7 +92,7 @@ There are two related systems: Enables `/restart` plus gateway restart tool actions. - Sets the explicit owner allowlist for owner-only command/tool surfaces. Separate from `commands.allowFrom`. + Sets the explicit owner allowlist for owner-only command/tool surfaces. This is the human operator account that can approve dangerous actions and run commands such as `/diagnostics`, `/export-trajectory`, and `/config`. It is separate from `commands.allowFrom` and from DM pairing access. Per-channel: makes owner-only commands require **owner identity** to run on that surface. When `true`, the sender must either match a resolved owner candidate (for example an entry in `commands.ownerAllowFrom` or provider-native owner metadata) or hold internal `operator.admin` scope on an internal message channel. A wildcard entry in channel `allowFrom`, or an empty/unresolved owner-candidate list, is **not** sufficient — owner-only commands fail closed on that channel. Leave this off if you want owner-only commands gated only by `ownerAllowFrom` and the standard command allowlists. @@ -129,7 +129,7 @@ Current source-of-truth: - `/stop` aborts the current run. - `/session idle ` and `/session max-age ` manage thread-binding expiry. - `/export-session [path]` exports the current session to HTML. Alias: `/export`. - - `/export-trajectory [path]` exports a JSONL [trajectory bundle](/tools/trajectory) for the current session. Alias: `/trajectory`. + - `/export-trajectory [path]` asks for exec approval, then exports a JSONL [trajectory bundle](/tools/trajectory) for the current session. Use it when you need the prompt, tool, and transcript timeline for one OpenClaw session. In group chats, the approval prompt and export result go to the owner privately. Alias: `/trajectory`. @@ -150,6 +150,7 @@ Current source-of-truth: - `/commands` shows the generated command catalog. - `/tools [compact|verbose]` shows what the current agent can use right now. - `/status` shows execution/runtime status, including `Execution`/`Runtime` labels and provider usage/quota when available. + - `/diagnostics [note]` is the owner-only support-report flow for Gateway bugs and Codex harness runs. It asks for explicit exec approval every time before running `openclaw gateway diagnostics export --json`; do not approve diagnostics with an allow-all rule. After approval, it sends a pasteable report with the local bundle path, manifest summary, privacy notes, and relevant session ids. In group chats, the approval prompt and report go to the owner privately. When the active session uses the OpenAI Codex harness, the same approval also sends relevant Codex feedback to OpenAI servers and the completed reply lists the OpenClaw session ids, Codex thread ids, and `codex resume ` commands. See [Diagnostics Export](/gateway/diagnostics). - `/crestodian ` runs the Crestodian setup and repair helper from an owner DM. - `/tasks` lists active/recent background tasks for the current session. - `/context [list|detail|json]` explains how context is assembled. @@ -221,7 +222,7 @@ Bundled plugins can add more slash commands. Current bundled commands in this re - `/phone status|arm [duration]|disarm` temporarily arms high-risk phone node commands. - `/voice status|list [limit]|set ` manages Talk voice config. On Discord, the native command name is `/talkvoice`. - `/card ...` sends LINE rich card presets. See [LINE](/channels/line). -- `/codex status|models|threads|resume|compact|review|account|mcp|skills` inspects and controls the bundled Codex app-server harness. See [Codex harness](/plugins/codex-harness). +- `/codex status|models|threads|resume|compact|review|diagnostics|account|mcp|skills` inspects and controls the bundled Codex app-server harness. See [Codex harness](/plugins/codex-harness). - QQBot-only commands: - `/bot-ping` - `/bot-version` diff --git a/docs/tools/trajectory.md b/docs/tools/trajectory.md index a9a9e34ebb1..27699d9ee04 100644 --- a/docs/tools/trajectory.md +++ b/docs/tools/trajectory.md @@ -20,6 +20,13 @@ Use it when you need to answer questions like: - Which model, plugins, skills, and runtime settings were active? - What usage and prompt-cache metadata did the provider return? +If you are filing a broad support report for a live Gateway issue, start with +[`/diagnostics`](/gateway/diagnostics#chat-command). Diagnostics collects the +sanitized Gateway bundle and, for OpenAI Codex harness sessions, can also send +Codex feedback to OpenAI servers after approval. Use `/export-trajectory` when +you specifically need the detailed per-session prompt, tool, and transcript +timeline. + ## Quick start Send this in the active session: @@ -49,6 +56,20 @@ You can choose a relative output directory name: The custom path is resolved inside `.openclaw/trajectory-exports/`. Absolute paths and `~` paths are rejected. +Trajectory bundles can contain prompts, model messages, tool schemas, tool +results, runtime events, and local paths. The chat slash command therefore runs +through exec approval every time. Approve the export once when you intend to +create the bundle; do not use allow-all. In group chats, OpenClaw sends the +approval prompt and export result to the owner privately instead of posting the +trajectory details back to the shared room. + +For local inspection or support workflows, you can also run the approved command +path directly: + +```bash +openclaw sessions export-trajectory --session-key "agent:main:telegram:direct:123" --workspace . +``` + ## Access Trajectory export is an owner command. The sender must pass the normal command diff --git a/extensions/codex/src/app-server/capabilities.ts b/extensions/codex/src/app-server/capabilities.ts index 7d9a751e70e..40377b95758 100644 --- a/extensions/codex/src/app-server/capabilities.ts +++ b/extensions/codex/src/app-server/capabilities.ts @@ -3,6 +3,7 @@ import { CodexAppServerRpcError } from "./client.js"; export const CODEX_CONTROL_METHODS = { account: "account/read", compact: "thread/compact/start", + feedback: "feedback/upload", listMcpServers: "mcpServerStatus/list", listSkills: "skills/list", listThreads: "thread/list", diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index e99583b21a9..67efe4a9b5a 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -102,6 +102,7 @@ type CodexAppServerRequestResultMap = { initialize: CodexInitializeResponse; "account/rateLimits/read": v2.GetAccountRateLimitsResponse; "account/read": v2.GetAccountResponse; + "feedback/upload": v2.FeedbackUploadResponse; "mcpServerStatus/list": v2.ListMcpServerStatusResponse; "model/list": v2.ModelListResponse; "review/start": v2.ReviewStartResponse; diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index 033d18af243..54169b98fbb 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -148,6 +148,7 @@ export function buildHelp(): string { "- /codex detach", "- /codex compact", "- /codex review", + "- /codex diagnostics [note]", "- /codex computer-use [status|install]", "- /codex account", "- /codex mcp", diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 38a5e1b1ba2..504cac54058 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js"; import { @@ -116,6 +117,63 @@ type ParsedComputerUseArgs = { help?: boolean; }; +type ParsedDiagnosticsArgs = + | { action: "request"; note: string } + | { action: "confirm"; token: string } + | { action: "cancel"; token: string }; + +type CodexDiagnosticsTarget = { + threadId: string; + sessionFile: string; + sessionKey?: string; + sessionId?: string; + channel?: string; + channelId?: string; + accountId?: string; + messageThreadId?: string | number; + threadParentId?: string; +}; + +type PendingCodexDiagnosticsConfirmation = { + token: string; + targets: CodexDiagnosticsTarget[]; + note?: string; + senderId: string; + channel: string; + accountId?: string; + channelId?: string; + messageThreadId?: string; + threadParentId?: string; + sessionKey?: string; + scopeKey: string; + privateRouted?: boolean; + createdAt: number; +}; + +const CODEX_DIAGNOSTICS_SOURCE = "openclaw-diagnostics"; +const CODEX_DIAGNOSTICS_REASON_MAX_CHARS = 2048; +const CODEX_DIAGNOSTICS_COOLDOWN_MS = 60_000; +const CODEX_DIAGNOSTICS_ERROR_MAX_CHARS = 500; +const CODEX_DIAGNOSTICS_COOLDOWN_MAX_THREADS = 100; +const CODEX_DIAGNOSTICS_COOLDOWN_MAX_SCOPES = 100; +const CODEX_DIAGNOSTICS_CONFIRMATION_TTL_MS = 5 * 60_000; +const CODEX_DIAGNOSTICS_CONFIRMATION_MAX_REQUESTS_PER_SCOPE = 100; +const CODEX_DIAGNOSTICS_CONFIRMATION_MAX_SCOPES = 100; +const CODEX_DIAGNOSTICS_SCOPE_FIELD_MAX_CHARS = 128; +const CODEX_RESUME_SAFE_THREAD_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; + +const lastCodexDiagnosticsUploadByThread = new Map(); +const lastCodexDiagnosticsUploadByScope = new Map(); +const pendingCodexDiagnosticsConfirmations = new Map(); +const pendingCodexDiagnosticsConfirmationTokensByScope = new Map(); + +export function resetCodexDiagnosticsFeedbackStateForTests(): void { + lastCodexDiagnosticsUploadByThread.clear(); + lastCodexDiagnosticsUploadByScope.clear(); + pendingCodexDiagnosticsConfirmations.clear(); + pendingCodexDiagnosticsConfirmationTokensByScope.clear(); +} + export async function handleCodexSubcommand( ctx: PluginCommandContext, options: { pluginConfig?: unknown; deps?: Partial }, @@ -188,6 +246,15 @@ export async function handleCodexSubcommand( ), }; } + if (normalized === "diagnostics") { + return await handleCodexDiagnosticsFeedback( + deps, + ctx, + options.pluginConfig, + rest.join(" "), + "/codex diagnostics", + ); + } if (normalized === "computer-use" || normalized === "computeruse") { return { text: await handleComputerUseCommand(deps, options.pluginConfig, rest), @@ -484,6 +551,834 @@ async function resolveControlSessionFile(ctx: PluginCommandContext): Promise { + if (ctx.senderIsOwner !== true) { + return { text: "Only an owner can send Codex diagnostics." }; + } + const parsed = parseDiagnosticsArgs(args); + if (parsed.action === "confirm") { + return { + text: await confirmCodexDiagnosticsFeedback(deps, ctx, pluginConfig, parsed.token), + }; + } + if (parsed.action === "cancel") { + return { text: cancelCodexDiagnosticsFeedback(ctx, parsed.token) }; + } + if (ctx.diagnosticsUploadApproved === true) { + return { + text: await sendCodexDiagnosticsFeedbackForContext(deps, ctx, pluginConfig, parsed.note), + }; + } + if (ctx.diagnosticsPreviewOnly === true) { + return { + text: await previewCodexDiagnosticsFeedbackApproval(deps, ctx, parsed.note), + }; + } + return await requestCodexDiagnosticsFeedbackApproval( + deps, + ctx, + pluginConfig, + parsed.note, + commandPrefix, + ); +} + +async function requestCodexDiagnosticsFeedbackApproval( + deps: CodexCommandDeps, + ctx: PluginCommandContext, + pluginConfig: unknown, + note: string, + commandPrefix: string, +): Promise { + if (!(await hasAnyCodexDiagnosticsSessionFile(ctx))) { + return { + text: "Cannot send Codex diagnostics because this command did not include an OpenClaw session file.", + }; + } + const targets = await resolveCodexDiagnosticsTargets(deps, ctx); + if (targets.length === 0) { + return { + text: [ + "No Codex thread is attached to this OpenClaw session yet.", + "Use /codex threads to find a thread, then /codex resume before sending diagnostics.", + ].join("\n"), + }; + } + const now = Date.now(); + const cooldownMessage = readCodexDiagnosticsTargetsCooldownMessage(targets, ctx, now); + if (cooldownMessage) { + return { text: cooldownMessage }; + } + if (!ctx.senderId) { + return { + text: "Cannot send Codex diagnostics because this command did not include a sender identity.", + }; + } + const reason = normalizeDiagnosticsReason(note); + const token = createCodexDiagnosticsConfirmation({ + targets, + note: reason, + senderId: ctx.senderId, + channel: ctx.channel, + scopeKey: readCodexDiagnosticsCooldownScope(ctx), + privateRouted: ctx.diagnosticsPrivateRouted === true, + ...readCodexDiagnosticsConfirmationScope(ctx), + now, + }); + const confirmCommand = `${commandPrefix} confirm ${token}`; + const cancelCommand = `${commandPrefix} cancel ${token}`; + const displayReason = reason ? escapeCodexChatText(formatCodexTextForDisplay(reason)) : undefined; + const lines = [ + targets.length === 1 ? "Codex runtime thread detected." : "Codex runtime threads detected.", + `Codex diagnostics can send ${targets.length === 1 ? "this thread's feedback bundle" : "these threads' feedback bundles"} to OpenAI servers.`, + "Codex sessions:", + ...formatCodexDiagnosticsTargetLines(targets), + ...(displayReason ? [`Note: ${displayReason}`] : []), + "Included: Codex logs and spawned Codex subthreads when available.", + `To send: ${confirmCommand}`, + `To cancel: ${cancelCommand}`, + "This request expires in 5 minutes.", + ]; + return { + text: lines.join("\n"), + interactive: { + blocks: [ + { + type: "buttons", + buttons: [ + { label: "Send diagnostics", value: confirmCommand, style: "danger" }, + { label: "Cancel", value: cancelCommand, style: "secondary" }, + ], + }, + ], + }, + }; +} + +async function previewCodexDiagnosticsFeedbackApproval( + deps: CodexCommandDeps, + ctx: PluginCommandContext, + note: string, +): Promise { + if (!(await hasAnyCodexDiagnosticsSessionFile(ctx))) { + return "Cannot send Codex diagnostics because this command did not include an OpenClaw session file."; + } + const targets = await resolveCodexDiagnosticsTargets(deps, ctx); + if (targets.length === 0) { + return [ + "No Codex thread is attached to this OpenClaw session yet.", + "Use /codex threads to find a thread, then /codex resume before sending diagnostics.", + ].join("\n"); + } + const cooldownMessage = readCodexDiagnosticsTargetsCooldownMessage(targets, ctx, Date.now(), { + includeThreadId: false, + }); + if (cooldownMessage) { + return cooldownMessage; + } + const reason = normalizeDiagnosticsReason(note); + const displayReason = reason ? escapeCodexChatText(formatCodexTextForDisplay(reason)) : undefined; + return [ + targets.length === 1 ? "Codex runtime thread detected." : "Codex runtime threads detected.", + `Approving diagnostics will also send ${targets.length === 1 ? "this thread's feedback bundle" : "these threads' feedback bundles"} to OpenAI servers.`, + "The completed diagnostics reply will list the OpenClaw session ids and Codex thread ids that were sent.", + ...(displayReason ? [`Note: ${displayReason}`] : []), + "Included: Codex logs and spawned Codex subthreads when available.", + ].join("\n"); +} + +async function confirmCodexDiagnosticsFeedback( + deps: CodexCommandDeps, + ctx: PluginCommandContext, + pluginConfig: unknown, + token: string, +): Promise { + const pending = readPendingCodexDiagnosticsConfirmation(token, Date.now()); + if (!pending) { + return "No pending Codex diagnostics confirmation was found. Run /diagnostics again to create a fresh request."; + } + if (!pending.senderId || !ctx.senderId) { + return "Cannot confirm Codex diagnostics because this command did not include the original sender identity."; + } + if (pending.senderId !== ctx.senderId) { + return "Only the user who requested these Codex diagnostics can confirm the upload."; + } + if (pending.channel !== ctx.channel) { + return "This Codex diagnostics confirmation belongs to a different channel."; + } + const scopeMismatch = readCodexDiagnosticsScopeMismatch(pending, ctx); + if (scopeMismatch) { + return scopeMismatch.confirmMessage; + } + deletePendingCodexDiagnosticsConfirmation(token); + if (!pending.privateRouted && !(await hasAnyCodexDiagnosticsSessionFile(ctx))) { + return "Cannot send Codex diagnostics because this command did not include an OpenClaw session file."; + } + const currentTargets = pending.privateRouted + ? await resolvePendingCodexDiagnosticsTargets(deps, pending.targets) + : await resolveCodexDiagnosticsTargets(deps, ctx); + if (!codexDiagnosticsTargetsMatch(pending.targets, currentTargets)) { + return "The Codex diagnostics sessions changed before confirmation. Run /diagnostics again for the current threads."; + } + return await sendCodexDiagnosticsFeedbackForTargets( + deps, + ctx, + pluginConfig, + pending.note ?? "", + pending.targets, + ); +} + +function cancelCodexDiagnosticsFeedback(ctx: PluginCommandContext, token: string): string { + const pending = readPendingCodexDiagnosticsConfirmation(token, Date.now()); + if (!pending) { + return "No pending Codex diagnostics confirmation was found."; + } + if (!pending.senderId || !ctx.senderId) { + return "Cannot cancel Codex diagnostics because this command did not include the original sender identity."; + } + if (pending.senderId !== ctx.senderId) { + return "Only the user who requested these Codex diagnostics can cancel the upload."; + } + if (pending.channel !== ctx.channel) { + return "This Codex diagnostics confirmation belongs to a different channel."; + } + const scopeMismatch = readCodexDiagnosticsScopeMismatch(pending, ctx); + if (scopeMismatch) { + return scopeMismatch.cancelMessage; + } + deletePendingCodexDiagnosticsConfirmation(token); + return [ + "Codex diagnostics upload canceled.", + "Codex sessions:", + ...formatCodexDiagnosticsTargetLines(pending.targets), + ].join("\n"); +} + +async function sendCodexDiagnosticsFeedbackForContext( + deps: CodexCommandDeps, + ctx: PluginCommandContext, + pluginConfig: unknown, + note: string, +): Promise { + if (!(await hasAnyCodexDiagnosticsSessionFile(ctx))) { + return "Cannot send Codex diagnostics because this command did not include an OpenClaw session file."; + } + const targets = await resolveCodexDiagnosticsTargets(deps, ctx); + if (targets.length === 0) { + return [ + "No Codex thread is attached to this OpenClaw session yet.", + "Use /codex threads to find a thread, then /codex resume before sending diagnostics.", + ].join("\n"); + } + return await sendCodexDiagnosticsFeedbackForTargets(deps, ctx, pluginConfig, note, targets); +} + +async function sendCodexDiagnosticsFeedbackForTargets( + deps: CodexCommandDeps, + ctx: PluginCommandContext, + pluginConfig: unknown, + note: string, + targets: CodexDiagnosticsTarget[], +): Promise { + if (targets.length === 0) { + return [ + "No Codex thread is attached to this OpenClaw session yet.", + "Use /codex threads to find a thread, then /codex resume before sending diagnostics.", + ].join("\n"); + } + const now = Date.now(); + const cooldownMessage = readCodexDiagnosticsTargetsCooldownMessage(targets, ctx, now); + if (cooldownMessage) { + return cooldownMessage; + } + const reason = normalizeDiagnosticsReason(note); + const sent: CodexDiagnosticsTarget[] = []; + const failed: Array<{ target: CodexDiagnosticsTarget; error: string }> = []; + for (const target of targets) { + const response = await deps.safeCodexControlRequest( + pluginConfig, + CODEX_CONTROL_METHODS.feedback, + { + classification: "bug", + threadId: target.threadId, + includeLogs: true, + tags: buildDiagnosticsTags(ctx), + ...(reason ? { reason } : {}), + }, + ); + if (!response.ok) { + failed.push({ target, error: response.error }); + continue; + } + const responseThreadId = isJsonObject(response.value) + ? readString(response.value, "threadId") + : undefined; + sent.push({ ...target, threadId: responseThreadId ?? target.threadId }); + recordCodexDiagnosticsUpload(target.threadId, ctx, now); + } + return formatCodexDiagnosticsUploadResult(sent, failed); +} + +async function hasAnyCodexDiagnosticsSessionFile(ctx: PluginCommandContext): Promise { + if (await resolveControlSessionFile(ctx)) { + return true; + } + return (ctx.diagnosticsSessions ?? []).some((session) => Boolean(session.sessionFile)); +} + +async function resolveCodexDiagnosticsTargets( + deps: CodexCommandDeps, + ctx: PluginCommandContext, +): Promise { + const activeSessionFile = await resolveControlSessionFile(ctx); + const candidates: CodexDiagnosticsTarget[] = []; + if (activeSessionFile) { + candidates.push({ + threadId: "", + sessionFile: activeSessionFile, + sessionKey: ctx.sessionKey, + sessionId: ctx.sessionId, + channel: ctx.channel, + channelId: ctx.channelId, + accountId: ctx.accountId, + messageThreadId: ctx.messageThreadId, + threadParentId: ctx.threadParentId, + }); + } + for (const session of ctx.diagnosticsSessions ?? []) { + if (!session.sessionFile) { + continue; + } + candidates.push({ + threadId: "", + sessionFile: session.sessionFile, + sessionKey: session.sessionKey, + sessionId: session.sessionId, + channel: session.channel, + channelId: session.channelId, + accountId: session.accountId, + messageThreadId: session.messageThreadId, + threadParentId: session.threadParentId, + }); + } + const seenSessionFiles = new Set(); + const seenThreadIds = new Set(); + const targets: CodexDiagnosticsTarget[] = []; + for (const candidate of candidates) { + if (seenSessionFiles.has(candidate.sessionFile)) { + continue; + } + seenSessionFiles.add(candidate.sessionFile); + const binding = await deps.readCodexAppServerBinding(candidate.sessionFile); + if (!binding?.threadId || seenThreadIds.has(binding.threadId)) { + continue; + } + seenThreadIds.add(binding.threadId); + targets.push({ ...candidate, threadId: binding.threadId }); + } + return targets; +} + +async function resolvePendingCodexDiagnosticsTargets( + deps: CodexCommandDeps, + targets: readonly CodexDiagnosticsTarget[], +): Promise { + const resolved: CodexDiagnosticsTarget[] = []; + for (const target of targets) { + const binding = await deps.readCodexAppServerBinding(target.sessionFile); + if (!binding?.threadId) { + continue; + } + resolved.push({ ...target, threadId: binding.threadId }); + } + return resolved; +} + +function codexDiagnosticsTargetsMatch( + expected: readonly CodexDiagnosticsTarget[], + actual: readonly CodexDiagnosticsTarget[], +): boolean { + const expectedThreadIds = expected.map((target) => target.threadId).toSorted(); + const actualThreadIds = actual.map((target) => target.threadId).toSorted(); + return ( + expectedThreadIds.length === actualThreadIds.length && + expectedThreadIds.every((threadId, index) => threadId === actualThreadIds[index]) + ); +} + +function formatCodexDiagnosticsUploadResult( + sent: readonly CodexDiagnosticsTarget[], + failed: ReadonlyArray<{ target: CodexDiagnosticsTarget; error: string }>, +): string { + const lines: string[] = []; + if (sent.length > 0) { + lines.push("Codex diagnostics sent to OpenAI servers:"); + lines.push(...formatCodexDiagnosticsTargetLines(sent)); + lines.push("Included Codex logs and spawned Codex subthreads when available."); + } + if (failed.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push("Could not send Codex diagnostics:"); + lines.push( + ...failed.map( + ({ target, error }) => + `${formatCodexDiagnosticsTargetLine(target)}: ${formatCodexErrorForDisplay(error)}`, + ), + ); + lines.push("Inspect locally:"); + lines.push( + ...failed.map(({ target }) => `- ${formatCodexResumeCommandForDisplay(target.threadId)}`), + ); + } + return lines.join("\n"); +} + +function formatCodexDiagnosticsTargetLines(targets: readonly CodexDiagnosticsTarget[]): string[] { + return targets.flatMap((target, index) => { + const lines = formatCodexDiagnosticsTargetBlock(target, index); + return index < targets.length - 1 ? [...lines, ""] : lines; + }); +} + +function formatCodexDiagnosticsTargetBlock( + target: CodexDiagnosticsTarget, + index: number, +): string[] { + const lines = [`Session ${index + 1}`]; + if (target.channel) { + lines.push(`Channel: ${formatCodexValueForDisplay(target.channel)}`); + } + if (target.sessionKey) { + lines.push(`OpenClaw session key: ${formatCodexCopyableValueForDisplay(target.sessionKey)}`); + } + if (target.sessionId) { + lines.push(`OpenClaw session id: ${formatCodexCopyableValueForDisplay(target.sessionId)}`); + } + lines.push(`Codex thread id: ${formatCodexCopyableValueForDisplay(target.threadId)}`); + lines.push(`Inspect locally: ${formatCodexResumeCommandForDisplay(target.threadId)}`); + return lines; +} + +function formatCodexDiagnosticsTargetLine(target: CodexDiagnosticsTarget): string { + const parts: string[] = []; + if (target.channel) { + parts.push(`channel ${formatCodexValueForDisplay(target.channel)}`); + } + const sessionLabel = target.sessionId || target.sessionKey; + if (sessionLabel) { + parts.push(`OpenClaw session ${formatCodexValueForDisplay(sessionLabel)}`); + } + parts.push(`Codex thread ${formatCodexThreadIdForDisplay(target.threadId)}`); + return `- ${parts.join(", ")}`; +} + +function normalizeDiagnosticsReason(note: string): string | undefined { + const normalized = normalizeOptionalString(note); + return normalized ? normalized.slice(0, CODEX_DIAGNOSTICS_REASON_MAX_CHARS) : undefined; +} + +function parseDiagnosticsArgs(args: string): ParsedDiagnosticsArgs { + const [action, token] = splitArgs(args); + const normalizedAction = action?.toLowerCase(); + if ((normalizedAction === "confirm" || normalizedAction === "--confirm") && token) { + return { action: "confirm", token }; + } + if ((normalizedAction === "cancel" || normalizedAction === "--cancel") && token) { + return { action: "cancel", token }; + } + return { action: "request", note: args }; +} + +function createCodexDiagnosticsConfirmation(params: { + targets: CodexDiagnosticsTarget[]; + note?: string; + senderId: string; + channel: string; + accountId?: string; + channelId?: string; + messageThreadId?: string; + threadParentId?: string; + sessionKey?: string; + scopeKey: string; + privateRouted?: boolean; + now: number; +}): string { + prunePendingCodexDiagnosticsConfirmations(params.now); + if ( + !pendingCodexDiagnosticsConfirmationTokensByScope.has(params.scopeKey) && + pendingCodexDiagnosticsConfirmationTokensByScope.size >= + CODEX_DIAGNOSTICS_CONFIRMATION_MAX_SCOPES + ) { + const oldestScopeKey = pendingCodexDiagnosticsConfirmationTokensByScope.keys().next().value; + if (typeof oldestScopeKey === "string") { + deletePendingCodexDiagnosticsConfirmationScope(oldestScopeKey); + } + } + const scopeTokens = pendingCodexDiagnosticsConfirmationTokensByScope.get(params.scopeKey) ?? []; + while (scopeTokens.length >= CODEX_DIAGNOSTICS_CONFIRMATION_MAX_REQUESTS_PER_SCOPE) { + const oldestToken = scopeTokens.shift(); + if (!oldestToken) { + break; + } + pendingCodexDiagnosticsConfirmations.delete(oldestToken); + } + const token = crypto.randomBytes(6).toString("hex"); + scopeTokens.push(token); + pendingCodexDiagnosticsConfirmationTokensByScope.set(params.scopeKey, scopeTokens); + pendingCodexDiagnosticsConfirmations.set(token, { + token, + targets: params.targets, + note: params.note, + senderId: params.senderId, + channel: params.channel, + accountId: params.accountId, + channelId: params.channelId, + messageThreadId: params.messageThreadId, + threadParentId: params.threadParentId, + sessionKey: params.sessionKey, + scopeKey: params.scopeKey, + ...(params.privateRouted === undefined ? {} : { privateRouted: params.privateRouted }), + createdAt: params.now, + }); + return token; +} + +function readCodexDiagnosticsConfirmationScope(ctx: PluginCommandContext): { + accountId?: string; + channelId?: string; + messageThreadId?: string; + threadParentId?: string; + sessionKey?: string; +} { + return { + accountId: normalizeCodexDiagnosticsScopeField(ctx.accountId), + channelId: normalizeCodexDiagnosticsScopeField(ctx.channelId), + messageThreadId: + typeof ctx.messageThreadId === "string" || typeof ctx.messageThreadId === "number" + ? normalizeCodexDiagnosticsScopeField(String(ctx.messageThreadId)) + : undefined, + threadParentId: normalizeCodexDiagnosticsScopeField(ctx.threadParentId), + sessionKey: normalizeCodexDiagnosticsScopeField(ctx.sessionKey), + }; +} + +function readCodexDiagnosticsScopeMismatch( + pending: PendingCodexDiagnosticsConfirmation, + ctx: PluginCommandContext, +): + | { + confirmMessage: string; + cancelMessage: string; + } + | undefined { + const current = readCodexDiagnosticsConfirmationScope(ctx); + if (pending.accountId !== current.accountId) { + return { + confirmMessage: "This Codex diagnostics confirmation belongs to a different account.", + cancelMessage: "This Codex diagnostics confirmation belongs to a different account.", + }; + } + if (pending.privateRouted) { + return undefined; + } + if (pending.channelId !== current.channelId) { + return { + confirmMessage: + "This Codex diagnostics confirmation belongs to a different channel instance.", + cancelMessage: "This Codex diagnostics confirmation belongs to a different channel instance.", + }; + } + if (pending.messageThreadId !== current.messageThreadId) { + return { + confirmMessage: "This Codex diagnostics confirmation belongs to a different thread.", + cancelMessage: "This Codex diagnostics confirmation belongs to a different thread.", + }; + } + if (pending.threadParentId !== current.threadParentId) { + return { + confirmMessage: "This Codex diagnostics confirmation belongs to a different parent thread.", + cancelMessage: "This Codex diagnostics confirmation belongs to a different parent thread.", + }; + } + if (pending.sessionKey !== current.sessionKey) { + return { + confirmMessage: "This Codex diagnostics confirmation belongs to a different session.", + cancelMessage: "This Codex diagnostics confirmation belongs to a different session.", + }; + } + return undefined; +} + +function readPendingCodexDiagnosticsConfirmation( + token: string, + now: number, +): PendingCodexDiagnosticsConfirmation | undefined { + prunePendingCodexDiagnosticsConfirmations(now); + return pendingCodexDiagnosticsConfirmations.get(token); +} + +function prunePendingCodexDiagnosticsConfirmations(now: number): void { + for (const [token, pending] of pendingCodexDiagnosticsConfirmations) { + if (now - pending.createdAt >= CODEX_DIAGNOSTICS_CONFIRMATION_TTL_MS) { + deletePendingCodexDiagnosticsConfirmation(token); + } + } +} + +function deletePendingCodexDiagnosticsConfirmation(token: string): void { + const pending = pendingCodexDiagnosticsConfirmations.get(token); + pendingCodexDiagnosticsConfirmations.delete(token); + if (!pending) { + return; + } + const scopeTokens = pendingCodexDiagnosticsConfirmationTokensByScope.get(pending.scopeKey); + if (!scopeTokens) { + return; + } + const tokenIndex = scopeTokens.indexOf(token); + if (tokenIndex >= 0) { + scopeTokens.splice(tokenIndex, 1); + } + if (scopeTokens.length === 0) { + pendingCodexDiagnosticsConfirmationTokensByScope.delete(pending.scopeKey); + } +} + +function deletePendingCodexDiagnosticsConfirmationScope(scopeKey: string): void { + const scopeTokens = pendingCodexDiagnosticsConfirmationTokensByScope.get(scopeKey) ?? []; + for (const token of scopeTokens) { + pendingCodexDiagnosticsConfirmations.delete(token); + } + pendingCodexDiagnosticsConfirmationTokensByScope.delete(scopeKey); +} + +function buildDiagnosticsTags(ctx: PluginCommandContext): Record { + const tags: Record = { + source: CODEX_DIAGNOSTICS_SOURCE, + }; + addTag(tags, "channel", ctx.channel); + return tags; +} + +function addTag(tags: Record, key: string, value: unknown): void { + if (typeof value === "string" && value.trim()) { + tags[key] = value.trim(); + } +} + +function formatCodexThreadIdForDisplay(threadId: string): string { + return escapeCodexChatText(formatCodexTextForDisplay(threadId)); +} + +function formatCodexValueForDisplay(value: string): string { + return escapeCodexChatText(formatCodexTextForDisplay(value)); +} + +function formatCodexCopyableValueForDisplay(value: string): string { + const safe = formatCodexTextForDisplay(value); + if (CODEX_RESUME_SAFE_THREAD_ID_PATTERN.test(safe)) { + return `\`${safe}\``; + } + return escapeCodexChatText(safe); +} + +function formatCodexTextForDisplay(value: string): string { + let safe = ""; + for (const character of value) { + const codePoint = character.codePointAt(0); + safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character; + } + safe = safe.trim(); + return safe || ""; +} + +function escapeCodexChatText(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("@", "\uff20") + .replaceAll("`", "\uff40") + .replaceAll("[", "\uff3b") + .replaceAll("]", "\uff3d") + .replaceAll("(", "\uff08") + .replaceAll(")", "\uff09") + .replaceAll("*", "\u2217") + .replaceAll("_", "\uff3f") + .replaceAll("~", "\uff5e") + .replaceAll("|", "\uff5c"); +} + +function readCodexDiagnosticsCooldownMs(threadId: string, now: number): number { + const lastSentAt = lastCodexDiagnosticsUploadByThread.get(threadId); + if (!lastSentAt) { + return 0; + } + const remainingMs = Math.max(0, CODEX_DIAGNOSTICS_COOLDOWN_MS - (now - lastSentAt)); + if (remainingMs === 0) { + lastCodexDiagnosticsUploadByThread.delete(threadId); + } + return remainingMs; +} + +function readCodexDiagnosticsTargetsCooldownMessage( + targets: readonly CodexDiagnosticsTarget[], + ctx: PluginCommandContext, + now: number, + options: { includeThreadId?: boolean } = {}, +): string | undefined { + for (const target of targets) { + const cooldownMs = readCodexDiagnosticsCooldownMs(target.threadId, now); + if (cooldownMs > 0) { + if (options.includeThreadId === false) { + return `Codex diagnostics were already sent for one of these Codex threads recently. Try again in ${Math.ceil( + cooldownMs / 1000, + )}s.`; + } + const displayThreadId = formatCodexThreadIdForDisplay(target.threadId); + return `Codex diagnostics were already sent for thread ${displayThreadId} recently. Try again in ${Math.ceil( + cooldownMs / 1000, + )}s.`; + } + } + const scopeCooldownMs = readCodexDiagnosticsScopeCooldownMs( + readCodexDiagnosticsCooldownScope(ctx), + now, + ); + if (scopeCooldownMs > 0) { + return `Codex diagnostics were already sent for this account or channel recently. Try again in ${Math.ceil( + scopeCooldownMs / 1000, + )}s.`; + } + return undefined; +} + +function readCodexDiagnosticsScopeCooldownMs(scope: string, now: number): number { + const lastSentAt = lastCodexDiagnosticsUploadByScope.get(scope); + if (!lastSentAt) { + return 0; + } + const remainingMs = Math.max(0, CODEX_DIAGNOSTICS_COOLDOWN_MS - (now - lastSentAt)); + if (remainingMs === 0) { + lastCodexDiagnosticsUploadByScope.delete(scope); + } + return remainingMs; +} + +function recordCodexDiagnosticsUpload( + threadId: string, + ctx: PluginCommandContext, + now: number, +): void { + pruneCodexDiagnosticsCooldowns(now); + recordBoundedCodexDiagnosticsCooldown( + lastCodexDiagnosticsUploadByScope, + readCodexDiagnosticsCooldownScope(ctx), + CODEX_DIAGNOSTICS_COOLDOWN_MAX_SCOPES, + now, + ); + recordBoundedCodexDiagnosticsCooldown( + lastCodexDiagnosticsUploadByThread, + threadId, + CODEX_DIAGNOSTICS_COOLDOWN_MAX_THREADS, + now, + ); +} + +function recordBoundedCodexDiagnosticsCooldown( + map: Map, + key: string, + maxSize: number, + now: number, +): void { + if (!map.has(key)) { + while (map.size >= maxSize) { + const oldestKey = map.keys().next().value; + if (typeof oldestKey !== "string") { + break; + } + map.delete(oldestKey); + } + } + map.set(key, now); +} + +function readCodexDiagnosticsCooldownScope(ctx: PluginCommandContext): string { + const scope = readCodexDiagnosticsConfirmationScope(ctx); + const payload = JSON.stringify({ + accountId: scope.accountId ?? null, + channelId: scope.channelId ?? null, + sessionKey: scope.sessionKey ?? null, + messageThreadId: scope.messageThreadId ?? null, + threadParentId: scope.threadParentId ?? null, + senderId: normalizeCodexDiagnosticsScopeField(ctx.senderId) ?? null, + channel: normalizeCodexDiagnosticsScopeField(ctx.channel) ?? "", + }); + return crypto.createHash("sha256").update(payload).digest("hex"); +} + +function pruneCodexDiagnosticsCooldowns(now: number): void { + pruneCodexDiagnosticsCooldownMap(lastCodexDiagnosticsUploadByThread, now); + pruneCodexDiagnosticsCooldownMap(lastCodexDiagnosticsUploadByScope, now); +} + +function pruneCodexDiagnosticsCooldownMap(map: Map, now: number): void { + for (const [key, lastSentAt] of map) { + if (now - lastSentAt >= CODEX_DIAGNOSTICS_COOLDOWN_MS) { + map.delete(key); + } + } +} + +function formatCodexErrorForDisplay(error: string): string { + const safe = formatCodexTextForDisplay(error).slice(0, CODEX_DIAGNOSTICS_ERROR_MAX_CHARS); + return escapeCodexChatText(safe) || "unknown error"; +} + +function formatCodexResumeCommandForDisplay(threadId: string): string { + const safeThreadId = formatCodexTextForDisplay(threadId); + if (!CODEX_RESUME_SAFE_THREAD_ID_PATTERN.test(safeThreadId)) { + return "run codex resume and paste the thread id shown above"; + } + return `\`codex resume ${safeThreadId}\``; +} + +function isUnsafeDisplayCodePoint(codePoint: number): boolean { + return ( + codePoint <= 0x001f || + (codePoint >= 0x007f && codePoint <= 0x009f) || + codePoint === 0x00ad || + codePoint === 0x061c || + codePoint === 0x180e || + (codePoint >= 0x200b && codePoint <= 0x200f) || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2060 && codePoint <= 0x206f) || + codePoint === 0xfeff || + (codePoint >= 0xfff9 && codePoint <= 0xfffb) || + (codePoint >= 0xe0000 && codePoint <= 0xe007f) + ); +} + +function normalizeCodexDiagnosticsScopeField(value: string | undefined): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + if (normalized.length <= CODEX_DIAGNOSTICS_SCOPE_FIELD_MAX_CHARS) { + return normalized; + } + return `sha256:${crypto.createHash("sha256").update(normalized).digest("hex")}`; +} + async function startThreadAction( deps: CodexCommandDeps, ctx: PluginCommandContext, diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 591fdcc5edd..dc6e7b74846 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -1,13 +1,16 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; +import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; import type { CodexComputerUseStatus } from "./app-server/computer-use.js"; import type { CodexAppServerStartOptions } from "./app-server/config.js"; import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js"; -import type { CodexCommandDeps } from "./command-handlers.js"; +import { + resetCodexDiagnosticsFeedbackStateForTests, + type CodexCommandDeps, +} from "./command-handlers.js"; import { handleCodexCommand } from "./commands.js"; let tempDir: string; @@ -20,6 +23,8 @@ function createContext( return { channel: "test", isAuthorizedSender: true, + senderIsOwner: true, + senderId: "user-1", args, commandBody: `/codex ${args}`, config: {}, @@ -51,12 +56,44 @@ function createDeps(overrides: Partial = {}): Partial { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-command-")); }); afterEach(async () => { + resetCodexDiagnosticsFeedbackStateForTests(); resetSharedCodexAppServerClientForTests(); await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -334,6 +371,984 @@ describe("codex command", () => { }); }); + it("asks before sending diagnostics feedback for the attached Codex thread", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-123" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand( + createContext("diagnostics tool loop repro", sessionFile, { + senderId: "user-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + }), + { deps }, + ); + + const token = readDiagnosticsConfirmationToken(request); + expect(request.text).toBe( + [ + "Codex runtime thread detected.", + "Codex diagnostics can send this thread's feedback bundle to OpenAI servers.", + "Codex sessions:", + ...expectedDiagnosticsTargetBlock({ + channel: "test", + sessionKey: "agent:main:session-1", + sessionId: "session-1", + threadId: "thread-123", + }), + "Note: tool loop repro", + "Included: Codex logs and spawned Codex subthreads when available.", + `To send: /codex diagnostics confirm ${token}`, + `To cancel: /codex diagnostics cancel ${token}`, + "This request expires in 5 minutes.", + ].join("\n"), + ); + expect(request.interactive).toMatchObject({ + blocks: [ + { + type: "buttons", + buttons: [ + { + label: "Send diagnostics", + value: `/codex diagnostics confirm ${token}`, + style: "danger", + }, + { label: "Cancel", value: `/codex diagnostics cancel ${token}` }, + ], + }, + ], + }); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${token}`, sessionFile, { + senderId: "user-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + }), + { deps }, + ), + ).resolves.toEqual({ + text: [ + "Codex diagnostics sent to OpenAI servers:", + ...expectedDiagnosticsTargetBlock({ + channel: "test", + sessionKey: "agent:main:session-1", + sessionId: "session-1", + threadId: "thread-123", + }), + "Included Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }); + expect(safeCodexControlRequest).toHaveBeenCalledWith( + undefined, + CODEX_CONTROL_METHODS.feedback, + { + classification: "bug", + reason: "tool loop repro", + threadId: "thread-123", + includeLogs: true, + tags: { + source: "openclaw-diagnostics", + channel: "test", + }, + }, + ); + }); + + it("previews exec-approved diagnostics upload without exposing Codex ids", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-preview", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-preview" }, + })); + + const result = await handleCodexCommand( + createContext("diagnostics flaky tool call", sessionFile, { + diagnosticsPreviewOnly: true, + senderId: "user-1", + sessionId: "session-preview", + sessionKey: "agent:main:telegram:preview", + }), + { deps: createDeps({ safeCodexControlRequest }) }, + ); + + expect(result.text).toBe( + [ + "Codex runtime thread detected.", + "Approving diagnostics will also send this thread's feedback bundle to OpenAI servers.", + "The completed diagnostics reply will list the OpenClaw session ids and Codex thread ids that were sent.", + "Note: flaky tool call", + "Included: Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + ); + expect(result.text).not.toContain("thread-preview"); + expect(result.text).not.toContain("session-preview"); + expect(result.text).not.toContain("agent:main:telegram:preview"); + expect(result.text).not.toContain("To send:"); + expect(result.interactive).toBeUndefined(); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + }); + + it("sends diagnostics feedback immediately after exec approval", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-approved", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-approved" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + + await expect( + handleCodexCommand( + createContext("diagnostics approved repro", sessionFile, { + diagnosticsUploadApproved: true, + senderId: "user-1", + sessionId: "session-approved", + sessionKey: "agent:main:telegram:approved", + }), + { deps }, + ), + ).resolves.toEqual({ + text: [ + "Codex diagnostics sent to OpenAI servers:", + ...expectedDiagnosticsTargetBlock({ + channel: "test", + sessionKey: "agent:main:telegram:approved", + sessionId: "session-approved", + threadId: "thread-approved", + }), + "Included Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(1); + expect(safeCodexControlRequest).toHaveBeenCalledWith( + undefined, + CODEX_CONTROL_METHODS.feedback, + { + classification: "bug", + reason: "approved repro", + threadId: "thread-approved", + includeLogs: true, + tags: { + source: "openclaw-diagnostics", + channel: "test", + }, + }, + ); + }); + + it("uploads all Codex diagnostics sessions and reports their channel/thread breakdown", async () => { + const firstSessionFile = path.join(tempDir, "session-one.jsonl"); + const secondSessionFile = path.join(tempDir, "session-two.jsonl"); + await fs.writeFile( + `${firstSessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-111", cwd: "/repo" }), + ); + await fs.writeFile( + `${secondSessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-222", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async (_config, _method, requestParams) => ({ + ok: true as const, + value: { + threadId: + requestParams && typeof requestParams === "object" && "threadId" in requestParams + ? requestParams.threadId + : undefined, + }, + })); + const deps = createDeps({ safeCodexControlRequest }); + const diagnosticsSessions = [ + { + sessionKey: "agent:main:whatsapp:one", + sessionId: "session-one", + sessionFile: firstSessionFile, + channel: "whatsapp", + }, + { + sessionKey: "agent:main:discord:two", + sessionId: "session-two", + sessionFile: secondSessionFile, + channel: "discord", + }, + ]; + + const request = await handleCodexCommand( + createContext("diagnostics multi-session repro", firstSessionFile, { + senderId: "user-1", + channel: "whatsapp", + sessionKey: "agent:main:whatsapp:one", + sessionId: "session-one", + diagnosticsSessions, + }), + { deps }, + ); + const token = readDiagnosticsConfirmationToken(request); + expect(request.text).toContain("Codex runtime threads detected."); + expect(request.text).toContain("OpenClaw session key: `agent:main:whatsapp:one`"); + expect(request.text).toContain("OpenClaw session id: `session-one`"); + expect(request.text).toContain("Codex thread id: `thread-111`"); + expect(request.text).toContain("OpenClaw session key: `agent:main:discord:two`"); + expect(request.text).toContain("OpenClaw session id: `session-two`"); + expect(request.text).toContain("Codex thread id: `thread-222`"); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${token}`, firstSessionFile, { + senderId: "user-1", + channel: "whatsapp", + sessionKey: "agent:main:whatsapp:one", + sessionId: "session-one", + diagnosticsSessions, + }), + { deps }, + ), + ).resolves.toEqual({ + text: [ + "Codex diagnostics sent to OpenAI servers:", + ...expectedDiagnosticsTargetBlock({ + index: 1, + channel: "whatsapp", + sessionKey: "agent:main:whatsapp:one", + sessionId: "session-one", + threadId: "thread-111", + }), + "", + ...expectedDiagnosticsTargetBlock({ + index: 2, + channel: "discord", + sessionKey: "agent:main:discord:two", + sessionId: "session-two", + threadId: "thread-222", + }), + "Included Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(2); + expect(safeCodexControlRequest).toHaveBeenNthCalledWith( + 1, + undefined, + CODEX_CONTROL_METHODS.feedback, + expect.objectContaining({ threadId: "thread-111", includeLogs: true }), + ); + expect(safeCodexControlRequest).toHaveBeenNthCalledWith( + 2, + undefined, + CODEX_CONTROL_METHODS.feedback, + expect.objectContaining({ threadId: "thread-222", includeLogs: true }), + ); + }); + + it("requires an owner for Codex diagnostics feedback uploads", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-owner", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-owner" }, + })); + + await expect( + handleCodexCommand( + createContext("diagnostics", sessionFile, { + senderIsOwner: false, + }), + { deps: createDeps({ safeCodexControlRequest }) }, + ), + ).resolves.toEqual({ + text: "Only an owner can send Codex diagnostics.", + }); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + }); + + it("refuses diagnostics confirmations without a stable sender identity", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-sender-required", cwd: "/repo" }), + ); + + await expect( + handleCodexCommand( + createContext("diagnostics", sessionFile, { + senderId: undefined, + }), + { deps: createDeps() }, + ), + ).resolves.toEqual({ + text: "Cannot send Codex diagnostics because this command did not include a sender identity.", + }); + }); + + it("keeps diagnostics confirmation scoped to the requesting sender", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-sender", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-sender" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand( + createContext("diagnostics", sessionFile, { senderId: "user-1" }), + { deps }, + ); + const token = readDiagnosticsConfirmationToken(request); + + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${token}`, sessionFile, { senderId: "user-2" }), + { deps }, + ), + ).resolves.toEqual({ + text: "Only the user who requested these Codex diagnostics can confirm the upload.", + }); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + }); + + it("consumes diagnostics confirmations before async upload work", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + let releaseFirstConfirmBindingRead: () => void = () => undefined; + let firstConfirmBindingReadStarted: () => void = () => undefined; + const firstConfirmBindingRead = new Promise((resolve) => { + releaseFirstConfirmBindingRead = resolve; + }); + const firstConfirmBindingReadStartedPromise = new Promise((resolve) => { + firstConfirmBindingReadStarted = resolve; + }); + let bindingReadCount = 0; + const readCodexAppServerBinding = vi.fn(async (bindingSessionFile: string) => { + bindingReadCount += 1; + if (bindingReadCount === 2) { + firstConfirmBindingReadStarted(); + await firstConfirmBindingRead; + } + return { + schemaVersion: 1 as const, + threadId: "thread-race", + cwd: "/repo", + sessionFile: bindingSessionFile, + createdAt: "2026-04-28T00:00:00.000Z", + updatedAt: "2026-04-28T00:00:00.000Z", + }; + }); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-race" }, + })); + const deps = createDeps({ readCodexAppServerBinding, safeCodexControlRequest }); + + const request = await handleCodexCommand( + createContext("diagnostics", sessionFile, { senderId: "user-1" }), + { deps }, + ); + const token = readDiagnosticsConfirmationToken(request); + const firstConfirm = handleCodexCommand( + createContext(`diagnostics confirm ${token}`, sessionFile, { senderId: "user-1" }), + { deps }, + ); + await firstConfirmBindingReadStartedPromise; + + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${token}`, sessionFile, { senderId: "user-1" }), + { deps }, + ), + ).resolves.toEqual({ + text: "No pending Codex diagnostics confirmation was found. Run /diagnostics again to create a fresh request.", + }); + + releaseFirstConfirmBindingRead(); + await expect(firstConfirm).resolves.toMatchObject({ + text: expect.stringContaining("Codex diagnostics sent to OpenAI servers:"), + }); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(1); + }); + + it("keeps diagnostics confirmation scoped to account and channel identity", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-account", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-account" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand( + createContext("diagnostics", sessionFile, { + accountId: "account-1", + channelId: "channel-1", + messageThreadId: "thread-1", + threadParentId: "parent-1", + sessionKey: "session-key-1", + }), + { deps }, + ); + const token = readDiagnosticsConfirmationToken(request); + + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${token}`, sessionFile, { + accountId: "account-2", + channelId: "channel-1", + messageThreadId: "thread-1", + threadParentId: "parent-1", + sessionKey: "session-key-1", + }), + { deps }, + ), + ).resolves.toEqual({ + text: "This Codex diagnostics confirmation belongs to a different account.", + }); + expect(safeCodexControlRequest).not.toHaveBeenCalled(); + }); + + it("allows private-routed diagnostics confirmations from the owner DM", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-private", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-private" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand( + createContext("diagnostics", sessionFile, { + accountId: "account-1", + channelId: "group-channel", + messageThreadId: "group-topic", + sessionKey: "group-session", + diagnosticsPrivateRouted: true, + }), + { deps }, + ); + const token = readDiagnosticsConfirmationToken(request); + + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${token}`, undefined, { + accountId: "account-1", + channelId: "owner-dm", + sessionKey: "owner-dm-session", + }), + { deps }, + ), + ).resolves.toEqual({ + text: [ + "Codex diagnostics sent to OpenAI servers:", + ...expectedDiagnosticsTargetBlock({ + channel: "test", + sessionKey: "group-session", + threadId: "thread-private", + }), + "Included Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }); + expect(safeCodexControlRequest).toHaveBeenCalledWith( + undefined, + CODEX_CONTROL_METHODS.feedback, + expect.objectContaining({ + classification: "bug", + threadId: "thread-private", + includeLogs: true, + }), + ); + }); + + it("keeps diagnostics confirmation eviction scoped to account identity", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-confirm-scope", cwd: "/repo" }), + ); + + const firstRequest = await handleCodexCommand( + createContext("diagnostics", sessionFile, { + accountId: "account-kept", + channelId: "channel-kept", + }), + { deps: createDeps() }, + ); + const firstToken = readDiagnosticsConfirmationToken(firstRequest); + + for (let index = 0; index < 100; index += 1) { + await handleCodexCommand( + createContext(`diagnostics ${index}`, sessionFile, { + accountId: "account-noisy", + channelId: "channel-noisy", + }), + { deps: createDeps() }, + ); + } + + await expect( + handleCodexCommand( + createContext(`diagnostics cancel ${firstToken}`, sessionFile, { + accountId: "account-kept", + channelId: "channel-kept", + }), + { deps: createDeps() }, + ), + ).resolves.toEqual({ + text: [ + "Codex diagnostics upload canceled.", + "Codex sessions:", + ...expectedDiagnosticsTargetBlock({ + channel: "test", + threadId: "thread-confirm-scope", + }), + ].join("\n"), + }); + }); + + it("bounds diagnostics notes before upload", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-789", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-789" }, + })); + const note = "x".repeat(2050); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand(createContext(`diagnostics ${note}`, sessionFile), { + deps, + }); + const token = readDiagnosticsConfirmationToken(request); + await handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }); + + expect(safeCodexControlRequest).toHaveBeenCalledWith( + undefined, + CODEX_CONTROL_METHODS.feedback, + expect.objectContaining({ + reason: "x".repeat(2048), + }), + ); + }); + + it("escapes diagnostics notes before showing approval text", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-note", cwd: "/repo" }), + ); + + const request = await handleCodexCommand( + createContext("diagnostics <@U123> [trusted](https://evil) @here `tick`", sessionFile), + { deps: createDeps() }, + ); + + expect(request.text).toContain( + "Note: <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here \uff40tick\uff40", + ); + expect(request.text).not.toContain("<@U123>"); + expect(request.text).not.toContain("[trusted](https://evil)"); + }); + + it("throttles repeated diagnostics uploads for the same thread", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-cooldown", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-cooldown" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand(createContext("diagnostics first", sessionFile), { + deps, + }); + const token = readDiagnosticsConfirmationToken(request); + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }), + ).resolves.toEqual({ + text: [ + "Codex diagnostics sent to OpenAI servers:", + ...expectedDiagnosticsTargetBlock({ + channel: "test", + threadId: "thread-cooldown", + }), + "Included Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }); + await expect( + handleCodexCommand(createContext("diagnostics again", sessionFile), { deps }), + ).resolves.toEqual({ + text: "Codex diagnostics were already sent for thread thread-cooldown recently. Try again in 60s.", + }); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(1); + }); + + it("throttles diagnostics uploads across threads", async () => { + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: {}, + })); + const deps = createDeps({ safeCodexControlRequest }); + const sessionFile = path.join(tempDir, "global-cooldown-session.jsonl"); + + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-global-1", cwd: "/repo" }), + ); + const request = await handleCodexCommand(createContext("diagnostics first", sessionFile), { + deps, + }); + const token = readDiagnosticsConfirmationToken(request); + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }), + ).resolves.toEqual({ + text: [ + "Codex diagnostics sent to OpenAI servers:", + ...expectedDiagnosticsTargetBlock({ + channel: "test", + threadId: "thread-global-1", + }), + "Included Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }); + + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-global-2", cwd: "/repo" }), + ); + await expect( + handleCodexCommand(createContext("diagnostics second", sessionFile), { deps }), + ).resolves.toEqual({ + text: "Codex diagnostics were already sent for this account or channel recently. Try again in 60s.", + }); + + expect(safeCodexControlRequest).toHaveBeenCalledTimes(1); + }); + + it("does not throttle diagnostics uploads across different account scopes", async () => { + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: {}, + })); + const deps = createDeps({ safeCodexControlRequest }); + const sessionFile = path.join(tempDir, "scoped-cooldown-session.jsonl"); + + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-scope-1", cwd: "/repo" }), + ); + const firstRequest = await handleCodexCommand( + createContext("diagnostics first", sessionFile, { + accountId: "account-1", + channelId: "channel-1", + }), + { deps }, + ); + const firstToken = readDiagnosticsConfirmationToken(firstRequest); + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${firstToken}`, sessionFile, { + accountId: "account-1", + channelId: "channel-1", + }), + { deps }, + ), + ).resolves.toMatchObject({ + text: expect.stringContaining("Codex diagnostics sent to OpenAI servers:"), + }); + + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-scope-2", cwd: "/repo" }), + ); + const secondRequest = await handleCodexCommand( + createContext("diagnostics second", sessionFile, { + accountId: "account-2", + channelId: "channel-2", + }), + { deps }, + ); + const secondToken = readDiagnosticsConfirmationToken(secondRequest); + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${secondToken}`, sessionFile, { + accountId: "account-2", + channelId: "channel-2", + }), + { deps }, + ), + ).resolves.toMatchObject({ + text: expect.stringContaining("Codex diagnostics sent to OpenAI servers:"), + }); + + expect(safeCodexControlRequest).toHaveBeenCalledTimes(2); + }); + + it("does not collide diagnostics cooldown scopes when ids contain delimiters", async () => { + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: {}, + })); + const deps = createDeps({ safeCodexControlRequest }); + const sessionFile = path.join(tempDir, "delimiter-cooldown-session.jsonl"); + + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-delimiter-1", cwd: "/repo" }), + ); + const firstScope = { + accountId: "a", + channelId: "b", + channel: "test|channel:x", + }; + const firstRequest = await handleCodexCommand( + createContext("diagnostics first", sessionFile, firstScope), + { deps }, + ); + const firstToken = readDiagnosticsConfirmationToken(firstRequest); + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${firstToken}`, sessionFile, firstScope), + { deps }, + ), + ).resolves.toMatchObject({ + text: expect.stringContaining("Codex diagnostics sent to OpenAI servers:"), + }); + + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-delimiter-2", cwd: "/repo" }), + ); + const secondScope = { + accountId: "a|channelId:b", + channel: "test|channel:x", + }; + const secondRequest = await handleCodexCommand( + createContext("diagnostics second", sessionFile, secondScope), + { deps }, + ); + const secondToken = readDiagnosticsConfirmationToken(secondRequest); + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${secondToken}`, sessionFile, secondScope), + { deps }, + ), + ).resolves.toMatchObject({ + text: expect.stringContaining("Codex diagnostics sent to OpenAI servers:"), + }); + + expect(safeCodexControlRequest).toHaveBeenCalledTimes(2); + }); + + it("does not collide diagnostics cooldown scopes when long ids share a prefix", async () => { + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: {}, + })); + const deps = createDeps({ safeCodexControlRequest }); + const sessionFile = path.join(tempDir, "long-scope-cooldown-session.jsonl"); + const sharedPrefix = "account-".repeat(40); + + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-long-scope-1", cwd: "/repo" }), + ); + const firstScope = { + accountId: `${sharedPrefix}first`, + channelId: "channel-long", + }; + const firstRequest = await handleCodexCommand( + createContext("diagnostics first", sessionFile, firstScope), + { deps }, + ); + const firstToken = readDiagnosticsConfirmationToken(firstRequest); + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${firstToken}`, sessionFile, firstScope), + { deps }, + ), + ).resolves.toMatchObject({ + text: expect.stringContaining("Codex diagnostics sent to OpenAI servers:"), + }); + + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-long-scope-2", cwd: "/repo" }), + ); + const secondScope = { + accountId: `${sharedPrefix}second`, + channelId: "channel-long", + }; + const secondRequest = await handleCodexCommand( + createContext("diagnostics second", sessionFile, secondScope), + { deps }, + ); + const secondToken = readDiagnosticsConfirmationToken(secondRequest); + await expect( + handleCodexCommand( + createContext(`diagnostics confirm ${secondToken}`, sessionFile, secondScope), + { deps }, + ), + ).resolves.toMatchObject({ + text: expect.stringContaining("Codex diagnostics sent to OpenAI servers:"), + }); + + expect(safeCodexControlRequest).toHaveBeenCalledTimes(2); + }); + + it("sanitizes diagnostics upload errors before showing them", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "<@U123>", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: false as const, + error: "bad\n\u009b\u202e <@U123> [trusted](https://evil) @here", + })); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand(createContext("diagnostics", sessionFile), { deps }); + expect(request.text).toContain("Codex thread id: <\uff20U123>"); + expect(request.text).not.toContain("<@U123>"); + const token = readDiagnosticsConfirmationToken(request); + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }), + ).resolves.toEqual({ + text: [ + "Could not send Codex diagnostics:", + "- channel test, Codex thread <\uff20U123>: bad??? <\uff20U123> \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here", + "Inspect locally:", + "- run codex resume and paste the thread id shown above", + ].join("\n"), + }); + }); + + it("does not throttle diagnostics retries after upload failures", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ schemaVersion: 1, threadId: "thread-retry", cwd: "/repo" }), + ); + const safeCodexControlRequest = vi + .fn() + .mockResolvedValueOnce({ ok: false as const, error: "temporary outage" }) + .mockResolvedValueOnce({ ok: true as const, value: { threadId: "thread-retry" } }); + const deps = createDeps({ safeCodexControlRequest }); + + const firstRequest = await handleCodexCommand(createContext("diagnostics", sessionFile), { + deps, + }); + const firstToken = readDiagnosticsConfirmationToken(firstRequest); + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${firstToken}`, sessionFile), { + deps, + }), + ).resolves.toEqual({ + text: [ + "Could not send Codex diagnostics:", + "- channel test, Codex thread thread-retry: temporary outage", + "Inspect locally:", + "- `codex resume thread-retry`", + ].join("\n"), + }); + + const secondRequest = await handleCodexCommand(createContext("diagnostics", sessionFile), { + deps, + }); + const secondToken = readDiagnosticsConfirmationToken(secondRequest); + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${secondToken}`, sessionFile), { + deps, + }), + ).resolves.toEqual({ + text: [ + "Codex diagnostics sent to OpenAI servers:", + ...expectedDiagnosticsTargetBlock({ + channel: "test", + threadId: "thread-retry", + }), + "Included Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }); + expect(safeCodexControlRequest).toHaveBeenCalledTimes(2); + }); + + it("omits inline diagnostics resume commands for unsafe thread ids", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-123'`\n\u009b\u202e; echo bad", + cwd: "/repo", + }), + ); + const safeCodexControlRequest = vi.fn(async () => ({ + ok: true as const, + value: { threadId: "thread-123'`\n\u009b\u202e; echo bad" }, + })); + const deps = createDeps({ safeCodexControlRequest }); + + const request = await handleCodexCommand(createContext("diagnostics", sessionFile), { deps }); + const token = readDiagnosticsConfirmationToken(request); + await expect( + handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }), + ).resolves.toEqual({ + text: [ + "Codex diagnostics sent to OpenAI servers:", + "Session 1", + "Channel: test", + "Codex thread id: thread-123'\uff40???; echo bad", + "Inspect locally: run codex resume and paste the thread id shown above", + "Included Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }); + }); + + it("explains diagnostics when no Codex thread is attached", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + + await expect( + handleCodexCommand(createContext("diagnostics", sessionFile), { deps: createDeps() }), + ).resolves.toEqual({ + text: [ + "No Codex thread is attached to this OpenClaw session yet.", + "Use /codex threads to find a thread, then /codex resume before sending diagnostics.", + ].join("\n"), + }); + }); + it("passes filters to Codex thread listing", async () => { const codexControlRequest = vi.fn(async () => ({ data: [{ id: "thread-123", title: "Fix the thing", model: "gpt-5.4", cwd: "/repo" }], diff --git a/extensions/codex/src/commands.ts b/extensions/codex/src/commands.ts index f45c5dbbea0..e5dc83023a3 100644 --- a/extensions/codex/src/commands.ts +++ b/extensions/codex/src/commands.ts @@ -12,6 +12,7 @@ export function createCodexCommand(options: { return { name: "codex", description: "Inspect and control the Codex app-server harness", + ownership: "reserved", agentPromptGuidance: [ "Native Codex app-server plugin is available (`/codex ...`). For Codex bind/control/thread/resume/steer/stop requests, prefer `/codex bind`, `/codex threads`, `/codex resume`, `/codex steer`, and `/codex stop` over ACP.", "Use ACP for Codex only when the user explicitly asks for ACP/acpx or wants to test the ACP path.", diff --git a/extensions/telegram/src/approval-handler.runtime.ts b/extensions/telegram/src/approval-handler.runtime.ts index 1ae907abf58..5991018dd9c 100644 --- a/extensions/telegram/src/approval-handler.runtime.ts +++ b/extensions/telegram/src/approval-handler.runtime.ts @@ -75,6 +75,10 @@ function buildPendingPayload(params: { approvalId: params.request.id, approvalSlug: params.request.id.slice(0, 8), approvalCommandId: params.request.id, + warningText: + params.view.approvalKind === "exec" + ? (params.view.warningText ?? undefined) + : undefined, command: params.view.approvalKind === "exec" ? params.view.commandText : "", cwd: params.view.approvalKind === "exec" ? (params.view.cwd ?? undefined) : undefined, host: diff --git a/extensions/telegram/src/approval-native.test.ts b/extensions/telegram/src/approval-native.test.ts index 0a7e9219ca5..ad0b95e04c4 100644 --- a/extensions/telegram/src/approval-native.test.ts +++ b/extensions/telegram/src/approval-native.test.ts @@ -39,6 +39,7 @@ describe("telegram native approval adapter", () => { }); expect(text).toContain("`channels.telegram.execApprovals.approvers`"); + expect(text).toContain("`commands.ownerAllowFrom`"); expect(text).toContain("`channels.telegram.allowFrom`"); expect(text).toContain("`channels.telegram.defaultTo`"); expect(text).not.toContain("`channels.telegram.dm.allowFrom`"); @@ -52,6 +53,7 @@ describe("telegram native approval adapter", () => { }); expect(text).toContain("`channels.telegram.accounts.work.execApprovals.approvers`"); + expect(text).toContain("`commands.ownerAllowFrom`"); expect(text).toContain("`channels.telegram.accounts.work.allowFrom`"); expect(text).toContain("`channels.telegram.accounts.work.defaultTo`"); expect(text).not.toContain("`channels.telegram.allowFrom`"); diff --git a/extensions/telegram/src/approval-native.ts b/extensions/telegram/src/approval-native.ts index b423599b442..c1c0479600b 100644 --- a/extensions/telegram/src/approval-native.ts +++ b/extensions/telegram/src/approval-native.ts @@ -92,7 +92,7 @@ const telegramNativeApprovalCapability = createApproverRestrictedNativeApprovalC accountId && accountId !== "default" ? `channels.telegram.accounts.${accountId}` : "channels.telegram"; - return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`${prefix}.allowFrom\` or direct-message \`${prefix}.defaultTo\` when possible. Leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`; + return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`commands.ownerAllowFrom\`, \`${prefix}.allowFrom\`, or direct-message \`${prefix}.defaultTo\` when possible. Leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`; }, listAccountIds: listTelegramAccountIds, hasApprovers: ({ cfg, accountId }) => diff --git a/extensions/telegram/src/bot-native-commands.group-auth.test.ts b/extensions/telegram/src/bot-native-commands.group-auth.test.ts index 2e0a4fff1ed..5c4a12e1463 100644 --- a/extensions/telegram/src/bot-native-commands.group-auth.test.ts +++ b/extensions/telegram/src/bot-native-commands.group-auth.test.ts @@ -14,6 +14,7 @@ describe("native command auth in groups", () => { telegramCfg?: TelegramAccountConfig; allowFrom?: string[]; groupAllowFrom?: string[]; + storeAllowFrom?: string[]; useAccessGroups?: boolean; groupConfig?: Record; resolveGroupPolicy?: () => ChannelGroupPolicy; @@ -23,6 +24,7 @@ describe("native command auth in groups", () => { telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig), allowFrom: params.allowFrom ?? [], groupAllowFrom: params.groupAllowFrom ?? [], + storeAllowFrom: params.storeAllowFrom, useAccessGroups: params.useAccessGroups ?? false, resolveGroupPolicy: params.resolveGroupPolicy ?? @@ -50,6 +52,20 @@ describe("native command auth in groups", () => { expect(notAuthCalls).toHaveLength(0); }); + it("does not authorize group native commands from the DM allowlist store", async () => { + const { handlers, sendMessage } = setup({ + storeAllowFrom: ["12345"], + useAccessGroups: true, + }); + + const ctx = createTelegramGroupCommandContext(); + + await handlers.status?.(ctx); + + const notAuthCalls = findNotAuthorizedCalls(sendMessage); + expect(notAuthCalls.length).toBeGreaterThan(0); + }); + it("authorizes native commands in groups from commands.allowFrom.telegram", async () => { const { handlers, sendMessage } = setup({ cfg: { diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index ea712398d90..44d7c14426d 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -196,18 +196,27 @@ function registerAndResolveStatusHandler(params: { cfg: OpenClawConfig; allowFrom?: string[]; groupAllowFrom?: string[]; + storeAllowFrom?: string[]; telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; } { - const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params; + const { + cfg, + allowFrom, + groupAllowFrom, + storeAllowFrom, + telegramCfg, + resolveTelegramGroupConfig, + } = params; return registerAndResolveCommandHandlerBase({ commandName: "status", cfg, allowFrom: allowFrom ?? ["*"], groupAllowFrom: groupAllowFrom ?? [], + storeAllowFrom, useAccessGroups: true, telegramCfg, resolveTelegramGroupConfig, @@ -219,6 +228,7 @@ function registerAndResolveCommandHandlerBase(params: { cfg: OpenClawConfig; allowFrom: string[]; groupAllowFrom: string[]; + storeAllowFrom?: string[]; useAccessGroups: boolean; telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; @@ -231,6 +241,7 @@ function registerAndResolveCommandHandlerBase(params: { cfg, allowFrom, groupAllowFrom, + storeAllowFrom, useAccessGroups, telegramCfg, resolveTelegramGroupConfig, @@ -239,7 +250,7 @@ function registerAndResolveCommandHandlerBase(params: { const sendMessage = vi.fn().mockResolvedValue(undefined); const telegramDeps: TelegramNativeCommandDeps = { getRuntimeConfig: vi.fn(() => cfg), - readChannelAllowFromStore: vi.fn(async () => []), + readChannelAllowFromStore: vi.fn(async () => storeAllowFrom ?? []), dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, getPluginCommandSpecs: vi.fn(() => []), listSkillCommandsForAgents: vi.fn(() => []), @@ -276,6 +287,7 @@ function registerAndResolveCommandHandler(params: { cfg: OpenClawConfig; allowFrom?: string[]; groupAllowFrom?: string[]; + storeAllowFrom?: string[]; useAccessGroups?: boolean; telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; @@ -288,6 +300,7 @@ function registerAndResolveCommandHandler(params: { cfg, allowFrom, groupAllowFrom, + storeAllowFrom, useAccessGroups, telegramCfg, resolveTelegramGroupConfig, @@ -297,6 +310,7 @@ function registerAndResolveCommandHandler(params: { cfg, allowFrom: allowFrom ?? [], groupAllowFrom: groupAllowFrom ?? [], + storeAllowFrom, useAccessGroups: useAccessGroups ?? true, telegramCfg, resolveTelegramGroupConfig, @@ -787,6 +801,42 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(sessionMetaCall?.ctx?.ChatType).toBe("group"); }); + it("does not mark paired Telegram DM allowlist entries as native group command owners", async () => { + const { handler, sendMessage } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + storeAllowFrom: ["200"], + }); + await handler(createTelegramTopicCommandContext()); + + expectUnauthorizedNewCommandBlocked(sendMessage); + }); + + it("authorizes paired Telegram DMs without marking them as owners", async () => { + const { handler } = registerAndResolveStatusHandler({ + cfg: {}, + allowFrom: [], + groupAllowFrom: [], + storeAllowFrom: ["200"], + }); + await handler(createTelegramPrivateCommandContext()); + + const dispatchCall = ( + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array< + [ + { + ctx?: { + CommandAuthorized?: boolean; + }; + }, + ] + > + )[0]?.[0]; + expect(dispatchCall?.ctx?.CommandAuthorized).toBe(true); + expect(dispatchCall?.ctx).not.toHaveProperty("OwnerAllowFrom"); + }); + it("routes Telegram native commands through bound topic sessions", async () => { sessionBindingMocks.resolveByConversation.mockReturnValue({ bindingId: "default:-1001234567890:topic:42", diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 5ee52dfd155..6cd6717501c 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -128,6 +128,7 @@ export function createNativeCommandsHarness(params?: { telegramCfg?: TelegramAccountConfig; allowFrom?: string[]; groupAllowFrom?: string[]; + storeAllowFrom?: string[]; useAccessGroups?: boolean; nativeEnabled?: boolean; groupConfig?: Record; @@ -139,7 +140,7 @@ export function createNativeCommandsHarness(params?: { const log: AnyMock = vi.fn(); const telegramDeps = { getRuntimeConfig: vi.fn(() => params?.cfg ?? ({} as OpenClawConfig)), - readChannelAllowFromStore: vi.fn(async () => []), + readChannelAllowFromStore: vi.fn(async () => params?.storeAllowFrom ?? []), dispatchReplyWithBufferedBlockDispatcher: replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 2be3ae76457..7db4ad146b2 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -116,6 +116,7 @@ type TelegramCommandAuthResult = { groupConfig?: TelegramGroupConfig | TelegramDirectConfig; topicConfig?: TelegramTopicConfig; commandAuthorized: boolean; + senderIsOwner: boolean; }; let telegramNativeCommandDeliveryRuntimePromise: @@ -414,6 +415,20 @@ async function resolveTelegramCommandAuth(params: { commandAuthorized: false, }) : null; + const ownerAccess = resolveCommandAuthorization({ + ctx: { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + AccountId: accountId, + ChatType: isGroup ? "group" : "direct", + From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + }, + cfg, + commandAuthorized: false, + }); const sendAuthMessage = async (text: string) => { await withTelegramApiErrorLogging({ @@ -493,6 +508,7 @@ async function resolveTelegramCommandAuth(params: { const groupSenderAllowed = isGroup ? isSenderAllowed({ allow: effectiveGroupAllow, senderId, senderUsername }) : false; + const ownerAuthorizerConfigured = ownerAccess.senderIsOwner || ownerAccess.ownerList.length > 0; const commandAuthorized = commandsAllowFromConfigured ? Boolean(commandsAllowFromAccess?.isAuthorizedSender) : resolveCommandAuthorizedFromAuthorizers({ @@ -502,6 +518,10 @@ async function resolveTelegramCommandAuth(params: { ...(isGroup ? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }] : []), + { + configured: ownerAuthorizerConfigured, + allowed: ownerAccess.senderIsOwner, + }, ], modeWhenAccessGroupsOff: "configured", }); @@ -519,6 +539,7 @@ async function resolveTelegramCommandAuth(params: { groupConfig, topicConfig, commandAuthorized, + senderIsOwner: ownerAccess.senderIsOwner, }; } @@ -1115,7 +1136,8 @@ export const registerTelegramNativeCommands = ({ if (!auth) { return; } - const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth; + const { senderId, commandAuthorized, senderIsOwner, isGroup, isForum, resolvedThreadId } = + auth; const runtimeContext = await resolveCommandRuntimeContext({ msg, runtimeCfg, @@ -1177,6 +1199,7 @@ export const registerTelegramNativeCommands = ({ senderId, channel: "telegram", isAuthorizedSender: commandAuthorized, + senderIsOwner, sessionKey: route.sessionKey, commandBody, config: runtimeCfg, diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index ae8f35a3f13..68300a31ac3 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -135,7 +135,7 @@ export const telegramChannelConfigUiHints = { }, "execApprovals.approvers": { label: "Telegram Exec Approval Approvers", - help: "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from channels.telegram.allowFrom and direct-message defaultTo when possible.", + help: "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom, channels.telegram.allowFrom, and direct-message defaultTo when possible.", }, "execApprovals.agentFilter": { label: "Telegram Exec Approval Agent Filter", diff --git a/extensions/telegram/src/exec-approval-forwarding.ts b/extensions/telegram/src/exec-approval-forwarding.ts index 03b5e91ec0c..95a45740f4f 100644 --- a/extensions/telegram/src/exec-approval-forwarding.ts +++ b/extensions/telegram/src/exec-approval-forwarding.ts @@ -34,6 +34,7 @@ export function buildTelegramExecApprovalPendingPayload(params: { approvalId: params.request.id, approvalSlug: params.request.id.slice(0, 8), approvalCommandId: params.request.id, + warningText: params.request.request.warningText ?? undefined, command: resolveExecApprovalCommandDisplay(params.request.request).commandText, cwd: params.request.request.cwd ?? undefined, host: params.request.request.host === "node" ? "node" : "gateway", diff --git a/extensions/telegram/src/exec-approvals.test.ts b/extensions/telegram/src/exec-approvals.test.ts index 19eb7d9b921..c10d078f7b9 100644 --- a/extensions/telegram/src/exec-approvals.test.ts +++ b/extensions/telegram/src/exec-approvals.test.ts @@ -110,7 +110,7 @@ function makeForeignChannelApprovalRequest(params: { } describe("telegram exec approvals", () => { - it("requires explicit enablement even when approvers resolve", () => { + it("auto-enables when approvers resolve unless explicitly disabled", () => { expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false); expect( isTelegramExecApprovalClientEnabled({ @@ -121,12 +121,12 @@ describe("telegram exec approvals", () => { isTelegramExecApprovalClientEnabled({ cfg: buildConfig(undefined, { allowFrom: ["123"] }), }), - ).toBe(false); + ).toBe(true); expect( isTelegramExecApprovalClientEnabled({ cfg: buildConfig({ approvers: ["123"] }), }), - ).toBe(false); + ).toBe(true); expect( isTelegramExecApprovalClientEnabled({ cfg: buildConfig({ enabled: "auto", approvers: ["123"] }), @@ -146,6 +146,20 @@ describe("telegram exec approvals", () => { expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false); }); + it("infers approvers from command owners", () => { + const cfg = { + ...buildConfig(), + commands: { + ownerAllowFrom: ["telegram:12345", "tg:67890", "discord:ignored", "-100999"], + }, + } as OpenClawConfig; + + expect(getTelegramExecApprovalApprovers({ cfg })).toEqual(["12345", "67890"]); + expect(isTelegramExecApprovalClientEnabled({ cfg })).toBe(true); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "12345" })).toBe(true); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "67890" })).toBe(true); + }); + it("infers approvers from allowFrom and direct defaultTo", () => { const cfg = buildConfig( { enabled: true }, diff --git a/extensions/telegram/src/exec-approvals.ts b/extensions/telegram/src/exec-approvals.ts index d400b3964dd..00c90d63bcb 100644 --- a/extensions/telegram/src/exec-approvals.ts +++ b/extensions/telegram/src/exec-approvals.ts @@ -35,18 +35,22 @@ function normalizeTelegramDirectApproverId(value: string | number): string | und return chatId; } +function resolveTelegramOwnerApprovers(cfg: OpenClawConfig): Array { + const ownerAllowFrom = cfg.commands?.ownerAllowFrom; + return Array.isArray(ownerAllowFrom) ? ownerAllowFrom : []; +} + export function resolveTelegramExecApprovalConfig(params: { cfg: OpenClawConfig; accountId?: string | null; }): TelegramExecApprovalConfig | undefined { const account = resolveTelegramAccount(params); const config = account.config.execApprovals; - if (!config) { - return undefined; - } + const enabled = + account.enabled && account.tokenSource !== "none" ? (config?.enabled ?? "auto") : false; return { ...config, - enabled: account.enabled && account.tokenSource !== "none" ? config.enabled : false, + enabled, }; } @@ -58,6 +62,7 @@ export function getTelegramExecApprovalApprovers(params: { return resolveApprovalApprovers({ explicit: resolveTelegramExecApprovalConfig(params)?.approvers, allowFrom: account.allowFrom, + extraAllowFrom: resolveTelegramOwnerApprovers(params.cfg), defaultTo: account.defaultTo ? String(account.defaultTo) : null, normalizeApprover: normalizeTelegramDirectApproverId, }); diff --git a/src/agents/bash-tools.exec-approval-followup.test.ts b/src/agents/bash-tools.exec-approval-followup.test.ts index b99ebe1e5b1..db2f5931317 100644 --- a/src/agents/bash-tools.exec-approval-followup.test.ts +++ b/src/agents/bash-tools.exec-approval-followup.test.ts @@ -132,6 +132,30 @@ describe("exec approval followup", () => { expect(callGatewayTool).not.toHaveBeenCalled(); }); + it("can force direct delivery even when a session key exists", async () => { + await sendExecApprovalFollowup({ + approvalId: "req-direct", + sessionKey: "agent:main:telegram:direct:123", + turnSourceChannel: "telegram", + turnSourceTo: "123", + turnSourceAccountId: "default", + resultText: + "Exec finished (gateway id=req-direct, session=sess_1, code 0)\npasteable diagnostics report", + direct: true, + }); + + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "123", + accountId: "default", + content: "pasteable diagnostics report", + idempotencyKey: "exec-approval-followup:req-direct", + }), + ); + expect(callGatewayTool).not.toHaveBeenCalled(); + }); + it("falls back to sanitized direct delivery without alarming prefix for successful completions", async () => { vi.mocked(callGatewayTool).mockRejectedValueOnce(new Error("session missing")); diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts index 1454277e9ef..40fcc352bfd 100644 --- a/src/agents/bash-tools.exec-approval-followup.ts +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -22,6 +22,7 @@ type ExecApprovalFollowupParams = { turnSourceAccountId?: string; turnSourceThreadId?: string | number; resultText: string; + direct?: boolean; }; function buildExecDeniedFollowupPrompt(resultText: string): string { @@ -229,7 +230,7 @@ export async function sendExecApprovalFollowup( let sessionError: unknown = null; - if (sessionKey) { + if (sessionKey && params.direct !== true) { try { await callGatewayTool( "agent", diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 751dbd5425b..bb49d42743a 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -17,6 +17,7 @@ export type RequestExecApprovalDecisionParams = { host: "gateway" | "node"; security: ExecSecurity; ask: ExecAsk; + warningText?: string; agentId?: string; resolvedPath?: string; sessionKey?: string; @@ -45,6 +46,7 @@ function buildExecApprovalRequestToolParams( host: params.host, security: params.security, ask: params.ask, + warningText: params.warningText, agentId: params.agentId, resolvedPath: params.resolvedPath, sessionKey: params.sessionKey, @@ -156,6 +158,7 @@ type HostExecApprovalParams = { nodeId?: string; security: ExecSecurity; ask: ExecAsk; + warningText?: string; agentId?: string; resolvedPath?: string; sessionKey?: string; @@ -212,6 +215,7 @@ function buildHostApprovalDecisionParams( host: params.host, security: params.security, ask: params.ask, + warningText: params.warningText, ...buildExecApprovalRequesterContext({ agentId: params.agentId, sessionKey: params.sessionKey, diff --git a/src/agents/bash-tools.exec-host-gateway.test.ts b/src/agents/bash-tools.exec-host-gateway.test.ts index 3d1f2e54920..1247f2c90d6 100644 --- a/src/agents/bash-tools.exec-host-gateway.test.ts +++ b/src/agents/bash-tools.exec-host-gateway.test.ts @@ -1,7 +1,13 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExecApprovalFollowupTarget } from "./bash-tools.exec-host-shared.js"; type StrictInlineEvalBoundary = typeof import("./bash-tools.exec-host-shared.js").enforceStrictInlineEvalApprovalBoundary; +type SendExecApprovalFollowupResult = + typeof import("./bash-tools.exec-host-shared.js").sendExecApprovalFollowupResult; +type BuildExecApprovalFollowupTargetMock = ( + value: ExecApprovalFollowupTarget, +) => ExecApprovalFollowupTarget | null; const INLINE_EVAL_HIT = { executable: "python3", @@ -12,7 +18,9 @@ const INLINE_EVAL_HIT = { const createAndRegisterDefaultExecApprovalRequestMock = vi.hoisted(() => vi.fn()); const buildExecApprovalPendingToolResultMock = vi.hoisted(() => vi.fn()); -const buildExecApprovalFollowupTargetMock = vi.hoisted(() => vi.fn(() => null)); +const buildExecApprovalFollowupTargetMock = vi.hoisted(() => + vi.fn(() => null), +); const createExecApprovalDecisionStateMock = vi.hoisted(() => vi.fn( (): { @@ -55,7 +63,9 @@ const resolveExecHostApprovalContextMock = vi.hoisted(() => })), ); const runExecProcessMock = vi.hoisted(() => vi.fn()); -const sendExecApprovalFollowupResultMock = vi.hoisted(() => vi.fn(async () => undefined)); +const sendExecApprovalFollowupResultMock = vi.hoisted(() => + vi.fn(async () => undefined), +); const enforceStrictInlineEvalApprovalBoundaryMock = vi.hoisted(() => vi.fn((value) => ({ approvedByAsk: value.approvedByAsk, @@ -307,6 +317,87 @@ describe("processGatewayAllowlist", () => { ); }); + it("formats diagnostics approvals as direct pasteable followups", async () => { + resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once"); + createExecApprovalDecisionStateMock.mockReturnValue({ + baseDecision: { timedOut: false }, + approvedByAsk: false, + deniedReason: null, + }); + const outcome = { + status: "completed" as const, + exitCode: 0, + exitSignal: null, + durationMs: 12, + timedOut: false, + aggregated: JSON.stringify({ + path: "/tmp/openclaw-diagnostics.zip", + bytes: 1234, + manifest: { + generatedAt: "2026-04-28T20:58:29.311Z", + openclawVersion: "2026.4.27", + contents: [ + { path: "diagnostics.json", bytes: 100 }, + { path: "summary.md", bytes: 200 }, + ], + privacy: { + payloadFree: true, + rawLogsIncluded: false, + notes: ["Logs keep operational summaries."], + }, + }, + }), + }; + runExecProcessMock.mockResolvedValue({ + session: { id: "sess-1" }, + promise: Promise.resolve(outcome), + }); + buildExecApprovalFollowupTargetMock.mockImplementation((value) => value); + + const approvalFollowup = vi.fn(async () => + [ + "OpenAI Codex harness:", + "Codex diagnostics sent to OpenAI servers:", + "Session 1", + "Channel: telegram", + "OpenClaw session id: `session-1`", + "Codex thread id: `thread-1`", + ].join("\n"), + ); + + await runGatewayAllowlist({ + command: "openclaw gateway diagnostics export --json", + trigger: "diagnostics", + approvalFollowupMode: "direct", + approvalFollowup, + }); + + await vi.waitFor(() => { + expect(sendExecApprovalFollowupResultMock).toHaveBeenCalled(); + }); + expect(buildExecApprovalFollowupTargetMock).toHaveBeenCalledWith( + expect.objectContaining({ direct: true }), + ); + expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledWith( + expect.objectContaining({ direct: true }), + expect.stringContaining("Diagnostics export created."), + ); + const followupText = sendExecApprovalFollowupResultMock.mock.calls[0]?.[1] ?? ""; + expect(followupText).toContain("Path: /tmp/openclaw-diagnostics.zip"); + expect(followupText).toContain("Contents (2 files):"); + expect(followupText).toContain("OpenAI Codex harness:"); + expect(followupText).toContain("Codex diagnostics sent to OpenAI servers:"); + expect(followupText).toContain("Codex thread id: `thread-1`"); + expect(approvalFollowup).toHaveBeenCalledWith( + expect.objectContaining({ + approvalId: "req-1", + sessionId: "sess-1", + trigger: "diagnostics", + outcome: expect.objectContaining({ status: "completed", exitCode: 0 }), + }), + ); + }); + it("denies timed-out inline-eval requests instead of auto-running them", async () => { const result = await runTimedOutStrictInlineEval({ security: "full", diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 00f154ee39d..1c4315788ed 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -42,7 +42,11 @@ import { normalizeNotifyOutput, runExecProcess, } from "./bash-tools.exec-runtime.js"; -import type { ExecToolDetails } from "./bash-tools.exec-types.js"; +import type { + ExecApprovalFollowupFactory, + ExecApprovalFollowupOutcome, + ExecToolDetails, +} from "./bash-tools.exec-types.js"; export type ProcessGatewayAllowlistParams = { command: string; @@ -65,6 +69,9 @@ export type ProcessGatewayAllowlistParams = { turnSourceAccountId?: string; turnSourceThreadId?: string | number; scopeKey?: string; + approvalFollowupText?: string; + approvalFollowup?: ExecApprovalFollowupFactory; + approvalFollowupMode?: "agent" | "direct"; warnings: string[]; notifySessionKey?: string; approvalRunningNoticeMs: number; @@ -92,6 +99,169 @@ function hasGatewayAllowlistMiss(params: { ); } +function formatOutcomeExitLabel(outcome: { exitCode: number | null; timedOut: boolean }): string { + return outcome.timedOut ? "timeout" : `code ${outcome.exitCode ?? "?"}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatBytes(value: unknown): string | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + return `${Math.max(0, Math.round(value))} bytes`; +} + +function formatDiagnosticsContents(manifest: Record): string[] { + const contents = Array.isArray(manifest.contents) ? manifest.contents : []; + if (contents.length === 0) { + return []; + } + const lines = [`Contents (${contents.length} files):`]; + for (const entry of contents.slice(0, 12)) { + if (!isRecord(entry)) { + continue; + } + const path = typeof entry.path === "string" ? entry.path : ""; + if (!path) { + continue; + } + const bytes = formatBytes(entry.bytes); + lines.push(`- ${bytes ? `${path} (${bytes})` : path}`); + } + if (contents.length > 12) { + lines.push(`- ... ${contents.length - 12} more`); + } + return lines; +} + +function formatDiagnosticsPrivacy(manifest: Record): string[] { + const privacy = isRecord(manifest.privacy) ? manifest.privacy : null; + if (!privacy) { + return []; + } + const lines = ["Privacy:"]; + if (typeof privacy.payloadFree === "boolean") { + lines.push(`- payload-free: ${privacy.payloadFree ? "yes" : "no"}`); + } + if (typeof privacy.rawLogsIncluded === "boolean") { + lines.push(`- raw logs included: ${privacy.rawLogsIncluded ? "yes" : "no"}`); + } + const notes = Array.isArray(privacy.notes) + ? privacy.notes.filter((note): note is string => typeof note === "string") + : []; + for (const note of notes.slice(0, 4)) { + lines.push(`- ${note}`); + } + return lines.length > 1 ? lines : []; +} + +function formatDiagnosticsExportSuccess(aggregated: string): string { + const trimmed = aggregated.trim(); + if (!trimmed) { + return "Diagnostics export completed, but no JSON output was returned."; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!isRecord(parsed)) { + return trimmed; + } + const manifest = isRecord(parsed.manifest) ? parsed.manifest : {}; + const lines = ["Diagnostics export created.", "", "Local Gateway bundle:"]; + const bundlePath = typeof parsed.path === "string" ? parsed.path : ""; + if (bundlePath) { + lines.push(`Path: ${bundlePath}`); + } + const bytes = formatBytes(parsed.bytes); + if (bytes) { + lines.push(`Size: ${bytes}`); + } + if (typeof manifest.generatedAt === "string") { + lines.push(`Generated at: ${manifest.generatedAt}`); + } + if (typeof manifest.openclawVersion === "string") { + lines.push(`OpenClaw version: ${manifest.openclawVersion}`); + } + const contents = formatDiagnosticsContents(manifest); + if (contents.length > 0) { + lines.push("", ...contents); + } + const privacy = formatDiagnosticsPrivacy(manifest); + if (privacy.length > 0) { + lines.push("", ...privacy); + } + return lines.join("\n"); + } catch { + return trimmed; + } +} + +function formatDiagnosticsExportFailure(params: { + outcome: { status: string; reason?: string; aggregated: string }; + exitLabel: string; +}): string { + const output = normalizeNotifyOutput(tail(params.outcome.aggregated || "", 4000)); + const lines = [`Diagnostics export failed (${params.exitLabel}).`]; + if (params.outcome.reason) { + lines.push(params.outcome.reason); + } + if (output) { + lines.push("", output); + } + return lines.join("\n"); +} + +function buildGatewayExecApprovalFollowupSummary(params: { + approvalId: string; + sessionId: string; + outcome: ExecApprovalFollowupOutcome; + trigger?: string; + approvalFollowupText?: string; +}): string { + const exitLabel = formatOutcomeExitLabel(params.outcome); + if (params.trigger === "diagnostics") { + const diagnosticsText = + params.outcome.status === "completed" && params.outcome.exitCode === 0 + ? formatDiagnosticsExportSuccess(params.outcome.aggregated) + : formatDiagnosticsExportFailure({ outcome: params.outcome, exitLabel }); + const followupText = params.approvalFollowupText?.trim(); + const body = [diagnosticsText, followupText].filter(Boolean).join("\n\n"); + return `Exec finished (gateway id=${params.approvalId}, session=${params.sessionId}, ${exitLabel})\n${body}`; + } + + const output = normalizeNotifyOutput( + tail(params.outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), + ); + return output + ? `Exec finished (gateway id=${params.approvalId}, session=${params.sessionId}, ${exitLabel})\n${output}` + : `Exec finished (gateway id=${params.approvalId}, session=${params.sessionId}, ${exitLabel})`; +} + +async function resolveGatewayExecApprovalFollowupText(params: { + approvalFollowup?: ExecApprovalFollowupFactory; + approvalId: string; + sessionId: string; + trigger?: string; + outcome: ExecApprovalFollowupOutcome; +}): Promise { + if (!params.approvalFollowup) { + return undefined; + } + try { + return await params.approvalFollowup({ + approvalId: params.approvalId, + sessionId: params.sessionId, + trigger: params.trigger, + outcome: params.outcome, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Diagnostics follow-up failed: ${message}`; + } +} + export async function processGatewayAllowlist( params: ProcessGatewayAllowlistParams, ): Promise { @@ -209,6 +379,7 @@ export async function processGatewayAllowlist( host: "gateway", security: hostSecurity, ask: hostAsk, + warningText: params.warnings.join("\n").trim() || undefined, ...buildExecApprovalRequesterContext({ agentId: params.agentId, sessionKey: params.sessionKey, @@ -286,6 +457,7 @@ export async function processGatewayAllowlist( turnSourceTo: params.turnSourceTo, turnSourceAccountId: params.turnSourceAccountId, turnSourceThreadId: params.turnSourceThreadId, + direct: params.approvalFollowupMode === "direct", }); void (async () => { @@ -398,13 +570,24 @@ export async function processGatewayAllowlist( markBackgrounded(run.session); const outcome = await run.promise; - const output = normalizeNotifyOutput( - tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), - ); - const exitLabel = outcome.timedOut ? "timeout" : `code ${outcome.exitCode ?? "?"}`; - const summary = output - ? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}` - : `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`; + const dynamicFollowupText = await resolveGatewayExecApprovalFollowupText({ + approvalFollowup: params.approvalFollowup, + approvalId, + sessionId: run.session.id, + trigger: params.trigger, + outcome, + }); + const approvalFollowupText = [params.approvalFollowupText, dynamicFollowupText] + .map((text) => text?.trim()) + .filter(Boolean) + .join("\n\n"); + const summary = buildGatewayExecApprovalFollowupSummary({ + approvalId, + sessionId: run.session.id, + outcome, + trigger: params.trigger, + approvalFollowupText, + }); await sendExecApprovalFollowupResult(followupTarget, summary); })(); diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index 793b05fba3d..9067dfcdde6 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -88,6 +88,7 @@ export type ExecApprovalFollowupTarget = { turnSourceTo?: string; turnSourceAccountId?: string; turnSourceThreadId?: string | number; + direct?: boolean; }; export type ExecApprovalFollowupResultDeps = { @@ -322,6 +323,7 @@ export function buildExecApprovalFollowupTarget( turnSourceTo: params.turnSourceTo, turnSourceAccountId: params.turnSourceAccountId, turnSourceThreadId: params.turnSourceThreadId, + direct: params.direct, }; } @@ -414,6 +416,7 @@ export async function sendExecApprovalFollowupResult( turnSourceAccountId: target.turnSourceAccountId, turnSourceThreadId: target.turnSourceThreadId, resultText, + direct: target.direct, }).catch((error) => { const message = formatErrorMessage(error); const key = `${target.approvalId}:${message}`; diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index e834457f146..ca18d4da91c 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -19,6 +19,10 @@ export type ExecToolDefaults = { agentId?: string; backgroundMs?: number; timeoutSec?: number; + approvalWarningText?: string; + approvalFollowupText?: string; + approvalFollowup?: ExecApprovalFollowupFactory; + approvalFollowupMode?: "agent" | "direct"; approvalRunningNoticeMs?: number; sandbox?: BashSandboxConfig; elevated?: ExecElevatedDefaults; @@ -34,6 +38,25 @@ export type ExecToolDefaults = { cwd?: string; }; +export type ExecApprovalFollowupOutcome = { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + aggregated: string; + reason?: string; +}; + +export type ExecApprovalFollowupContext = { + approvalId: string; + sessionId: string; + trigger?: string; + outcome: ExecApprovalFollowupOutcome; +}; + +export type ExecApprovalFollowupFactory = ( + context: ExecApprovalFollowupContext, +) => string | undefined | Promise; + export type ExecElevatedDefaults = { enabled: boolean; allowed: boolean; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 05e93c87c9c..b7d9b9493d9 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1464,6 +1464,10 @@ export function createExecTool( const maxOutput = DEFAULT_MAX_OUTPUT; const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT; const warnings: string[] = []; + const approvalWarningText = normalizeOptionalString(defaults?.approvalWarningText); + if (approvalWarningText) { + warnings.push(approvalWarningText); + } let execCommandOverride: string | undefined; const backgroundRequested = params.background === true; const yieldRequested = typeof params.yieldMs === "number"; @@ -1729,6 +1733,9 @@ export function createExecTool( turnSourceAccountId: defaults?.accountId, turnSourceThreadId: defaults?.currentThreadTs, scopeKey: defaults?.scopeKey, + approvalFollowupText: defaults?.approvalFollowupText, + approvalFollowup: defaults?.approvalFollowup, + approvalFollowupMode: defaults?.approvalFollowupMode, warnings, notifySessionKey, approvalRunningNoticeMs, diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 693d11dd9ab..363eb9c714f 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -190,6 +190,23 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { category: "status", tier: "essential", }), + defineChatCommand({ + key: "diagnostics", + nativeName: "diagnostics", + description: "Explain Gateway diagnostics and Codex feedback upload options.", + textAlias: "/diagnostics", + acceptsArgs: true, + category: "status", + tier: "standard", + args: [ + { + name: "note", + description: "Optional note for Codex feedback upload", + type: "string", + captureRemaining: true, + }, + ], + }), defineChatCommand({ key: "crestodian", description: "Run the Crestodian setup and repair helper.", diff --git a/src/auto-reply/reply/commands-diagnostics.test.ts b/src/auto-reply/reply/commands-diagnostics.test.ts new file mode 100644 index 00000000000..ada174d46c7 --- /dev/null +++ b/src/auto-reply/reply/commands-diagnostics.test.ts @@ -0,0 +1,624 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; +import { createPluginRegistry, type PluginRecord } from "../../plugins/registry.js"; +import type { PluginRuntime } from "../../plugins/runtime/types.js"; +import type { PluginCommandContext } from "../../plugins/types.js"; +import type { MsgContext } from "../templating.js"; +import { createDiagnosticsCommandHandler } from "./commands-diagnostics.js"; +import type { HandleCommandsParams } from "./commands-types.js"; + +type ExecCall = { + defaults: unknown; + params: unknown; +}; + +function buildDiagnosticsParams( + commandBodyNormalized: string, + overrides: Partial = {}, +): HandleCommandsParams { + return { + cfg: { commands: { text: true } } as OpenClawConfig, + ctx: { + Provider: "whatsapp", + Surface: "whatsapp", + CommandSource: "text", + AccountId: "account-1", + MessageThreadId: "thread-1", + } as MsgContext, + command: { + commandBodyNormalized, + isAuthorizedSender: true, + senderIsOwner: true, + senderId: "user-1", + channel: "whatsapp", + channelId: "whatsapp", + surface: "whatsapp", + ownerList: [], + rawBodyNormalized: commandBodyNormalized, + from: "user-1", + to: "bot", + }, + sessionKey: "agent:main:whatsapp:direct:user-1", + workspaceDir: "/tmp", + provider: "openai", + model: "gpt-5.4", + contextTokens: 0, + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + directives: {}, + elevated: { enabled: true, allowed: true, failures: [] }, + ...overrides, + } as HandleCommandsParams; +} + +function createBundledPluginRecord(id: string): PluginRecord { + return { + id, + name: id, + source: `bundled:${id}`, + rootDir: `/bundled/${id}`, + origin: "bundled", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + cliBackendIds: [], + providerIds: [], + speechProviderIds: [], + realtimeTranscriptionProviderIds: [], + realtimeVoiceProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + videoGenerationProviderIds: [], + musicGenerationProviderIds: [], + webFetchProviderIds: [], + webSearchProviderIds: [], + migrationProviderIds: [], + memoryEmbeddingProviderIds: [], + agentHarnessIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + gatewayDiscoveryServiceIds: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + } as PluginRecord; +} + +function registerHostTrustedReservedCommandForTest( + command: Parameters[1], +) { + const pluginRegistry = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: true, + }); + pluginRegistry.registerCommand(createBundledPluginRecord(command.name), command); +} + +function registerCodexDiagnosticsCommandForTest( + handler: (ctx: PluginCommandContext) => Promise, +) { + const calls: PluginCommandContext[] = []; + const commandHandler = vi.fn(async (ctx: PluginCommandContext) => { + calls.push(ctx); + await handler(ctx); + if (ctx.diagnosticsPreviewOnly) { + return { + text: [ + "Codex runtime thread detected.", + "Approving diagnostics will also send this thread's feedback bundle to OpenAI servers.", + "The completed diagnostics reply will list the OpenClaw session ids and Codex thread ids that were sent.", + "Included: Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }; + } + if (ctx.diagnosticsUploadApproved) { + return { + text: [ + "Codex diagnostics sent to OpenAI servers:", + "Session 1", + "Channel: whatsapp", + "OpenClaw session id: `session-1`", + "Codex thread id: `codex-thread-1`", + "Inspect locally: `codex resume codex-thread-1`", + "Included Codex logs and spawned Codex subthreads when available.", + ].join("\n"), + }; + } + return { + text: [ + "Codex runtime thread detected.", + "Thread: codex-thread-1", + "To send: /codex diagnostics confirm abc123def456", + "To cancel: /codex diagnostics cancel abc123def456", + ].join("\n"), + interactive: { + blocks: [ + { + type: "buttons" as const, + buttons: [ + { + label: "Send diagnostics", + value: "/codex diagnostics confirm abc123def456", + style: "danger" as const, + }, + { + label: "Cancel", + value: "/codex diagnostics cancel abc123def456", + style: "secondary" as const, + }, + ], + }, + ], + }, + }; + }); + registerHostTrustedReservedCommandForTest({ + name: "codex", + description: "Codex command", + acceptsArgs: true, + handler: commandHandler, + ownership: "reserved", + }); + return { calls, commandHandler }; +} + +function createDiagnosticsHandlerForTest( + options: { + privateTargets?: Array<{ channel: string; to: string; accountId?: string | null }>; + execResult?: { + content: Array<{ type: "text"; text: string }>; + details?: { status: string; [key: string]: unknown }; + }; + } = {}, +) { + const execCalls: ExecCall[] = []; + const privateReplies: Array<{ + targets: Array<{ channel: string; to: string; accountId?: string | null }>; + text?: string; + }> = []; + const createExecTool = vi.fn((defaults: unknown) => ({ + execute: vi.fn(async (_toolCallId: string, params: unknown) => { + execCalls.push({ defaults, params }); + return ( + options.execResult ?? { + content: [ + { + type: "text" as const, + text: "Exec approval pending. Allowed decisions: allow-once, deny.", + }, + ], + details: { + status: "approval-pending" as const, + approvalId: "approval-1", + approvalSlug: "diag-approval", + expiresAtMs: Date.now() + 60_000, + allowedDecisions: ["allow-once", "deny"] as const, + host: "gateway" as const, + command: "openclaw gateway diagnostics export --json", + cwd: "/tmp", + }, + } + ); + }), + })); + return { + execCalls, + privateReplies, + handleDiagnosticsCommand: createDiagnosticsCommandHandler({ + createExecTool: createExecTool as never, + resolvePrivateDiagnosticsTargets: vi.fn(async () => options.privateTargets ?? []), + deliverPrivateDiagnosticsReply: vi.fn(async ({ targets, reply }) => { + privateReplies.push({ targets, text: reply.text }); + return true; + }), + }), + }; +} + +afterEach(() => { + clearPluginCommands(); +}); + +describe("diagnostics command", () => { + it("requests Gateway diagnostics approval without a duplicate pending chat reply", async () => { + const { execCalls, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); + const result = await handleDiagnosticsCommand(buildDiagnosticsParams("/diagnostics"), true); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply).toBeUndefined(); + expect(execCalls).toHaveLength(1); + expect(execCalls[0]?.defaults).toMatchObject({ + host: "gateway", + security: "allowlist", + ask: "always", + trigger: "diagnostics", + approvalFollowupMode: "direct", + approvalWarningText: expect.stringContaining( + "Diagnostics can include sensitive local logs and host-level runtime metadata.", + ), + }); + expect( + String((execCalls[0]?.defaults as { approvalWarningText?: string }).approvalWarningText), + ).toContain("https://docs.openclaw.ai/gateway/diagnostics"); + expect(execCalls[0]?.params).toMatchObject({ + security: "allowlist", + ask: "always", + }); + const command = (execCalls[0]?.params as { command?: string }).command ?? ""; + expect(command).toContain("gateway"); + expect(command).toContain("diagnostics"); + expect(command).toContain("export"); + expect(command).toContain("--json"); + expect(command).not.toBe("openclaw gateway diagnostics export --json"); + }); + + it("uses the originating Telegram route for native diagnostics followups", async () => { + const { execCalls, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); + const params = buildDiagnosticsParams("/diagnostics", { + ctx: { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:8460800771", + From: "telegram:8460800771", + To: "slash:8460800771", + CommandSource: "native", + AccountId: "account-1", + } as MsgContext, + command: { + commandBodyNormalized: "/diagnostics", + isAuthorizedSender: true, + senderIsOwner: true, + senderId: "8460800771", + channel: "telegram", + channelId: "telegram", + surface: "telegram", + ownerList: [], + rawBodyNormalized: "/diagnostics", + from: "telegram:8460800771", + to: "slash:8460800771", + }, + sessionKey: "agent:main:telegram:slash:8460800771", + }); + + await handleDiagnosticsCommand(params, true); + + expect(execCalls).toHaveLength(1); + expect(execCalls[0]?.defaults).toMatchObject({ + messageProvider: "telegram", + currentChannelId: "telegram:8460800771", + accountId: "account-1", + }); + }); + + it("falls back to a visible reply when approval cannot be queued", async () => { + const { execCalls, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest({ + execResult: { + content: [ + { + type: "text", + text: "Exec approval is required, but no interactive approval client is currently available.", + }, + ], + details: { + status: "approval-unavailable", + reason: "no-approval-route", + }, + }, + }); + const result = await handleDiagnosticsCommand(buildDiagnosticsParams("/diagnostics"), true); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain( + "Diagnostics can include sensitive local logs and host-level runtime metadata.", + ); + expect(result?.reply?.text).toContain("https://docs.openclaw.ai/gateway/diagnostics"); + expect(result?.reply?.text).toContain("no interactive approval client"); + expect(execCalls).toHaveLength(1); + }); + + it("wraps Codex feedback upload into the Gateway diagnostics approval", async () => { + const { calls } = registerCodexDiagnosticsCommandForTest(async () => null); + const { execCalls, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); + const result = await handleDiagnosticsCommand( + buildDiagnosticsParams("/diagnostics flaky tool call", { + sessionEntry: { + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + updatedAt: 1, + agentHarnessId: "codex", + }, + }), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply).toBeUndefined(); + expect(calls).toHaveLength(1); + expect(calls[0]?.args).toBe("diagnostics flaky tool call"); + expect(calls[0]?.diagnosticsPreviewOnly).toBe(true); + expect(calls[0]?.senderIsOwner).toBe(true); + expect(calls[0]?.sessionFile).toBe("/tmp/session.jsonl"); + expect(calls[0]?.diagnosticsSessions).toEqual([ + expect.objectContaining({ + agentHarnessId: "codex", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + channel: "whatsapp", + accountId: "account-1", + }), + ]); + const defaults = execCalls[0]?.defaults as { + approvalWarningText?: string; + approvalFollowupText?: string; + approvalFollowup?: () => Promise; + }; + expect(defaults.approvalWarningText).toContain("OpenAI Codex harness:"); + expect(defaults.approvalWarningText).toContain( + "Approving diagnostics will also send this thread's feedback bundle to OpenAI servers.", + ); + expect(defaults.approvalWarningText).not.toContain("To send:"); + expect(defaults.approvalWarningText).not.toContain("/codex diagnostics confirm"); + expect(defaults.approvalFollowupText).toBeUndefined(); + + await expect(defaults.approvalFollowup?.()).resolves.toContain( + "Codex diagnostics sent to OpenAI servers:", + ); + expect(calls).toHaveLength(2); + expect(calls[1]?.diagnosticsUploadApproved).toBe(true); + }); + + it("passes sidecar-bound session files to Codex diagnostics even when harness metadata is stale", async () => { + const { calls } = registerCodexDiagnosticsCommandForTest(async () => null); + const { execCalls, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); + const result = await handleDiagnosticsCommand( + buildDiagnosticsParams("/diagnostics", { + sessionKey: "agent:main:telegram:direct:user-1", + sessionEntry: { + sessionId: "telegram-session", + sessionFile: "/tmp/telegram.jsonl", + updatedAt: 1, + }, + sessionStore: { + "agent:main:telegram:direct:user-1": { + sessionId: "telegram-session", + sessionFile: "/tmp/telegram.jsonl", + updatedAt: 1, + }, + "agent:main:discord:channel:123": { + sessionId: "discord-session", + sessionFile: "/tmp/discord.jsonl", + updatedAt: 2, + channel: "discord", + }, + }, + }), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply).toBeUndefined(); + expect(calls).toHaveLength(1); + expect(calls[0]?.diagnosticsSessions).toEqual([ + expect.objectContaining({ + sessionKey: "agent:main:telegram:direct:user-1", + sessionId: "telegram-session", + sessionFile: "/tmp/telegram.jsonl", + channel: "whatsapp", + }), + expect.objectContaining({ + sessionKey: "agent:main:discord:channel:123", + sessionId: "discord-session", + sessionFile: "/tmp/discord.jsonl", + channel: "discord", + }), + ]); + expect( + (execCalls[0]?.defaults as { approvalWarningText?: string }).approvalWarningText, + ).toContain("OpenAI Codex harness:"); + }); + + it("omits the Codex section for ordinary sessions without Codex targets", async () => { + registerHostTrustedReservedCommandForTest({ + name: "codex", + description: "Codex command", + acceptsArgs: true, + ownership: "reserved", + handler: vi.fn(async () => ({ + text: [ + "No Codex thread is attached to this OpenClaw session yet.", + "Use /codex threads to find a thread, then /codex resume before sending diagnostics.", + ].join("\n"), + })), + }); + const { execCalls, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); + + await handleDiagnosticsCommand( + buildDiagnosticsParams("/diagnostics", { + sessionEntry: { + sessionId: "ordinary-session", + sessionFile: "/tmp/ordinary.jsonl", + updatedAt: 1, + }, + }), + true, + ); + + expect( + (execCalls[0]?.defaults as { approvalWarningText?: string }).approvalWarningText, + ).not.toContain("OpenAI Codex harness:"); + }); + + it("routes group diagnostics details privately before starting collection", async () => { + const { calls } = registerCodexDiagnosticsCommandForTest(async () => null); + const { execCalls, privateReplies, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest( + { + privateTargets: [{ channel: "whatsapp", to: "owner-dm", accountId: "account-1" }], + }, + ); + + const result = await handleDiagnosticsCommand( + buildDiagnosticsParams("/diagnostics flaky tool call", { + isGroup: true, + sessionEntry: { + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + updatedAt: 1, + agentHarnessId: "codex", + }, + }), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toBe( + "Diagnostics are sensitive. I sent the diagnostics details and approval prompts to the owner privately.", + ); + expect(result?.reply?.text).not.toContain("codex-thread-1"); + expect(privateReplies).toHaveLength(0); + expect(execCalls).toHaveLength(1); + expect(execCalls[0]?.defaults).toMatchObject({ + currentChannelId: "owner-dm", + accountId: "account-1", + }); + expect( + (execCalls[0]?.defaults as { approvalWarningText?: string }).approvalWarningText, + ).toContain("Approving diagnostics will also send this thread's feedback bundle"); + expect( + (execCalls[0]?.defaults as { approvalWarningText?: string }).approvalWarningText, + ).not.toContain("To send:"); + expect(calls[0]?.diagnosticsPrivateRouted).toBe(true); + }); + + it("fails closed in groups when no private diagnostics route is available", async () => { + registerCodexDiagnosticsCommandForTest(async () => null); + const { execCalls, privateReplies, handleDiagnosticsCommand } = + createDiagnosticsHandlerForTest(); + + const result = await handleDiagnosticsCommand( + buildDiagnosticsParams("/diagnostics", { + isGroup: true, + sessionEntry: { + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + updatedAt: 1, + agentHarnessId: "codex", + }, + }), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Run /diagnostics from an owner DM"); + expect(execCalls).toHaveLength(0); + expect(privateReplies).toHaveLength(0); + }); + + it("routes group diagnostics confirmations privately", async () => { + const commandHandler = vi.fn(async () => ({ + text: [ + "Codex diagnostics sent to OpenAI servers:", + "- channel whatsapp, OpenClaw session session-1, Codex thread codex-thread-1", + ].join("\n"), + })); + registerHostTrustedReservedCommandForTest({ + name: "codex", + description: "Codex command", + acceptsArgs: true, + handler: commandHandler, + ownership: "reserved", + }); + const { privateReplies, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest({ + privateTargets: [{ channel: "whatsapp", to: "owner-dm", accountId: "account-1" }], + }); + + const result = await handleDiagnosticsCommand( + buildDiagnosticsParams("/diagnostics confirm abc123def456", { isGroup: true }), + true, + ); + + expect(result?.reply?.text).toBe( + "Diagnostics are sensitive. I sent the diagnostics details and approval prompts to the owner privately.", + ); + expect(privateReplies).toHaveLength(1); + expect(privateReplies[0]?.text).toContain("Codex diagnostics sent to OpenAI servers:"); + expect(privateReplies[0]?.text).toContain("codex-thread-1"); + }); + + it("requires an owner for diagnostics", async () => { + const { handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); + const result = await handleDiagnosticsCommand( + buildDiagnosticsParams("/diagnostics", { + command: { + ...buildDiagnosticsParams("/diagnostics").command, + senderIsOwner: false, + }, + }), + true, + ); + + expect(result).toEqual({ shouldContinue: false }); + }); + + it("routes confirmations back to the Codex diagnostics handler without repeating the preamble", async () => { + const { handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); + const commandHandler = vi.fn(async (ctx: PluginCommandContext) => ({ + text: `confirmed ${ctx.args}`, + })); + registerHostTrustedReservedCommandForTest({ + name: "codex", + description: "Codex command", + acceptsArgs: true, + handler: commandHandler, + ownership: "reserved", + }); + + const result = await handleDiagnosticsCommand( + buildDiagnosticsParams("/diagnostics confirm abc123def456"), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(commandHandler).toHaveBeenCalledTimes(1); + expect(result?.reply?.text).toBe("confirmed diagnostics confirm abc123def456"); + }); + + it("does not delegate diagnostics to a non-Codex plugin command", async () => { + const { handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); + const commandHandler = vi.fn(async () => ({ text: "wrong codex" })); + registerPluginCommand( + "third-party", + { + name: "codex", + description: "Fake Codex command", + acceptsArgs: true, + handler: commandHandler, + }, + { allowReservedCommandNames: true }, + ); + + const result = await handleDiagnosticsCommand( + buildDiagnosticsParams("/diagnostics confirm abc123def456"), + true, + ); + + expect(result?.reply?.text).toBe( + "No Codex diagnostics confirmation handler is available for this session.", + ); + expect(commandHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auto-reply/reply/commands-diagnostics.ts b/src/auto-reply/reply/commands-diagnostics.ts new file mode 100644 index 00000000000..cc1827d3d6b --- /dev/null +++ b/src/auto-reply/reply/commands-diagnostics.ts @@ -0,0 +1,614 @@ +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { createExecTool } from "../../agents/bash-tools.js"; +import type { ExecToolDetails } from "../../agents/bash-tools.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import type { ExecApprovalRequest } from "../../infra/exec-approvals.js"; +import type { InteractiveReply } from "../../interactive/payload.js"; +import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js"; +import type { PluginCommandDiagnosticsSession, PluginCommandResult } from "../../plugins/types.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { ReplyPayload } from "../types.js"; +import { rejectNonOwnerCommand } from "./command-gates.js"; +import { buildCurrentOpenClawCliCommand } from "./commands-openclaw-cli.js"; +import { + deliverPrivateCommandReply, + readCommandDeliveryTarget, + readCommandMessageThreadId, + resolvePrivateCommandRouteTargets, + type PrivateCommandRouteTarget, +} from "./commands-private-route.js"; +import type { CommandHandler, HandleCommandsParams } from "./commands-types.js"; + +const DIAGNOSTICS_COMMAND = "/diagnostics"; +const CODEX_DIAGNOSTICS_COMMAND = "/codex diagnostics"; +const DIAGNOSTICS_DOCS_URL = "https://docs.openclaw.ai/gateway/diagnostics"; +const GATEWAY_DIAGNOSTICS_EXPORT_JSON_LABEL = "openclaw gateway diagnostics export --json"; +const DIAGNOSTICS_EXEC_SCOPE_KEY = "chat:diagnostics"; +const DIAGNOSTICS_PRIVATE_ROUTE_UNAVAILABLE = + "I couldn't find a private owner approval route for diagnostics. Run /diagnostics from an owner DM so the sensitive diagnostics details are not posted in this chat."; +const DIAGNOSTICS_PRIVATE_ROUTE_ACK = + "Diagnostics are sensitive. I sent the diagnostics details and approval prompts to the owner privately."; + +type DiagnosticsCommandDeps = { + createExecTool: typeof createExecTool; + resolvePrivateDiagnosticsTargets: ( + params: HandleCommandsParams, + ) => Promise; + deliverPrivateDiagnosticsReply: (params: { + commandParams: HandleCommandsParams; + targets: PrivateCommandRouteTarget[]; + reply: ReplyPayload; + }) => Promise; +}; + +type GatewayDiagnosticsApprovalResult = + | { status: "pending" } + | { status: "reply"; reply: ReplyPayload }; + +type CodexDiagnosticsApprovalIntegration = { + approvalText?: string; + approvalFollowup?: () => Promise; +}; + +const defaultDiagnosticsCommandDeps: DiagnosticsCommandDeps = { + createExecTool, + resolvePrivateDiagnosticsTargets: resolvePrivateDiagnosticsTargetsForCommand, + deliverPrivateDiagnosticsReply: deliverPrivateDiagnosticsReply, +}; + +export function createDiagnosticsCommandHandler( + deps: Partial = {}, +): CommandHandler { + const resolvedDeps: DiagnosticsCommandDeps = { + ...defaultDiagnosticsCommandDeps, + ...deps, + }; + return async (params, allowTextCommands) => + await handleDiagnosticsCommandWithDeps(resolvedDeps, params, allowTextCommands); +} + +export const handleDiagnosticsCommand: CommandHandler = createDiagnosticsCommandHandler(); + +async function handleDiagnosticsCommandWithDeps( + deps: DiagnosticsCommandDeps, + params: HandleCommandsParams, + allowTextCommands: boolean, +) { + if (!allowTextCommands) { + return null; + } + const args = parseDiagnosticsArgs(params.command.commandBodyNormalized); + if (args == null) { + return null; + } + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /diagnostics from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + const ownerGate = rejectNonOwnerCommand(params, DIAGNOSTICS_COMMAND); + if (ownerGate) { + return ownerGate; + } + + if (isCodexDiagnosticsConfirmationAction(args)) { + const codexResult = await executeCodexDiagnosticsAddon(params, args); + const reply = codexResult + ? rewriteCodexDiagnosticsResult(codexResult) + : { text: "No Codex diagnostics confirmation handler is available for this session." }; + if (params.isGroup) { + return await deliverGroupDiagnosticsReplyPrivately(deps, params, reply); + } + return { + shouldContinue: false, + reply, + }; + } + + if (params.isGroup) { + const targets = await deps.resolvePrivateDiagnosticsTargets(params); + if (targets.length === 0) { + return { + shouldContinue: false, + reply: { text: DIAGNOSTICS_PRIVATE_ROUTE_UNAVAILABLE }, + }; + } + const privateReply = await buildDiagnosticsReply(deps, params, args, { + diagnosticsPrivateRouted: true, + privateApprovalTarget: targets[0], + }); + if (!privateReply) { + return { + shouldContinue: false, + reply: { text: DIAGNOSTICS_PRIVATE_ROUTE_ACK }, + }; + } + const delivered = await deps.deliverPrivateDiagnosticsReply({ + commandParams: params, + targets, + reply: privateReply, + }); + return { + shouldContinue: false, + reply: { + text: delivered ? DIAGNOSTICS_PRIVATE_ROUTE_ACK : DIAGNOSTICS_PRIVATE_ROUTE_UNAVAILABLE, + }, + }; + } + + const reply = await buildDiagnosticsReply(deps, params, args); + return reply ? { shouldContinue: false, reply } : { shouldContinue: false }; +} + +async function buildDiagnosticsReply( + deps: DiagnosticsCommandDeps, + params: HandleCommandsParams, + args: string, + options: { + diagnosticsPrivateRouted?: boolean; + privateApprovalTarget?: PrivateCommandRouteTarget; + } = {}, +): Promise { + const codexDiagnostics = await buildCodexDiagnosticsApprovalIntegration(params, args, options); + const gatewayApproval = await requestGatewayDiagnosticsExportApproval( + deps, + params, + options, + codexDiagnostics, + ); + if (gatewayApproval.status === "pending") { + return undefined; + } + return gatewayApproval.reply; +} + +async function deliverGroupDiagnosticsReplyPrivately( + deps: DiagnosticsCommandDeps, + params: HandleCommandsParams, + reply: ReplyPayload, +) { + const targets = await deps.resolvePrivateDiagnosticsTargets(params); + if (targets.length === 0) { + return { + shouldContinue: false, + reply: { text: DIAGNOSTICS_PRIVATE_ROUTE_UNAVAILABLE }, + }; + } + const delivered = await deps.deliverPrivateDiagnosticsReply({ + commandParams: params, + targets, + reply, + }); + return { + shouldContinue: false, + reply: { + text: delivered ? DIAGNOSTICS_PRIVATE_ROUTE_ACK : DIAGNOSTICS_PRIVATE_ROUTE_UNAVAILABLE, + }, + }; +} + +function parseDiagnosticsArgs(commandBody: string): string | undefined { + const trimmed = commandBody.trim(); + if (trimmed === DIAGNOSTICS_COMMAND) { + return ""; + } + if (trimmed.startsWith(`${DIAGNOSTICS_COMMAND} `)) { + return trimmed.slice(DIAGNOSTICS_COMMAND.length + 1).trim(); + } + if (trimmed.startsWith(`${DIAGNOSTICS_COMMAND}:`)) { + return trimmed.slice(DIAGNOSTICS_COMMAND.length + 1).trim(); + } + return undefined; +} + +function buildDiagnosticsPreamble(): string[] { + return [ + "Diagnostics can include sensitive local logs and host-level runtime metadata.", + `Treat diagnostics bundles like secrets and review what they contain before sharing: ${DIAGNOSTICS_DOCS_URL}`, + ]; +} + +function buildDiagnosticsApprovalWarning(codexApprovalText?: string): string { + const lines = buildDiagnosticsPreamble(); + if (codexApprovalText) { + lines.push("", codexApprovalText); + } + return lines.join("\n"); +} + +async function resolvePrivateDiagnosticsTargetsForCommand( + params: HandleCommandsParams, +): Promise { + return await resolvePrivateCommandRouteTargets({ + commandParams: params, + request: buildDiagnosticsApprovalRequest(params), + }); +} + +function buildDiagnosticsApprovalRequest(params: HandleCommandsParams): ExecApprovalRequest { + const now = Date.now(); + const agentId = + params.agentId ?? + resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + return { + id: "diagnostics-private-route", + request: { + command: buildGatewayDiagnosticsExportJsonCommand(), + agentId, + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + turnSourceChannel: params.command.channel, + turnSourceTo: readCommandDeliveryTarget(params) ?? null, + turnSourceAccountId: params.ctx.AccountId ?? null, + turnSourceThreadId: readCommandMessageThreadId(params) ?? null, + }, + createdAtMs: now, + expiresAtMs: now + 5 * 60_000, + }; +} + +function buildGatewayDiagnosticsExportJsonCommand(): string { + return buildCurrentOpenClawCliCommand(["gateway", "diagnostics", "export", "--json"]); +} + +async function deliverPrivateDiagnosticsReply(params: { + commandParams: HandleCommandsParams; + targets: PrivateCommandRouteTarget[]; + reply: ReplyPayload; +}): Promise { + return await deliverPrivateCommandReply(params); +} + +async function requestGatewayDiagnosticsExportApproval( + deps: DiagnosticsCommandDeps, + params: HandleCommandsParams, + options: { privateApprovalTarget?: PrivateCommandRouteTarget } = {}, + codexDiagnostics: CodexDiagnosticsApprovalIntegration = {}, +): Promise { + const timeoutSec = params.cfg.tools?.exec?.timeoutSec; + const agentId = + params.agentId ?? + resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + const messageThreadId = readCommandMessageThreadId(params); + const command = buildGatewayDiagnosticsExportJsonCommand(); + try { + const execTool = deps.createExecTool({ + host: "gateway", + security: "allowlist", + ask: "always", + trigger: "diagnostics", + scopeKey: DIAGNOSTICS_EXEC_SCOPE_KEY, + approvalWarningText: buildDiagnosticsApprovalWarning(codexDiagnostics.approvalText), + approvalFollowup: codexDiagnostics.approvalFollowup, + approvalFollowupMode: "direct", + allowBackground: true, + timeoutSec, + cwd: params.workspaceDir, + agentId, + sessionKey: params.sessionKey, + messageProvider: params.command.channel, + currentChannelId: options.privateApprovalTarget?.to ?? readCommandDeliveryTarget(params), + currentThreadTs: options.privateApprovalTarget + ? options.privateApprovalTarget.threadId == null + ? undefined + : String(options.privateApprovalTarget.threadId) + : messageThreadId, + accountId: options.privateApprovalTarget?.accountId ?? params.ctx.AccountId ?? undefined, + notifyOnExit: params.cfg.tools?.exec?.notifyOnExit, + notifyOnExitEmptySuccess: params.cfg.tools?.exec?.notifyOnExitEmptySuccess, + }); + const result = await execTool.execute("chat-diagnostics-gateway-export", { + command, + security: "allowlist", + ask: "always", + background: true, + timeout: timeoutSec, + }); + if (result.details?.status === "approval-pending") { + return { status: "pending" }; + } + const codexFollowupText = + result.details?.status === "completed" || result.details?.status === "failed" + ? await codexDiagnostics.approvalFollowup?.() + : undefined; + const lines = buildDiagnosticsPreamble(); + lines.push( + "", + `Local Gateway bundle: requested \`${GATEWAY_DIAGNOSTICS_EXPORT_JSON_LABEL}\` through exec approval. Approve once to create the bundle; do not use allow-all for diagnostics.`, + formatExecToolResultForDiagnostics(result), + ); + if (codexFollowupText) { + lines.push("", codexFollowupText); + } + return { status: "reply", reply: { text: lines.join("\n") } }; + } catch (error) { + const lines = buildDiagnosticsPreamble(); + lines.push( + "", + `Local Gateway bundle: could not request exec approval for \`${GATEWAY_DIAGNOSTICS_EXPORT_JSON_LABEL}\`.`, + formatExecDiagnosticsText(formatErrorMessage(error)), + ); + return { status: "reply", reply: { text: lines.join("\n") } }; + } +} + +async function buildCodexDiagnosticsApprovalIntegration( + params: HandleCommandsParams, + args: string, + options: { diagnosticsPrivateRouted?: boolean } = {}, +): Promise { + const hasHarnessMetadata = hasCodexHarnessMetadata(params); + const previewResult = await executeCodexDiagnosticsAddon(params, args, { + ...options, + diagnosticsPreviewOnly: true, + }); + if (!previewResult) { + return hasHarnessMetadata + ? { + approvalText: + "OpenAI Codex harness: selected for this session, but the bundled Codex diagnostics command is not registered.", + } + : undefined; + } + const preview = rewriteCodexDiagnosticsResult(previewResult); + if (!hasHarnessMetadata && isCodexDiagnosticsUnavailableText(preview.text)) { + return undefined; + } + return { + approvalText: preview.text ? ["OpenAI Codex harness:", preview.text].join("\n") : undefined, + approvalFollowup: async () => { + const uploadResult = await executeCodexDiagnosticsAddon(params, args, { + ...options, + diagnosticsUploadApproved: true, + }); + if (!uploadResult) { + return hasHarnessMetadata + ? "OpenAI Codex harness: selected for this session, but the bundled Codex diagnostics command is not registered." + : undefined; + } + const uploaded = rewriteCodexDiagnosticsResult(uploadResult); + if (!hasHarnessMetadata && isCodexDiagnosticsUnavailableText(uploaded.text)) { + return undefined; + } + return uploaded.text ? ["OpenAI Codex harness:", uploaded.text].join("\n") : undefined; + }, + }; +} + +function isCodexDiagnosticsConfirmationAction(args: string): boolean { + const [action, token] = args.trim().split(/\s+/, 2); + const normalized = action?.toLowerCase(); + return Boolean( + token && + (normalized === "confirm" || + normalized === "--confirm" || + normalized === "cancel" || + normalized === "--cancel"), + ); +} + +function hasCodexHarnessMetadata(params: HandleCommandsParams): boolean { + const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; + if (targetSessionEntry?.agentHarnessId === "codex") { + return true; + } + return Object.values(params.sessionStore ?? {}).some( + (entry) => entry?.agentHarnessId === "codex", + ); +} + +function isCodexDiagnosticsUnavailableText(text: string | undefined): boolean { + return ( + text?.startsWith("No Codex thread is attached to this OpenClaw session yet.") === true || + text?.startsWith( + "Cannot send Codex diagnostics because this command did not include an OpenClaw session file.", + ) === true + ); +} + +async function executeCodexDiagnosticsAddon( + params: HandleCommandsParams, + args: string, + options: { + diagnosticsPrivateRouted?: boolean; + diagnosticsUploadApproved?: boolean; + diagnosticsPreviewOnly?: boolean; + } = {}, +): Promise { + const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; + const commandBody = args ? `${CODEX_DIAGNOSTICS_COMMAND} ${args}` : CODEX_DIAGNOSTICS_COMMAND; + const match = matchPluginCommand(commandBody); + if (!match || match.command.pluginId !== "codex") { + return undefined; + } + return await executePluginCommand({ + command: match.command, + args: match.args, + senderId: params.command.senderId, + channel: params.command.channel, + channelId: params.command.channelId, + isAuthorizedSender: params.command.isAuthorizedSender, + senderIsOwner: params.command.senderIsOwner, + gatewayClientScopes: params.ctx.GatewayClientScopes, + sessionKey: params.sessionKey, + sessionId: targetSessionEntry?.sessionId, + sessionFile: targetSessionEntry?.sessionFile, + commandBody, + config: params.cfg, + from: params.command.from, + to: params.command.to, + accountId: params.ctx.AccountId ?? undefined, + messageThreadId: + typeof params.ctx.MessageThreadId === "string" || + typeof params.ctx.MessageThreadId === "number" + ? params.ctx.MessageThreadId + : undefined, + threadParentId: normalizeOptionalString(params.ctx.ThreadParentId), + diagnosticsSessions: buildCodexDiagnosticsSessions(params), + ...(options.diagnosticsUploadApproved === undefined + ? {} + : { diagnosticsUploadApproved: options.diagnosticsUploadApproved }), + ...(options.diagnosticsPreviewOnly === undefined + ? {} + : { diagnosticsPreviewOnly: options.diagnosticsPreviewOnly }), + ...(options.diagnosticsPrivateRouted === undefined + ? {} + : { diagnosticsPrivateRouted: options.diagnosticsPrivateRouted }), + }); +} + +function buildCodexDiagnosticsSessions( + params: HandleCommandsParams, +): PluginCommandDiagnosticsSession[] { + const sessions = new Map(); + const activeEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; + if (activeEntry) { + sessions.set(params.sessionKey, activeEntry); + } + for (const [sessionKey, entry] of Object.entries(params.sessionStore ?? {})) { + if (entry) { + sessions.set(sessionKey, entry); + } + } + return Array.from(sessions.entries()) + .filter(([, entry]) => Boolean(entry.sessionFile)) + .map(([sessionKey, entry]) => ({ + sessionKey, + sessionId: entry.sessionId, + sessionFile: entry.sessionFile, + agentHarnessId: entry.agentHarnessId, + channel: resolveDiagnosticsSessionChannel(entry, params, sessionKey), + channelId: resolveDiagnosticsSessionChannelId(entry, params, sessionKey), + accountId: + normalizeOptionalString(entry.deliveryContext?.accountId) ?? + normalizeOptionalString(entry.origin?.accountId) ?? + normalizeOptionalString(entry.lastAccountId) ?? + (sessionKey === params.sessionKey ? (params.ctx.AccountId ?? undefined) : undefined), + messageThreadId: + entry.deliveryContext?.threadId ?? + entry.origin?.threadId ?? + entry.lastThreadId ?? + (sessionKey === params.sessionKey && + (typeof params.ctx.MessageThreadId === "string" || + typeof params.ctx.MessageThreadId === "number") + ? params.ctx.MessageThreadId + : undefined), + threadParentId: + sessionKey === params.sessionKey + ? normalizeOptionalString(params.ctx.ThreadParentId) + : undefined, + })); +} + +function resolveDiagnosticsSessionChannel( + entry: SessionEntry, + params: HandleCommandsParams, + sessionKey: string, +): string | undefined { + return ( + normalizeOptionalString(entry.deliveryContext?.channel) ?? + normalizeOptionalString(entry.origin?.provider) ?? + normalizeOptionalString(entry.channel) ?? + normalizeOptionalString(entry.lastChannel) ?? + (sessionKey === params.sessionKey ? params.command.channel : undefined) + ); +} + +function resolveDiagnosticsSessionChannelId( + entry: SessionEntry, + params: HandleCommandsParams, + sessionKey: string, +) { + return ( + normalizeOptionalString(entry.origin?.nativeChannelId) ?? + (sessionKey === params.sessionKey ? params.command.channelId : undefined) + ); +} + +function formatExecToolResultForDiagnostics(result: { + content?: Array<{ type: string; text?: string }>; + details?: ExecToolDetails; +}): string { + const text = result.content + ?.map((chunk) => (chunk.type === "text" && typeof chunk.text === "string" ? chunk.text : "")) + .filter(Boolean) + .join("\n") + .trim(); + if (text) { + return formatExecDiagnosticsText(text); + } + const details = result.details; + if (details?.status === "approval-pending") { + const decisions = details.allowedDecisions?.join(", ") || "allow-once, deny"; + return formatExecDiagnosticsText( + `Exec approval pending (${details.approvalSlug}). Allowed decisions: ${decisions}.`, + ); + } + if (details?.status === "running") { + return formatExecDiagnosticsText( + `Gateway diagnostics export is running (exec session ${details.sessionId}).`, + ); + } + if (details?.status === "completed" || details?.status === "failed") { + return formatExecDiagnosticsText(details.aggregated); + } + return "(no exec details returned)"; +} + +function formatExecDiagnosticsText(text: string): string { + const trimmed = text.trim(); + if (!trimmed) { + return "(no exec output)"; + } + return trimmed; +} + +function rewriteCodexDiagnosticsResult(result: PluginCommandResult): PluginCommandResult { + const { continueAgent: _continueAgent, ...reply } = result; + void _continueAgent; + return { + ...reply, + ...(reply.text ? { text: rewriteCodexDiagnosticsCommandPrefix(reply.text) } : {}), + ...(reply.interactive ? { interactive: rewriteInteractive(reply.interactive) } : {}), + }; +} + +function rewriteInteractive(interactive: InteractiveReply): InteractiveReply { + return { + blocks: interactive.blocks.map((block) => { + if (block.type === "buttons") { + return { + ...block, + buttons: block.buttons.map((button) => ({ + ...button, + ...(button.value ? { value: rewriteCodexDiagnosticsCommandPrefix(button.value) } : {}), + })), + }; + } + if (block.type === "select") { + return { + ...block, + options: block.options.map((option) => ({ + ...option, + value: rewriteCodexDiagnosticsCommandPrefix(option.value), + })), + }; + } + return block; + }), + }; +} + +function rewriteCodexDiagnosticsCommandPrefix(value: string): string { + return value + .replaceAll(`${CODEX_DIAGNOSTICS_COMMAND} confirm`, `${DIAGNOSTICS_COMMAND} confirm`) + .replaceAll(`${CODEX_DIAGNOSTICS_COMMAND} cancel`, `${DIAGNOSTICS_COMMAND} cancel`); +} diff --git a/src/auto-reply/reply/commands-export-common.ts b/src/auto-reply/reply/commands-export-common.ts index 9e2d0a7c675..61a55a29636 100644 --- a/src/auto-reply/reply/commands-export-common.ts +++ b/src/auto-reply/reply/commands-export-common.ts @@ -15,6 +15,8 @@ export interface ExportCommandSessionTarget { sessionFile: string; } +export const MAX_EXPORT_COMMAND_OUTPUT_PATH_CHARS = 512; + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -22,7 +24,7 @@ function escapeRegExp(value: string): string { export function parseExportCommandOutputPath( commandBodyNormalized: string, aliases: readonly string[], -): { outputPath?: string } { +): { outputPath?: string; error?: string } { const normalized = commandBodyNormalized.trim(); if (aliases.some((alias) => normalized === `/${alias}`)) { return {}; @@ -30,6 +32,11 @@ export function parseExportCommandOutputPath( const aliasPattern = aliases.map(escapeRegExp).join("|"); const args = normalized.replace(new RegExp(`^/(${aliasPattern})\\s*`), "").trim(); const outputPath = args.split(/\s+/).find((part) => !part.startsWith("-")); + if (outputPath && outputPath.length > MAX_EXPORT_COMMAND_OUTPUT_PATH_CHARS) { + return { + error: `❌ Output path is too long. Keep it at ${MAX_EXPORT_COMMAND_OUTPUT_PATH_CHARS} characters or less.`, + }; + } return { outputPath }; } diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index f2ecdfe8eb2..417d1304f51 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -121,6 +121,9 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro "export-session", "export", ]); + if (args.error) { + return { text: args.error }; + } const sessionTarget = resolveExportCommandSessionTarget(params); if (isReplyPayload(sessionTarget)) { return sessionTarget; diff --git a/src/auto-reply/reply/commands-export-trajectory.test.ts b/src/auto-reply/reply/commands-export-trajectory.test.ts index 76b962c3fc5..78a385b74ff 100644 --- a/src/auto-reply/reply/commands-export-trajectory.test.ts +++ b/src/auto-reply/reply/commands-export-trajectory.test.ts @@ -68,6 +68,7 @@ function makeParams(workspaceDir = makeTempDir()): HandleCommandsParams { cfg: {}, ctx: { SessionKey: "agent:main:slash-session", + AccountId: "account-1", }, command: { commandBodyNormalized: "/export-trajectory", @@ -78,6 +79,8 @@ function makeParams(workspaceDir = makeTempDir()): HandleCommandsParams { surface: "quietchat", ownerList: [], rawBodyNormalized: "/export-trajectory", + from: "sender-1", + to: "bot", }, sessionEntry: { sessionId: "session-1", @@ -98,6 +101,56 @@ function makeParams(workspaceDir = makeTempDir()): HandleCommandsParams { } as unknown as HandleCommandsParams; } +function createExecDeps( + options: { + privateTargets?: Array<{ channel: string; to: string; accountId?: string | null }>; + } = {}, +) { + const execCalls: Array<{ defaults: unknown; params: unknown }> = []; + const privateReplies: Array<{ + targets: Array<{ channel: string; to: string; accountId?: string | null }>; + text?: string; + }> = []; + const createExecTool = vi.fn((defaults: unknown) => ({ + execute: vi.fn(async (_toolCallId: string, params: unknown) => { + execCalls.push({ defaults, params }); + return { + details: { + status: "approval-pending" as const, + approvalId: "approval-1", + approvalSlug: "traj-approval", + expiresAtMs: Date.now() + 60_000, + allowedDecisions: ["allow-once", "deny"] as const, + host: "gateway" as const, + command: "openclaw sessions export-trajectory --session-key agent:target:session", + cwd: "/tmp", + }, + }; + }), + })); + return { + execCalls, + privateReplies, + deps: { + createExecTool: createExecTool as never, + resolvePrivateTrajectoryTargets: vi.fn(async () => options.privateTargets ?? []), + deliverPrivateTrajectoryReply: vi.fn(async ({ targets, reply }) => { + privateReplies.push({ targets, text: reply.text }); + return true; + }), + }, + }; +} + +function readEncodedRequestFromCommand(command: string): Record { + const match = command.match(/'?--request-json-base64'?\s+'?([A-Za-z0-9_-]+)'?/u); + expect(match?.[1]).toBeTruthy(); + return JSON.parse(Buffer.from(match?.[1] ?? "", "base64url").toString("utf8")) as Record< + string, + unknown + >; +} + describe("buildExportTrajectoryReply", () => { beforeEach(() => { vi.clearAllMocks(); @@ -225,3 +278,166 @@ describe("buildExportTrajectoryReply", () => { expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled(); }); }); + +describe("buildExportTrajectoryCommandReply", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("requests per-run exec approval for trajectory exports", async () => { + const { buildExportTrajectoryCommandReply } = await import("./commands-export-trajectory.js"); + const { execCalls, deps } = createExecDeps(); + + const reply = await buildExportTrajectoryCommandReply(makeParams(), deps); + + expect(reply.text).toContain( + "Trajectory exports can include prompts, model messages, tool schemas", + ); + expect(reply.text).toContain("https://docs.openclaw.ai/tools/trajectory"); + expect(reply.text).toContain("do not use allow-all"); + expect(reply.text).toContain("Allowed decisions: allow-once, deny"); + expect(execCalls).toHaveLength(1); + expect(execCalls[0]?.defaults).toMatchObject({ + host: "gateway", + security: "allowlist", + ask: "always", + trigger: "export-trajectory", + currentChannelId: "bot", + accountId: "account-1", + }); + expect(execCalls[0]?.params).toMatchObject({ + security: "allowlist", + ask: "always", + background: true, + }); + const command = (execCalls[0]?.params as { command?: string }).command ?? ""; + expect(command).toContain("sessions"); + expect(command).toContain("export-trajectory"); + expect(command).toContain("--request-json-base64"); + expect(command).toContain("--json"); + expect(command).not.toContain("--session-key"); + expect(command).not.toContain("openclaw sessions export-trajectory"); + const request = readEncodedRequestFromCommand(command); + expect(request).toMatchObject({ + sessionKey: "agent:target:session", + workspace: expect.stringContaining("openclaw-export-command-"), + }); + }); + + it("uses the originating Telegram route for native trajectory export followups", async () => { + const { buildExportTrajectoryCommandReply } = await import("./commands-export-trajectory.js"); + const { execCalls, deps } = createExecDeps(); + const params = makeParams(); + params.ctx = { + ...params.ctx, + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:8460800771", + From: "telegram:8460800771", + To: "slash:8460800771", + CommandSource: "native", + }; + params.command = { + ...params.command, + channel: "telegram", + surface: "telegram", + from: "telegram:8460800771", + to: "slash:8460800771", + }; + + await buildExportTrajectoryCommandReply(params, deps); + + expect(execCalls).toHaveLength(1); + expect(execCalls[0]?.defaults).toMatchObject({ + messageProvider: "telegram", + currentChannelId: "telegram:8460800771", + accountId: "account-1", + }); + }); + + it("keeps user-controlled export values out of the shell command", async () => { + const { buildExportTrajectoryCommandReply } = await import("./commands-export-trajectory.js"); + const { execCalls, deps } = createExecDeps(); + const params = makeParams(); + params.command.commandBodyNormalized = "/export-trajectory bad'; Invoke-Expression evil ;'"; + + await buildExportTrajectoryCommandReply(params, deps); + + const command = (execCalls[0]?.params as { command?: string }).command ?? ""; + expect(command).toMatch(/'?sessions'?\s+'?export-trajectory'?/u); + expect(command).toMatch(/'?--request-json-base64'?\s+'?[A-Za-z0-9_-]+'?/u); + expect(command).toMatch(/'?--json'?$/u); + expect(command).not.toContain("Invoke-Expression"); + expect(readEncodedRequestFromCommand(command)).toMatchObject({ + output: "bad';", + }); + }); + + it("rejects oversized output paths before requesting exec approval", async () => { + const { buildExportTrajectoryCommandReply } = await import("./commands-export-trajectory.js"); + const { execCalls, deps } = createExecDeps(); + const params = makeParams(); + params.command.commandBodyNormalized = `/export-trajectory ${"a".repeat(513)}`; + + const reply = await buildExportTrajectoryCommandReply(params, deps); + + expect(reply.text).toContain("Output path is too long"); + expect(execCalls).toHaveLength(0); + }); + + it("rejects oversized encoded export requests before requesting exec approval", async () => { + const { buildExportTrajectoryCommandReply } = await import("./commands-export-trajectory.js"); + const { execCalls, deps } = createExecDeps(); + const params = makeParams(); + params.workspaceDir = `/${"workspace".repeat(1200)}`; + + const reply = await buildExportTrajectoryCommandReply(params, deps); + + expect(reply.text).toContain("Encoded trajectory export request is too large"); + expect(execCalls).toHaveLength(0); + }); + + it("routes group trajectory export approval privately", async () => { + const { buildExportTrajectoryCommandReply } = await import("./commands-export-trajectory.js"); + const { execCalls, privateReplies, deps } = createExecDeps({ + privateTargets: [{ channel: "quietchat", to: "owner-dm", accountId: "account-1" }], + }); + const params = makeParams(); + params.isGroup = true; + params.command.to = "group-1"; + + const reply = await buildExportTrajectoryCommandReply(params, deps); + + expect(reply.text).toBe( + "Trajectory exports are sensitive. I sent the export request and approval prompt to the owner privately.", + ); + expect(reply.text).not.toContain("agent:target:session"); + expect(privateReplies).toHaveLength(1); + expect(privateReplies[0]?.targets).toEqual([ + { channel: "quietchat", to: "owner-dm", accountId: "account-1" }, + ]); + expect(privateReplies[0]?.text).toContain("Trajectory exports can include prompts"); + expect(privateReplies[0]?.text).toContain("openclaw sessions export-trajectory"); + expect(privateReplies[0]?.text).toContain("Session: agent:target:session"); + expect(execCalls).toHaveLength(1); + expect(execCalls[0]?.defaults).toMatchObject({ + currentChannelId: "owner-dm", + accountId: "account-1", + }); + }); + + it("fails closed in groups when no private owner route is available", async () => { + const { buildExportTrajectoryCommandReply } = await import("./commands-export-trajectory.js"); + const { execCalls, privateReplies, deps } = createExecDeps(); + const params = makeParams(); + params.isGroup = true; + params.command.to = "group-1"; + + const reply = await buildExportTrajectoryCommandReply(params, deps); + + expect(reply.text).toContain("Run /export-trajectory from an owner DM"); + expect(execCalls).toHaveLength(0); + expect(privateReplies).toHaveLength(0); + }); +}); diff --git a/src/auto-reply/reply/commands-export-trajectory.ts b/src/auto-reply/reply/commands-export-trajectory.ts index a85703a88fa..ec0465b322d 100644 --- a/src/auto-reply/reply/commands-export-trajectory.ts +++ b/src/auto-reply/reply/commands-export-trajectory.ts @@ -1,113 +1,119 @@ import fs from "node:fs"; -import path from "node:path"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { createExecTool } from "../../agents/bash-tools.js"; +import type { ExecToolDetails } from "../../agents/bash-tools.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import type { ExecApprovalRequest } from "../../infra/exec-approvals.js"; import { - exportTrajectoryBundle, - resolveDefaultTrajectoryExportDir, -} from "../../trajectory/export.js"; + exportTrajectoryForCommand, + formatTrajectoryCommandExportSummary, + resolveTrajectoryCommandOutputDir, +} from "../../trajectory/command-export.js"; import type { ReplyPayload } from "../types.js"; import { isReplyPayload, parseExportCommandOutputPath, resolveExportCommandSessionTarget, } from "./commands-export-common.js"; +import { + buildCurrentOpenClawCliArgv, + buildCurrentOpenClawCliCommand, +} from "./commands-openclaw-cli.js"; +import { + deliverPrivateCommandReply, + readCommandDeliveryTarget, + readCommandMessageThreadId, + resolvePrivateCommandRouteTargets, + type PrivateCommandRouteTarget, +} from "./commands-private-route.js"; import type { HandleCommandsParams } from "./commands-types.js"; -function isPathInsideOrEqual(baseDir: string, candidate: string): boolean { - const relative = path.relative(baseDir, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} +const EXPORT_TRAJECTORY_DOCS_URL = "https://docs.openclaw.ai/tools/trajectory"; +const EXPORT_TRAJECTORY_EXEC_SCOPE_KEY = "chat:export-trajectory"; +const MAX_TRAJECTORY_EXPORT_ENCODED_REQUEST_CHARS = 8192; +const EXPORT_TRAJECTORY_PRIVATE_ROUTE_UNAVAILABLE = + "I couldn't find a private owner approval route for the trajectory export. Run /export-trajectory from an owner DM so the sensitive trajectory bundle is not posted in this chat."; +const EXPORT_TRAJECTORY_PRIVATE_ROUTE_ACK = + "Trajectory exports are sensitive. I sent the export request and approval prompt to the owner privately."; -function validateExistingExportDirectory(params: { - dir: string; - label: string; - realWorkspace: string; -}): string { - const linkStat = fs.lstatSync(params.dir); - if (linkStat.isSymbolicLink() || !linkStat.isDirectory()) { - throw new Error(`${params.label} must be a real directory inside the workspace`); - } - const realDir = fs.realpathSync(params.dir); - if (!isPathInsideOrEqual(params.realWorkspace, realDir)) { - throw new Error("Trajectory exports directory must stay inside the workspace"); - } - return realDir; -} +type ExportTrajectoryCommandDeps = { + createExecTool: typeof createExecTool; + resolvePrivateTrajectoryTargets: ( + params: HandleCommandsParams, + request: TrajectoryExportExecRequest, + ) => Promise; + deliverPrivateTrajectoryReply: (params: { + commandParams: HandleCommandsParams; + targets: PrivateCommandRouteTarget[]; + reply: ReplyPayload; + }) => Promise; +}; -function mkdirIfMissingThenValidate(params: { - dir: string; - label: string; - realWorkspace: string; -}): string { - if (!fs.existsSync(params.dir)) { - try { - fs.mkdirSync(params.dir, { mode: 0o700 }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") { - throw error; - } +const defaultExportTrajectoryCommandDeps: ExportTrajectoryCommandDeps = { + createExecTool, + resolvePrivateTrajectoryTargets: resolvePrivateTrajectoryTargetsForCommand, + deliverPrivateTrajectoryReply: deliverPrivateTrajectoryReply, +}; + +export async function buildExportTrajectoryCommandReply( + params: HandleCommandsParams, + deps: Partial = {}, +): Promise { + const resolvedDeps: ExportTrajectoryCommandDeps = { + ...defaultExportTrajectoryCommandDeps, + ...deps, + }; + const args = parseExportCommandOutputPath(params.command.commandBodyNormalized, [ + "export-trajectory", + "trajectory", + ]); + if (args.error) { + return { text: args.error }; + } + let request: TrajectoryExportExecRequest; + try { + request = buildTrajectoryExportExecRequest(params, args.outputPath); + } catch (error) { + return { text: `❌ Failed to prepare trajectory export request: ${formatErrorMessage(error)}` }; + } + if (params.isGroup) { + const targets = await resolvedDeps.resolvePrivateTrajectoryTargets(params, request); + if (targets.length === 0) { + return { text: EXPORT_TRAJECTORY_PRIVATE_ROUTE_UNAVAILABLE }; } - } - return validateExistingExportDirectory(params); -} - -function resolveTrajectoryExportBaseDir(workspaceDir: string): { - baseDir: string; - realBase: string; -} { - const workspacePath = path.resolve(workspaceDir); - const realWorkspace = fs.realpathSync(workspacePath); - const stateDir = path.join(workspacePath, ".openclaw"); - mkdirIfMissingThenValidate({ - dir: stateDir, - label: "OpenClaw state directory", - realWorkspace, - }); - const baseDir = path.join(stateDir, "trajectory-exports"); - const realBase = mkdirIfMissingThenValidate({ - dir: baseDir, - label: "Trajectory exports directory", - realWorkspace, - }); - return { baseDir: path.resolve(baseDir), realBase }; -} - -function resolveTrajectoryCommandOutputDir(params: { - outputPath?: string; - workspaceDir: string; - sessionId: string; -}): string { - const { baseDir, realBase } = resolveTrajectoryExportBaseDir(params.workspaceDir); - const raw = params.outputPath?.trim(); - if (!raw) { - const defaultDir = resolveDefaultTrajectoryExportDir({ - workspaceDir: params.workspaceDir, - sessionId: params.sessionId, + const privateReply = await buildExportTrajectoryApprovalReply(resolvedDeps, params, request, { + privateApprovalTarget: targets[0], }); - return path.join(baseDir, path.basename(defaultDir)); + const delivered = await resolvedDeps.deliverPrivateTrajectoryReply({ + commandParams: params, + targets, + reply: privateReply, + }); + return { + text: delivered + ? EXPORT_TRAJECTORY_PRIVATE_ROUTE_ACK + : EXPORT_TRAJECTORY_PRIVATE_ROUTE_UNAVAILABLE, + }; } - if (path.isAbsolute(raw) || raw.startsWith("~")) { - throw new Error("Output path must be relative to the workspace trajectory exports directory"); - } - const resolvedBase = path.resolve(baseDir); - const outputDir = path.resolve(resolvedBase, raw); - const relative = path.relative(resolvedBase, outputDir); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { - throw new Error("Output path must stay inside the workspace trajectory exports directory"); - } - let existingParent = outputDir; - while (!fs.existsSync(existingParent)) { - const next = path.dirname(existingParent); - if (next === existingParent) { - break; - } - existingParent = next; - } - const realExistingParent = fs.realpathSync(existingParent); - if (!isPathInsideOrEqual(realBase, realExistingParent)) { - throw new Error("Output path must stay inside the real trajectory exports directory"); - } - return outputDir; + return await buildExportTrajectoryApprovalReply(resolvedDeps, params, request); +} + +async function buildExportTrajectoryApprovalReply( + deps: ExportTrajectoryCommandDeps, + params: HandleCommandsParams, + request: TrajectoryExportExecRequest, + options: { privateApprovalTarget?: PrivateCommandRouteTarget } = {}, +): Promise { + return { + text: [ + "Trajectory exports can include prompts, model messages, tool schemas, tool results, runtime events, and local paths.", + `Treat trajectory bundles like secrets and review them before sharing: ${EXPORT_TRAJECTORY_DOCS_URL}`, + "", + formatTrajectoryExportRequestDetails(request.request), + "", + await requestTrajectoryExportApproval(deps, params, request, options), + ].join("\n"), + }; } export async function buildExportTrajectoryReply( @@ -117,6 +123,9 @@ export async function buildExportTrajectoryReply( "export-trajectory", "trajectory", ]); + if (args.error) { + return { text: args.error }; + } const sessionTarget = resolveExportCommandSessionTarget(params); if (isReplyPayload(sessionTarget)) { return sessionTarget; @@ -140,9 +149,9 @@ export async function buildExportTrajectoryReply( }; } - let bundle: ReturnType; + let summary: ReturnType; try { - bundle = exportTrajectoryBundle({ + summary = exportTrajectoryForCommand({ outputDir, sessionFile, sessionId: entry.sessionId, @@ -155,27 +164,209 @@ export async function buildExportTrajectoryReply( }; } - const relativePath = path.relative(params.workspaceDir, bundle.outputDir); - const displayPath = - relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath) - ? relativePath - : path.basename(bundle.outputDir); - const files = ["manifest.json", "events.jsonl", "session-branch.json"]; - if (bundle.events.some((event) => event.type === "context.compiled")) { - files.push("system-prompt.txt", "tools.json"); - } - files.push(...bundle.supplementalFiles); - return { - text: [ - "✅ Trajectory exported!", - "", - `📦 Bundle: ${displayPath}`, - `🧵 Session: ${entry.sessionId}`, - `📊 Events: ${bundle.manifest.eventCount}`, - `🧪 Runtime events: ${bundle.manifest.runtimeEventCount}`, - `📝 Transcript events: ${bundle.manifest.transcriptEventCount}`, - `📁 Files: ${files.join(", ")}`, - ].join("\n"), + text: formatTrajectoryCommandExportSummary(summary), }; } + +async function resolvePrivateTrajectoryTargetsForCommand( + params: HandleCommandsParams, + request: TrajectoryExportExecRequest, +): Promise { + return await resolvePrivateCommandRouteTargets({ + commandParams: params, + request: buildTrajectoryExportApprovalRequest(params, request), + }); +} + +async function deliverPrivateTrajectoryReply(params: { + commandParams: HandleCommandsParams; + targets: PrivateCommandRouteTarget[]; + reply: ReplyPayload; +}): Promise { + return await deliverPrivateCommandReply(params); +} + +function buildTrajectoryExportApprovalRequest( + params: HandleCommandsParams, + request: TrajectoryExportExecRequest, +): ExecApprovalRequest { + const now = Date.now(); + const agentId = + params.agentId ?? + resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + return { + id: "trajectory-export-private-route", + request: { + command: request.command, + commandArgv: request.argv, + agentId, + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + turnSourceChannel: params.command.channel, + turnSourceTo: readCommandDeliveryTarget(params) ?? null, + turnSourceAccountId: params.ctx.AccountId ?? null, + turnSourceThreadId: readCommandMessageThreadId(params) ?? null, + }, + createdAtMs: now, + expiresAtMs: now + 5 * 60_000, + }; +} + +async function requestTrajectoryExportApproval( + deps: ExportTrajectoryCommandDeps, + params: HandleCommandsParams, + request: TrajectoryExportExecRequest, + options: { privateApprovalTarget?: PrivateCommandRouteTarget } = {}, +): Promise { + const timeoutSec = params.cfg.tools?.exec?.timeoutSec; + const agentId = + params.agentId ?? + resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + const messageThreadId = readCommandMessageThreadId(params); + try { + const execTool = deps.createExecTool({ + host: "gateway", + security: "allowlist", + ask: "always", + trigger: "export-trajectory", + scopeKey: EXPORT_TRAJECTORY_EXEC_SCOPE_KEY, + allowBackground: true, + timeoutSec, + cwd: params.workspaceDir, + agentId, + sessionKey: params.sessionKey, + messageProvider: params.command.channel, + currentChannelId: options.privateApprovalTarget?.to ?? readCommandDeliveryTarget(params), + currentThreadTs: options.privateApprovalTarget + ? options.privateApprovalTarget.threadId == null + ? undefined + : String(options.privateApprovalTarget.threadId) + : messageThreadId, + accountId: options.privateApprovalTarget?.accountId ?? params.ctx.AccountId ?? undefined, + notifyOnExit: params.cfg.tools?.exec?.notifyOnExit, + notifyOnExitEmptySuccess: params.cfg.tools?.exec?.notifyOnExitEmptySuccess, + }); + const result = await execTool.execute("chat-export-trajectory", { + command: request.command, + security: "allowlist", + ask: "always", + background: true, + timeout: timeoutSec, + }); + return [ + `Trajectory bundle: requested \`${request.displayCommand}\` through exec approval. Approve once to create the bundle; do not use allow-all for trajectory exports.`, + formatExecToolResultForTrajectory(result), + ].join("\n"); + } catch (error) { + return [ + `Trajectory bundle: could not request exec approval for \`${request.displayCommand}\`.`, + formatExecTrajectoryText(formatErrorMessage(error)), + ].join("\n"); + } +} + +function formatExecToolResultForTrajectory(result: { + content?: Array<{ type: string; text?: string }>; + details?: ExecToolDetails; +}): string { + const text = result.content + ?.map((chunk) => (chunk.type === "text" && typeof chunk.text === "string" ? chunk.text : "")) + .filter(Boolean) + .join("\n") + .trim(); + if (text) { + return formatExecTrajectoryText(text); + } + const details = result.details; + if (details?.status === "approval-pending") { + const decisions = details.allowedDecisions?.join(", ") || "allow-once, deny"; + return formatExecTrajectoryText( + `Exec approval pending (${details.approvalSlug}). Allowed decisions: ${decisions}.`, + ); + } + if (details?.status === "running") { + return formatExecTrajectoryText( + `Trajectory export is running (exec session ${details.sessionId}).`, + ); + } + if (details?.status === "completed" || details?.status === "failed") { + return formatExecTrajectoryText(details.aggregated); + } + return "(no exec details returned)"; +} + +function formatExecTrajectoryText(text: string): string { + const trimmed = text.trim(); + if (!trimmed) { + return "(no exec output)"; + } + return trimmed; +} + +type TrajectoryExportCliRequest = { + sessionKey: string; + workspace: string; + output?: string; + store?: string; + agent?: string; +}; + +type TrajectoryExportExecRequest = { + argv: string[]; + command: string; + displayCommand: string; + encodedRequest: string; + request: TrajectoryExportCliRequest; +}; + +function buildTrajectoryExportExecRequest( + params: HandleCommandsParams, + outputPath?: string, +): TrajectoryExportExecRequest { + const request: TrajectoryExportCliRequest = { + sessionKey: params.sessionKey, + workspace: params.workspaceDir, + }; + if (outputPath) { + request.output = outputPath; + } + if (params.storePath && params.storePath !== "(multiple)") { + request.store = params.storePath; + } + if (params.agentId) { + request.agent = params.agentId; + } + const encodedRequest = Buffer.from(JSON.stringify(request), "utf8").toString("base64url"); + if (encodedRequest.length > MAX_TRAJECTORY_EXPORT_ENCODED_REQUEST_CHARS) { + throw new Error("Encoded trajectory export request is too large"); + } + const args = ["sessions", "export-trajectory", "--request-json-base64", encodedRequest, "--json"]; + return { + argv: buildCurrentOpenClawCliArgv(args), + command: buildCurrentOpenClawCliCommand(args), + displayCommand: ["openclaw", ...args].join(" "), + encodedRequest, + request, + }; +} + +function formatTrajectoryExportRequestDetails(request: TrajectoryExportCliRequest): string { + const lines = [ + `Session: ${request.sessionKey}`, + `Workspace: ${request.workspace}`, + `Output: ${request.output ?? "(default)"}`, + ]; + if (request.store) { + lines.push(`Store: ${request.store}`); + } + if (request.agent) { + lines.push(`Agent: ${request.agent}`); + } + return lines.join("\n"); +} diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts index 901cfdbbad9..c2f7fe51e4e 100644 --- a/src/auto-reply/reply/commands-handlers.runtime.ts +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -7,6 +7,7 @@ import { handleCompactCommand } from "./commands-compact.js"; import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; import { handleContextCommand } from "./commands-context-command.js"; import { handleCrestodianCommand } from "./commands-crestodian.js"; +import { handleDiagnosticsCommand } from "./commands-diagnostics.js"; import { handleDockCommand } from "./commands-dock.js"; import { handleCommandsListCommand, @@ -53,6 +54,7 @@ export function loadCommandHandlers(): CommandHandler[] { handleCommandsListCommand, handleToolsCommand, handleStatusCommand, + handleDiagnosticsCommand, handleTasksCommand, handleAllowlistCommand, handleApproveCommand, diff --git a/src/auto-reply/reply/commands-info.test.ts b/src/auto-reply/reply/commands-info.test.ts index 50e43e2ca05..401849ec410 100644 --- a/src/auto-reply/reply/commands-info.test.ts +++ b/src/auto-reply/reply/commands-info.test.ts @@ -9,7 +9,9 @@ import type { HandleCommandsParams } from "./commands-types.js"; import { handleWhoamiCommand } from "./commands-whoami.js"; const buildContextReplyMock = vi.hoisted(() => vi.fn()); -const buildExportTrajectoryReplyMock = vi.hoisted(() => vi.fn(async () => ({ text: "exported" }))); +const buildExportTrajectoryCommandReplyMock = vi.hoisted(() => + vi.fn(async () => ({ text: "exported" })), +); const listSkillCommandsForAgentsMock = vi.hoisted(() => vi.fn(() => [])); const buildCommandsMessagePaginatedMock = vi.hoisted(() => vi.fn(() => ({ text: "/commands", currentPage: 1, totalPages: 1 })), @@ -20,7 +22,7 @@ vi.mock("./commands-context-report.js", () => ({ })); vi.mock("./commands-export-trajectory.js", () => ({ - buildExportTrajectoryReply: buildExportTrajectoryReplyMock, + buildExportTrajectoryCommandReply: buildExportTrajectoryCommandReplyMock, })); vi.mock("./commands-status.js", () => ({ @@ -92,7 +94,7 @@ function buildInfoParams( describe("info command handlers", () => { beforeEach(() => { vi.clearAllMocks(); - buildExportTrajectoryReplyMock.mockResolvedValue({ text: "exported" }); + buildExportTrajectoryCommandReplyMock.mockResolvedValue({ text: "exported" }); buildContextReplyMock.mockImplementation(async (params: HandleCommandsParams) => { const normalized = params.command.commandBodyNormalized; if (normalized === "/context list") { @@ -119,7 +121,7 @@ describe("info command handlers", () => { const result = await handleExportTrajectoryCommand(params, true); expect(result).toEqual({ shouldContinue: false }); - expect(buildExportTrajectoryReplyMock).not.toHaveBeenCalled(); + expect(buildExportTrajectoryCommandReplyMock).not.toHaveBeenCalled(); }); it("returns sender details for /whoami", async () => { diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index ee463f95271..a6967c1c9d1 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -13,7 +13,7 @@ import { buildThreadingToolContext } from "./agent-runner-utils.js"; import { resolveChannelAccountId } from "./channel-context.js"; import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js"; import { buildExportSessionReply } from "./commands-export-session.js"; -import { buildExportTrajectoryReply } from "./commands-export-trajectory.js"; +import { buildExportTrajectoryCommandReply } from "./commands-export-trajectory.js"; import { buildStatusReply } from "./commands-status.js"; import type { CommandHandler } from "./commands-types.js"; import { extractExplicitGroupId } from "./group-id.js"; @@ -259,5 +259,5 @@ export const handleExportTrajectoryCommand: CommandHandler = async (params, allo if (nonOwner) { return nonOwner; } - return { shouldContinue: false, reply: await buildExportTrajectoryReply(params) }; + return { shouldContinue: false, reply: await buildExportTrajectoryCommandReply(params) }; }; diff --git a/src/auto-reply/reply/commands-openclaw-cli.ts b/src/auto-reply/reply/commands-openclaw-cli.ts new file mode 100644 index 00000000000..4a11eb77452 --- /dev/null +++ b/src/auto-reply/reply/commands-openclaw-cli.ts @@ -0,0 +1,17 @@ +function quoteShellArg(value: string): string { + if (process.platform === "win32") { + return `'${value.replaceAll("'", "''")}'`; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} + +export function buildCurrentOpenClawCliArgv(args: string[]): string[] { + const entry = process.argv[1]?.trim(); + return entry && entry !== process.execPath + ? [process.execPath, ...process.execArgv, entry, ...args] + : [process.execPath, ...args]; +} + +export function buildCurrentOpenClawCliCommand(args: string[]): string { + return buildCurrentOpenClawCliArgv(args).map(quoteShellArg).join(" "); +} diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index 176294527d4..5dad291a5a4 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -39,6 +39,7 @@ export const handlePluginCommand: CommandHandler = async ( channel: command.channel, channelId: command.channelId, isAuthorizedSender: command.isAuthorizedSender, + senderIsOwner: command.senderIsOwner, gatewayClientScopes: params.ctx.GatewayClientScopes, sessionKey: params.sessionKey, sessionId: targetSessionEntry?.sessionId, diff --git a/src/auto-reply/reply/commands-private-route.ts b/src/auto-reply/reply/commands-private-route.ts new file mode 100644 index 00000000000..24328038971 --- /dev/null +++ b/src/auto-reply/reply/commands-private-route.ts @@ -0,0 +1,114 @@ +import { + getLoadedChannelPlugin, + resolveChannelApprovalAdapter, +} from "../../channels/plugins/index.js"; +import type { ExecApprovalRequest } from "../../infra/exec-approvals.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { OriginatingChannelType } from "../templating.js"; +import type { ReplyPayload } from "../types.js"; +import type { HandleCommandsParams } from "./commands-types.js"; +import { routeReply } from "./route-reply.js"; + +export type PrivateCommandRouteTarget = { + channel: string; + to: string; + accountId?: string | null; + threadId?: string | number | null; +}; + +export async function resolvePrivateCommandRouteTargets(params: { + commandParams: HandleCommandsParams; + request: ExecApprovalRequest; +}): Promise { + const adapter = resolveChannelApprovalAdapter( + getLoadedChannelPlugin(params.commandParams.command.channel), + ); + const native = adapter?.native; + if (!native?.resolveApproverDmTargets) { + return []; + } + const accountId = params.commandParams.ctx.AccountId ?? undefined; + const capabilities = native.describeDeliveryCapabilities({ + cfg: params.commandParams.cfg, + accountId, + approvalKind: "exec", + request: params.request, + }); + if (!capabilities.enabled || !capabilities.supportsApproverDmSurface) { + return []; + } + const targets = await native.resolveApproverDmTargets({ + cfg: params.commandParams.cfg, + accountId, + approvalKind: "exec", + request: params.request, + }); + return dedupePrivateCommandRouteTargets( + targets.map((target) => ({ + channel: params.commandParams.command.channel, + to: target.to, + accountId, + threadId: target.threadId, + })), + ); +} + +export async function deliverPrivateCommandReply(params: { + commandParams: HandleCommandsParams; + targets: PrivateCommandRouteTarget[]; + reply: ReplyPayload; +}): Promise { + const results = await Promise.allSettled( + params.targets.map((target) => + routeReply({ + payload: params.reply, + channel: target.channel as OriginatingChannelType, + to: target.to, + accountId: target.accountId ?? undefined, + threadId: target.threadId ?? undefined, + cfg: params.commandParams.cfg, + sessionKey: params.commandParams.sessionKey, + policyConversationType: "direct", + mirror: false, + isGroup: false, + }), + ), + ); + return results.some((result) => result.status === "fulfilled" && result.value.ok); +} + +export function readCommandMessageThreadId(params: HandleCommandsParams): string | undefined { + return typeof params.ctx.MessageThreadId === "string" || + typeof params.ctx.MessageThreadId === "number" + ? String(params.ctx.MessageThreadId) + : undefined; +} + +export function readCommandDeliveryTarget(params: HandleCommandsParams): string | undefined { + return ( + normalizeOptionalString(params.ctx.OriginatingTo) ?? + normalizeOptionalString(params.command.to) ?? + normalizeOptionalString(params.command.from) + ); +} + +function dedupePrivateCommandRouteTargets( + targets: PrivateCommandRouteTarget[], +): PrivateCommandRouteTarget[] { + const seen = new Set(); + const deduped: PrivateCommandRouteTarget[] = []; + for (const target of targets) { + const key = [ + target.channel, + target.to, + target.accountId ?? "", + target.threadId == null ? "" : String(target.threadId), + ].join("\0"); + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(target); + } + return deduped; +} diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index 793e2c1b15b..2e13db0fed3 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -6,6 +6,8 @@ const mocks = vi.hoisted(() => ({ listChannelPairingRequests: vi.fn(), approveChannelPairingCode: vi.fn(), notifyPairingApproved: vi.fn(), + readConfigFileSnapshotForWrite: vi.fn(), + replaceConfigFile: vi.fn(), normalizeChannelId: vi.fn((raw: string) => { if (!raw) { return null; @@ -28,6 +30,8 @@ const { listChannelPairingRequests, approveChannelPairingCode, notifyPairingApproved, + readConfigFileSnapshotForWrite, + replaceConfigFile, normalizeChannelId, getPairingAdapter, listPairingChannels, @@ -56,6 +60,8 @@ vi.mock("../channels/plugins/index.js", () => ({ vi.mock("../config/config.js", () => ({ getRuntimeConfig: vi.fn().mockReturnValue({}), loadConfig: vi.fn().mockReturnValue({}), + readConfigFileSnapshotForWrite: mocks.readConfigFileSnapshotForWrite, + replaceConfigFile: mocks.replaceConfigFile, })); describe("pairing cli", () => { @@ -73,6 +79,23 @@ describe("pairing cli", () => { }, }); notifyPairingApproved.mockClear(); + readConfigFileSnapshotForWrite.mockClear(); + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + path: "/tmp/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + issues: [], + legacyIssues: [], + sourceConfig: {}, + runtimeConfig: {}, + }, + writeOptions: {}, + }); + replaceConfigFile.mockClear(); + replaceConfigFile.mockResolvedValue(undefined); normalizeChannelId.mockClear(); getPairingAdapter.mockClear(); listPairingChannels.mockClear(); @@ -202,12 +225,44 @@ describe("pairing cli", () => { channel: "telegram", code: "ABCDEFGH", }); + expect(replaceConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + nextConfig: { + commands: { + ownerAllowFrom: ["telegram:123"], + }, + }, + }), + ); expect(log).toHaveBeenCalledWith(expect.stringContaining("Approved")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Command owner configured")); } finally { log.mockRestore(); } }); + it("does not overwrite an existing command owner when approving pairing", async () => { + readConfigFileSnapshotForWrite.mockResolvedValueOnce({ + snapshot: { + path: "/tmp/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + issues: [], + legacyIssues: [], + sourceConfig: { commands: { ownerAllowFrom: ["discord:999"] } }, + runtimeConfig: { commands: { ownerAllowFrom: ["discord:999"] } }, + }, + writeOptions: {}, + }); + mockApprovedPairing(); + + await runPairing(["pairing", "approve", "telegram", "ABCDEFGH"]); + + expect(replaceConfigFile).not.toHaveBeenCalled(); + }); + it("forwards --account for approve", async () => { mockApprovedPairing(); diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index 6b80ed3f480..e7ac3512edb 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -1,7 +1,15 @@ import type { Command } from "commander"; import { normalizeChannelId } from "../channels/plugins/index.js"; import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/pairing.js"; -import { getRuntimeConfig } from "../config/config.js"; +import { + formatCommandOwnerFromChannelSender, + hasConfiguredCommandOwners, +} from "../commands/doctor-command-owner.js"; +import { + getRuntimeConfig, + readConfigFileSnapshotForWrite, + replaceConfigFile, +} from "../config/config.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; import { approveChannelPairingCode, listChannelPairingRequests } from "../pairing/pairing-store.js"; import type { PairingChannel } from "../pairing/pairing-store.types.js"; @@ -42,6 +50,34 @@ async function notifyApproved(channel: PairingChannel, id: string) { await notifyPairingApproved({ channelId: channel, id, cfg }); } +async function maybeBootstrapCommandOwnerFromPairing(params: { + channel: PairingChannel; + id: string; +}): Promise<{ ownerEntry: string | null; bootstrapped: boolean }> { + const ownerEntry = formatCommandOwnerFromChannelSender(params); + if (!ownerEntry) { + return { ownerEntry: null, bootstrapped: false }; + } + + const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); + if (hasConfiguredCommandOwners(snapshot.sourceConfig)) { + return { ownerEntry, bootstrapped: false }; + } + + const nextConfig = structuredClone(snapshot.sourceConfig); + nextConfig.commands = { + ...nextConfig.commands, + ownerAllowFrom: [ownerEntry], + }; + await replaceConfigFile({ + nextConfig, + snapshot, + writeOptions, + afterWrite: { mode: "auto" }, + }); + return { ownerEntry, bootstrapped: true }; +} + export function registerPairingCli(program: Command) { const channels = listPairingChannels(); const pairing = program @@ -155,6 +191,15 @@ export function registerPairingCli(program: Command) { defaultRuntime.log( `${theme.success("Approved")} ${theme.muted(channel)} sender ${theme.command(approved.id)}.`, ); + const ownerBootstrap = await maybeBootstrapCommandOwnerFromPairing({ + channel, + id: approved.id, + }); + if (ownerBootstrap.bootstrapped && ownerBootstrap.ownerEntry) { + defaultRuntime.log( + `${theme.success("Command owner configured")} ${theme.command(ownerBootstrap.ownerEntry)} ${theme.muted("(commands.ownerAllowFrom was empty).")}`, + ); + } if (!opts.notify) { return; diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index f82dde563e3..195fc809206 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ healthCommand: vi.fn(), sessionsCommand: vi.fn(), sessionsCleanupCommand: vi.fn(), + exportTrajectoryCommand: vi.fn(), tasksListCommand: vi.fn(), tasksAuditCommand: vi.fn(), tasksMaintenanceCommand: vi.fn(), @@ -28,6 +29,7 @@ const statusCommand = mocks.statusCommand; const healthCommand = mocks.healthCommand; const sessionsCommand = mocks.sessionsCommand; const sessionsCleanupCommand = mocks.sessionsCleanupCommand; +const exportTrajectoryCommand = mocks.exportTrajectoryCommand; const tasksListCommand = mocks.tasksListCommand; const tasksAuditCommand = mocks.tasksAuditCommand; const tasksMaintenanceCommand = mocks.tasksMaintenanceCommand; @@ -56,6 +58,10 @@ vi.mock("../../commands/sessions-cleanup.js", () => ({ sessionsCleanupCommand: mocks.sessionsCleanupCommand, })); +vi.mock("../../commands/export-trajectory.js", () => ({ + exportTrajectoryCommand: mocks.exportTrajectoryCommand, +})); + vi.mock("../../commands/tasks.js", () => ({ tasksListCommand: mocks.tasksListCommand, tasksAuditCommand: mocks.tasksAuditCommand, @@ -93,6 +99,7 @@ describe("registerStatusHealthSessionsCommands", () => { healthCommand.mockResolvedValue(undefined); sessionsCommand.mockResolvedValue(undefined); sessionsCleanupCommand.mockResolvedValue(undefined); + exportTrajectoryCommand.mockResolvedValue(undefined); tasksListCommand.mockResolvedValue(undefined); tasksAuditCommand.mockResolvedValue(undefined); tasksMaintenanceCommand.mockResolvedValue(undefined); @@ -249,6 +256,51 @@ describe("registerStatusHealthSessionsCommands", () => { ); }); + it("runs sessions export-trajectory with owner-routable export options", async () => { + await runCli([ + "sessions", + "--store", + "/tmp/sessions.json", + "export-trajectory", + "--session-key", + "agent:main:telegram:direct:owner", + "--workspace", + "/workspace", + "--output", + "bug-123", + "--json", + ]); + + expect(exportTrajectoryCommand).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:telegram:direct:owner", + output: "bug-123", + workspace: "/workspace", + store: "/tmp/sessions.json", + json: true, + }), + runtime, + ); + }); + + it("forwards encoded sessions export-trajectory requests", async () => { + await runCli([ + "sessions", + "export-trajectory", + "--request-json-base64", + "eyJzZXNzaW9uS2V5IjoiYWdlbnQ6bWFpbjp0ZWxlZ3JhbTpkaXJlY3Q6b3duZXIifQ", + "--json", + ]); + + expect(exportTrajectoryCommand).toHaveBeenCalledWith( + expect.objectContaining({ + requestJsonBase64: "eyJzZXNzaW9uS2V5IjoiYWdlbnQ6bWFpbjp0ZWxlZ3JhbTpkaXJlY3Q6b3duZXIifQ", + json: true, + }), + runtime, + ); + }); + it("runs tasks list from the parent command", async () => { await runCli(["tasks", "--json", "--runtime", "acp", "--status", "running"]); diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 0ce796dfc8b..cfb1c731e2f 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { exportTrajectoryCommand } from "../../commands/export-trajectory.js"; import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "../../commands/flows.js"; import { healthCommand } from "../../commands/health.js"; import { sessionsCleanupCommand } from "../../commands/sessions-cleanup.js"; @@ -223,6 +224,40 @@ export function registerStatusHealthSessionsCommands(program: Command) { }); }); + sessionsCmd + .command("export-trajectory") + .description("Export a redacted trajectory bundle for a stored session") + .option("--session-key ", "Session key to export") + .option("--output ", "Output directory name inside .openclaw/trajectory-exports") + .option("--workspace ", "Workspace root for the export (default: current directory)") + .option("--store ", "Path to session store (default: resolved from session key)") + .option("--agent ", "Agent id for resolving the default session store") + .option("--request-json-base64 ", "Base64url-encoded export request") + .option("--json", "Output JSON", false) + .action(async (opts, command) => { + const parentOpts = command.parent?.opts() as + | { + store?: string; + agent?: string; + json?: boolean; + } + | undefined; + await runCommandWithRuntime(defaultRuntime, async () => { + await exportTrajectoryCommand( + { + sessionKey: opts.sessionKey as string | undefined, + output: opts.output as string | undefined, + workspace: opts.workspace as string | undefined, + store: (opts.store as string | undefined) ?? parentOpts?.store, + agent: (opts.agent as string | undefined) ?? parentOpts?.agent, + requestJsonBase64: opts.requestJsonBase64 as string | undefined, + json: Boolean(opts.json || parentOpts?.json), + }, + defaultRuntime, + ); + }); + }); + const tasksCmd = program .command("tasks") .description("Inspect durable background tasks and TaskFlow state") diff --git a/src/commands/doctor-command-owner.test.ts b/src/commands/doctor-command-owner.test.ts new file mode 100644 index 00000000000..4e2c5facad8 --- /dev/null +++ b/src/commands/doctor-command-owner.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + formatCommandOwnerFromChannelSender, + hasConfiguredCommandOwners, + noteCommandOwnerHealth, +} from "./doctor-command-owner.js"; + +const note = vi.hoisted(() => vi.fn()); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + +describe("command owner health", () => { + beforeEach(() => { + note.mockClear(); + }); + + it("detects configured command owners", () => { + expect(hasConfiguredCommandOwners({})).toBe(false); + expect(hasConfiguredCommandOwners({ commands: { ownerAllowFrom: [] } })).toBe(false); + expect(hasConfiguredCommandOwners({ commands: { ownerAllowFrom: ["telegram:123"] } })).toBe( + true, + ); + }); + + it("formats pairing senders as channel-scoped command owners", () => { + expect(formatCommandOwnerFromChannelSender({ channel: "telegram", id: "123" })).toBe( + "telegram:123", + ); + expect(formatCommandOwnerFromChannelSender({ channel: "telegram", id: "telegram:123" })).toBe( + "telegram:123", + ); + }); + + it("explains missing command owners in plain language", () => { + noteCommandOwnerHealth({}); + + expect(note).toHaveBeenCalledWith( + expect.stringContaining("No command owner is configured."), + "Command owner", + ); + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("human operator account"); + expect(message).toContain("DM pairing only lets someone talk to the bot"); + expect(message).toContain("commands.ownerAllowFrom"); + }); + + it("does not warn when command owners are configured", () => { + noteCommandOwnerHealth({ commands: { ownerAllowFrom: ["telegram:123"] } }); + + expect(note).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/doctor-command-owner.ts b/src/commands/doctor-command-owner.ts new file mode 100644 index 00000000000..c4c9d832916 --- /dev/null +++ b/src/commands/doctor-command-owner.ts @@ -0,0 +1,51 @@ +import { formatCliCommand } from "../cli/command-format.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PairingChannel } from "../pairing/pairing-store.types.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { note } from "../terminal/note.js"; + +export function resolveConfiguredCommandOwners(cfg: OpenClawConfig): string[] { + const owners = cfg.commands?.ownerAllowFrom; + if (!Array.isArray(owners)) { + return []; + } + return owners.map((entry) => normalizeOptionalString(String(entry ?? "")) ?? "").filter(Boolean); +} + +export function hasConfiguredCommandOwners(cfg: OpenClawConfig): boolean { + return resolveConfiguredCommandOwners(cfg).length > 0; +} + +export function formatCommandOwnerFromChannelSender(params: { + channel: PairingChannel; + id: string; +}): string | null { + const id = normalizeOptionalString(params.id); + if (!id) { + return null; + } + const separatorIndex = id.indexOf(":"); + if (separatorIndex > 0) { + const prefix = id.slice(0, separatorIndex); + if (prefix.toLowerCase() === String(params.channel).toLowerCase()) { + return id; + } + } + return `${params.channel}:${id}`; +} + +export function noteCommandOwnerHealth(cfg: OpenClawConfig): void { + if (hasConfiguredCommandOwners(cfg)) { + return; + } + note( + [ + "No command owner is configured.", + "A command owner is the human operator account allowed to run owner-only commands and approve dangerous actions, including /diagnostics, /export-trajectory, /config, and exec approvals.", + "DM pairing only lets someone talk to the bot; it does not make that sender the owner for privileged commands.", + `Fix: set commands.ownerAllowFrom to your channel user id, for example ${formatCliCommand("openclaw config set commands.ownerAllowFrom '[\"telegram:123456789\"]'")}`, + "Restart the gateway after changing this if it is already running.", + ].join("\n"), + "Command owner", + ); +} diff --git a/src/commands/doctor-plugin-manifests.test.ts b/src/commands/doctor-plugin-manifests.test.ts index b1c29fe852c..b6983f54a49 100644 --- a/src/commands/doctor-plugin-manifests.test.ts +++ b/src/commands/doctor-plugin-manifests.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js"; +import { cleanupTrackedTempDirs } from "../plugins/test-helpers/fs-fixtures.js"; import type { RuntimeEnv } from "../runtime.js"; import { collectLegacyPluginManifestContractMigrations, @@ -12,22 +12,12 @@ import type { DoctorPrompter } from "./doctor-prompter.js"; const tempDirs: string[] = []; -function makeTempDir() { - return makeTrackedTempDir("openclaw-doctor-plugin-manifests", tempDirs); -} - -function makePluginWorkspace() { - const workspaceDir = makeTempDir(); - return { - workspaceDir, - pluginsRoot: path.join(workspaceDir, ".openclaw", "extensions"), - env: { - ...process.env, - OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", - }, - }; +function makeTrustedBundledPluginsDir() { + const fixturesRoot = path.join(process.cwd(), "dist", "extensions"); + fs.mkdirSync(fixturesRoot, { recursive: true }); + const dir = fs.mkdtempSync(path.join(fixturesRoot, "openclaw-doctor-plugin-manifests-")); + tempDirs.push(dir); + return dir; } function configWithPluginLoadPath(pluginRoot: string): OpenClawConfig { @@ -101,7 +91,7 @@ describe("doctor plugin manifest legacy contract repair", () => { }); it("collects legacy top-level capability keys for migration", () => { - const { pluginsRoot } = makePluginWorkspace(); + const pluginsRoot = makeTrustedBundledPluginsDir(); const root = path.join(pluginsRoot, "openai"); fs.mkdirSync(root, { recursive: true }); writePackageJson(root); @@ -127,7 +117,7 @@ describe("doctor plugin manifest legacy contract repair", () => { }); it("rewrites legacy top-level capability keys into contracts", async () => { - const { pluginsRoot } = makePluginWorkspace(); + const pluginsRoot = makeTrustedBundledPluginsDir(); const root = path.join(pluginsRoot, "openai"); fs.mkdirSync(root, { recursive: true }); writePackageJson(root); @@ -168,7 +158,7 @@ describe("doctor plugin manifest legacy contract repair", () => { }); it("ignores non-object contracts payloads when collecting migrations", () => { - const { pluginsRoot } = makePluginWorkspace(); + const pluginsRoot = makeTrustedBundledPluginsDir(); const root = path.join(pluginsRoot, "openai"); fs.mkdirSync(root, { recursive: true }); writePackageJson(root); diff --git a/src/commands/export-trajectory.ts b/src/commands/export-trajectory.ts new file mode 100644 index 00000000000..9cab6b099ac --- /dev/null +++ b/src/commands/export-trajectory.ts @@ -0,0 +1,142 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + resolveDefaultSessionStorePath, + resolveSessionFilePath, + resolveSessionFilePathOptions, +} from "../config/sessions/paths.js"; +import { loadSessionStore } from "../config/sessions/store.js"; +import type { SessionEntry } from "../config/sessions/types.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; +import { + exportTrajectoryForCommand, + formatTrajectoryCommandExportSummary, +} from "../trajectory/command-export.js"; + +export type ExportTrajectoryCommandOptions = { + sessionKey?: string; + output?: string; + store?: string; + agent?: string; + workspace?: string; + json?: boolean; + requestJsonBase64?: string; +}; + +type EncodedExportTrajectoryRequest = { + sessionKey?: unknown; + output?: unknown; + store?: unknown; + agent?: unknown; + workspace?: unknown; +}; + +const ENCODED_EXPORT_REQUEST_RE = /^[A-Za-z0-9_-]{1,65536}$/u; + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function decodeExportTrajectoryRequest(encoded: string): Partial { + const trimmed = encoded.trim(); + if (!ENCODED_EXPORT_REQUEST_RE.test(trimmed)) { + throw new Error("Encoded trajectory export request is invalid"); + } + const decoded = JSON.parse(Buffer.from(trimmed, "base64url").toString("utf8")) as unknown; + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) { + throw new Error("Encoded trajectory export request must be a JSON object"); + } + const request = decoded as EncodedExportTrajectoryRequest; + return { + sessionKey: readOptionalString(request.sessionKey) ?? "", + output: readOptionalString(request.output), + store: readOptionalString(request.store), + agent: readOptionalString(request.agent), + workspace: readOptionalString(request.workspace), + }; +} + +function resolveExportTrajectoryOptions( + opts: ExportTrajectoryCommandOptions, +): ExportTrajectoryCommandOptions { + const encoded = opts.requestJsonBase64?.trim(); + if (!encoded) { + return opts; + } + return { + ...opts, + ...decodeExportTrajectoryRequest(encoded), + }; +} + +export async function exportTrajectoryCommand( + opts: ExportTrajectoryCommandOptions, + runtime: RuntimeEnv, +): Promise { + let resolvedOpts: ExportTrajectoryCommandOptions; + try { + resolvedOpts = resolveExportTrajectoryOptions(opts); + } catch (error) { + runtime.error(`Failed to decode trajectory export request: ${formatErrorMessage(error)}`); + runtime.exit(1); + return; + } + const sessionKey = resolvedOpts.sessionKey?.trim(); + if (!sessionKey) { + runtime.error("--session-key is required"); + runtime.exit(1); + return; + } + const targetAgentId = resolvedOpts.agent ?? resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolvedOpts.store + ? path.resolve(resolvedOpts.store) + : resolveDefaultSessionStorePath(targetAgentId); + const store = loadSessionStore(storePath, { skipCache: true }); + const entry = store[sessionKey] as SessionEntry | undefined; + if (!entry?.sessionId) { + runtime.error(`Session not found: ${sessionKey}`); + runtime.exit(1); + return; + } + + let sessionFile: string; + try { + sessionFile = resolveSessionFilePath( + entry.sessionId, + entry, + resolveSessionFilePathOptions({ agentId: targetAgentId, storePath }), + ); + } catch (error) { + runtime.error(`Failed to resolve session file: ${formatErrorMessage(error)}`); + runtime.exit(1); + return; + } + if (!fs.existsSync(sessionFile)) { + runtime.error("Session file not found."); + runtime.exit(1); + return; + } + + let summary: ReturnType; + try { + summary = exportTrajectoryForCommand({ + outputPath: resolvedOpts.output, + sessionFile, + sessionId: entry.sessionId, + sessionKey, + workspaceDir: path.resolve(resolvedOpts.workspace ?? process.cwd()), + }); + } catch (error) { + runtime.error(`Failed to export trajectory: ${formatErrorMessage(error)}`); + runtime.exit(1); + return; + } + + if (resolvedOpts.json) { + writeRuntimeJson(runtime, summary); + return; + } + runtime.log(formatTrajectoryCommandExportSummary(summary)); +} diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index a81015ec912..bf480246f1e 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -20,4 +20,11 @@ describe("doctor health contributions", () => { expect(ids.indexOf("doctor:plugin-registry")).toBeGreaterThan(-1); expect(ids.indexOf("doctor:plugin-registry")).toBeLessThan(ids.indexOf("doctor:write-config")); }); + + it("checks command owner configuration before final config writes", () => { + const ids = resolveDoctorHealthContributions().map((entry) => entry.id); + + expect(ids.indexOf("doctor:command-owner")).toBeGreaterThan(-1); + expect(ids.indexOf("doctor:command-owner")).toBeLessThan(ids.indexOf("doctor:write-config")); + }); }); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 448a3c7baff..0018fdf28be 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -184,6 +184,11 @@ async function runGatewayAuthHealth(ctx: DoctorHealthFlowContext): Promise note("Gateway token configured.", "Gateway auth"); } +async function runCommandOwnerHealth(ctx: DoctorHealthFlowContext): Promise { + const { noteCommandOwnerHealth } = await import("../commands/doctor-command-owner.js"); + noteCommandOwnerHealth(ctx.cfg); +} + async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise { const { noteClaudeCliHealth } = await import("../commands/doctor-claude-cli.js"); noteClaudeCliHealth(ctx.cfg); @@ -562,6 +567,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { label: "Gateway auth", run: runGatewayAuthHealth, }), + createDoctorHealthContribution({ + id: "doctor:command-owner", + label: "Command owner", + run: runCommandOwnerHealth, + }), createDoctorHealthContribution({ id: "doctor:legacy-state", label: "Legacy state", diff --git a/src/gateway/gateway-trajectory-export.live.test.ts b/src/gateway/gateway-trajectory-export.live.test.ts index 1e41d5216e6..1d85576283d 100644 --- a/src/gateway/gateway-trajectory-export.live.test.ts +++ b/src/gateway/gateway-trajectory-export.live.test.ts @@ -138,6 +138,29 @@ async function waitForPath(filePath: string, timeoutMs = 60_000): Promise throw new Error(`timed out waiting for ${filePath}`); } +async function approveTrajectoryExport(client: GatewayClient): Promise { + const approvals = (await client.request( + "exec.approval.list", + {}, + { timeoutMs: 10_000 }, + )) as Array<{ + id?: string; + request?: { + command?: string; + }; + }>; + const approval = approvals.find((entry) => + entry.request?.command?.includes("sessions export-trajectory"), + ); + expect(approval?.id).toBeTruthy(); + await client.request( + "exec.approval.resolve", + { id: approval!.id, decision: "allow-once" }, + { timeoutMs: 10_000 }, + ); + return approval!.id!; +} + describeLive("gateway live trajectory export", () => { let cleanup: Array<() => Promise> = []; @@ -244,14 +267,18 @@ describeLive("gateway live trajectory export", () => { exportResponse?.status === "ok" || exportResponse?.status === "started", ).toBe(true); - await waitForPath(path.join(bundleDir, "events.jsonl"), 60_000); const finalText = typeof exportResponse?.message === "object" ? extractFirstTextBlock(exportResponse.message) : undefined; + expect(finalText).toContain("Trajectory exports can include"); + expect(finalText).toContain("through exec approval"); + const approvalId = await approveTrajectoryExport(client); + logLiveStep("export:approved", { approvalId }); + await waitForPath(path.join(bundleDir, "events.jsonl"), 60_000); logLiveStep("export:done", { finalText }); if (finalText) { - expect(finalText).toContain("Trajectory exported!"); + expect(finalText).toContain("Approve once"); } expect(await listDirectoryNames(bundleDir)).toEqual( expect.arrayContaining([ diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index b94d674aac9..ba92a0b361d 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -132,6 +132,7 @@ export const ExecApprovalRequestParamsSchema = Type.Object( host: Type.Optional(Type.Union([Type.String(), Type.Null()])), 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()])), 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/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index a2fea23c6ff..6ea70fb61bd 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -1,6 +1,7 @@ import { resolveExecApprovalCommandDisplay, sanitizeExecApprovalDisplayText, + sanitizeExecApprovalWarningText, } from "../../infra/exec-approval-command-display.js"; import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js"; import { @@ -131,6 +132,7 @@ export function createExecApprovalHandlers( host?: string; security?: string; ask?: string; + warningText?: string | null; agentId?: string; resolvedPath?: string; sessionKey?: string; @@ -204,6 +206,7 @@ export function createExecApprovalHandlers( return; } const envBinding = buildSystemRunApprovalEnvBinding(p.env); + const warningText = normalizeOptionalString(p.warningText); const systemRunBinding = host === "node" ? buildSystemRunApprovalBinding({ @@ -237,6 +240,7 @@ export function createExecApprovalHandlers( host: host || null, security: p.security ?? null, ask: p.ask ?? null, + warningText: warningText ? sanitizeExecApprovalWarningText(warningText) : null, 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 69c58c9699d..2df7400b251 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -1307,6 +1307,26 @@ describe("exec approval handlers", () => { ); }); + it("preserves approval warning line breaks while sanitizing hidden characters", async () => { + const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + timeoutMs: 10, + warningText: "Diagnostics line one\r\n\r\nOpenAI Codex harness:\nSend feedback\u200B", + }, + }); + const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); + expect(requested).toBeTruthy(); + const request = (requested?.payload as { request?: Record })?.request ?? {}; + expect(request["warningText"]).toBe( + "Diagnostics line one\n\nOpenAI Codex harness:\nSend feedback\\u{200B}", + ); + expect(request["warningText"]).not.toContain("\\u{A}"); + }); + it("accepts resolve during broadcast", async () => { const manager = new ExecApprovalManager(); const handlers = createExecApprovalHandlers(manager); diff --git a/src/infra/approval-view-model.ts b/src/infra/approval-view-model.ts index 557de7393d3..f6fd65bac7d 100644 --- a/src/infra/approval-view-model.ts +++ b/src/infra/approval-view-model.ts @@ -68,6 +68,7 @@ function buildExecViewBase( metadata: buildExecMetadata(request), ask: request.request.ask ?? null, agentId: request.request.agentId ?? null, + warningText: request.request.warningText ?? null, commandText, commandPreview, cwd: request.request.cwd ?? null, diff --git a/src/infra/approval-view-model.types.ts b/src/infra/approval-view-model.types.ts index 7becbfc00ec..746f5b5ce97 100644 --- a/src/infra/approval-view-model.types.ts +++ b/src/infra/approval-view-model.types.ts @@ -34,6 +34,7 @@ export type ExecApprovalViewBase = ApprovalViewBase & { approvalKind: "exec"; ask?: string | null; agentId?: string | null; + warningText?: string | null; commandText: string; commandPreview?: string | null; cwd?: string | null; diff --git a/src/infra/exec-approval-command-display.test.ts b/src/infra/exec-approval-command-display.test.ts index b3b328ca6dc..c4c927c2937 100644 --- a/src/infra/exec-approval-command-display.test.ts +++ b/src/infra/exec-approval-command-display.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveExecApprovalCommandDisplay, sanitizeExecApprovalDisplayText, + sanitizeExecApprovalWarningText, } from "./exec-approval-command-display.js"; describe("sanitizeExecApprovalDisplayText", () => { @@ -157,6 +158,32 @@ describe("sanitizeExecApprovalDisplayText", () => { }); }); +describe("sanitizeExecApprovalWarningText", () => { + it("keeps approval warning prose line breaks readable", () => { + const warning = + "Diagnostics can include sensitive local logs.\n\nOpenAI Codex harness:\nApproving diagnostics will also send Codex feedback."; + + expect(sanitizeExecApprovalWarningText(warning)).toBe(warning); + }); + + it("normalizes escaped line separators while still escaping hidden spoofing characters", () => { + const warning = "Line one\r\nLine two\u2028Line three\u200B"; + + expect(sanitizeExecApprovalWarningText(warning)).toBe( + "Line one\nLine two\nLine three\\u{200B}", + ); + }); + + it("redacts secrets in warning prose without escaping newlines", () => { + const warning = "Token:\nsk-abc123456789012345678"; + const result = sanitizeExecApprovalWarningText(warning); + + expect(result).toContain("Token:\n"); + expect(result).not.toContain("sk-abc123456789012345678"); + expect(result).not.toContain("\\u{A}"); + }); +}); + describe("resolveExecApprovalCommandDisplay", () => { it.each([ { diff --git a/src/infra/exec-approval-command-display.ts b/src/infra/exec-approval-command-display.ts index ddee6e278b2..87e437330d7 100644 --- a/src/infra/exec-approval-command-display.ts +++ b/src/infra/exec-approval-command-display.ts @@ -19,6 +19,8 @@ const EXEC_APPROVAL_MAX_OUTPUT = 16 * 1024; const EXEC_APPROVAL_TRUNCATION_MARKER = "…[truncated]"; const EXEC_APPROVAL_OVERSIZED_MARKER = "[exec approval command exceeds display size limit; full text suppressed]"; +const EXEC_APPROVAL_WARNING_OVERSIZED_MARKER = + "[exec approval warning exceeds display size limit; full text suppressed]"; const BYPASS_MASK = "***"; @@ -26,8 +28,14 @@ function formatCodePointEscape(char: string): string { return `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`; } -function escapeInvisibles(text: string): string { - return text.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, formatCodePointEscape); +function normalizeDisplayLineBreaks(text: string): string { + return text.replace(/\r\n?/g, "\n").replace(/[\u2028\u2029]/g, "\n"); +} + +function escapeInvisibles(text: string, options?: { preserveLineBreaks?: boolean }): string { + return text.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, (char) => + options?.preserveLineBreaks && char === "\n" ? "\n" : formatCodePointEscape(char), + ); } function truncateForDisplay(text: string): string { @@ -81,11 +89,14 @@ function buildStrippedView(original: string): { stripped: string; strippedToOrig return { stripped: strippedChars.join(""), strippedToOrig }; } -export function sanitizeExecApprovalDisplayText(commandText: string): string { +function sanitizeExecApprovalDisplayTextInternal( + commandText: string, + options?: { preserveLineBreaks?: boolean; oversizedMarker?: string }, +): string { if (commandText.length > EXEC_APPROVAL_MAX_INPUT) { // Refuse to display inputs above the hard cap; anything larger must be approved through // another channel. Running redaction on a multi-megabyte payload would be a DoS vector. - return EXEC_APPROVAL_OVERSIZED_MARKER; + return options?.oversizedMarker ?? EXEC_APPROVAL_OVERSIZED_MARKER; } const rawRedacted = redactSensitiveText(commandText, { mode: "tools" }); const { stripped, strippedToOrig } = buildStrippedView(commandText); @@ -94,7 +105,7 @@ export function sanitizeExecApprovalDisplayText(commandText: string): string { // raw-view redaction is sufficient. Preserve structure and show invisible-character spoof // attempts as `\u{...}` escapes. if (strippedRedacted === stripped) { - return truncateForDisplay(escapeInvisibles(rawRedacted)); + return truncateForDisplay(escapeInvisibles(rawRedacted, options)); } // Detect bypass by position-bitmap coverage. Run each redaction pattern independently on // both views and map stripped-view match positions back to original coordinates. If every @@ -115,7 +126,7 @@ export function sanitizeExecApprovalDisplayText(commandText: string): string { } } if (!bypassDetected) { - return truncateForDisplay(escapeInvisibles(rawRedacted)); + return truncateForDisplay(escapeInvisibles(rawRedacted, options)); } // Bypass path. Project the stripped-view mask back onto original positions, union with the // raw-view mask, and emit a rendering where each contiguous masked run becomes a single @@ -144,12 +155,28 @@ export function sanitizeExecApprovalDisplayText(commandText: string): string { } const codePoint = commandText.codePointAt(i) ?? 0xfffd; const cp = String.fromCodePoint(codePoint); - out += EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp) ? formatCodePointEscape(cp) : cp; + out += + options?.preserveLineBreaks && cp === "\n" + ? cp + : EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp) + ? formatCodePointEscape(cp) + : cp; i += cp.length; } return truncateForDisplay(out); } +export function sanitizeExecApprovalDisplayText(commandText: string): string { + return sanitizeExecApprovalDisplayTextInternal(commandText); +} + +export function sanitizeExecApprovalWarningText(warningText: string): string { + return sanitizeExecApprovalDisplayTextInternal(normalizeDisplayLineBreaks(warningText), { + preserveLineBreaks: true, + oversizedMarker: EXEC_APPROVAL_WARNING_OVERSIZED_MARKER, + }); +} + function normalizePreview(commandText: string, commandPreview?: string | null): string | null { const previewRaw = commandPreview?.trim() ?? ""; if (!previewRaw) { diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index f3b45d36741..a0e41da28a0 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -232,6 +232,10 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(request.request); const decisionText = allowedDecisions.join("|"); const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`]; + const warningText = request.request.warningText?.trim(); + if (warningText) { + lines.push("", warningText); + } const command = formatApprovalCommand( resolveExecApprovalCommandDisplay(request.request).commandText, ); diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 30fc5a8e78d..2de6bbc741a 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -94,6 +94,7 @@ export type ExecApprovalRequestPayload = { host?: string | null; security?: string | null; ask?: string | null; + warningText?: string | null; allowedDecisions?: readonly ExecApprovalDecision[]; agentId?: string | null; resolvedPath?: string | null; diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index fd497c53d45..8f1a7034770 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -1,8 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { setBundledPluginsDirOverrideForTest } from "../plugins/bundled-dir.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { clearBundledRuntimeDependencyNodePaths, resolveBundledRuntimeDependencyInstallRoot, @@ -22,11 +21,12 @@ const { createTempDirSync } = createPluginSdkTestHarness(); const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; const originalPluginStageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR; -const trustedBundledFixturesRoot = path.resolve("dist-runtime", "extensions"); const FACADE_LOADER_GLOBAL = "__openclawTestLoadBundledPluginPublicSurfaceModuleSync"; const STAGED_RUNTIME_DEP_NAME = "openclaw-facade-loader-runtime-dep"; type FacadeLoaderJitiFactory = NonNullable[0]>; -const trustedBundledFixtureDirs: string[] = []; +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const trustedBundledPluginFixtureRoots: string[] = []; +let trustedPluginIdCounter = 0; function forceNodeRuntimeVersionsForTest(): () => void { const originalVersions = process.versions; @@ -46,82 +46,99 @@ function forceNodeRuntimeVersionsForTest(): () => void { }; } -function createTrustedBundledFixtureRoot(prefix: string): string { - fs.mkdirSync(trustedBundledFixturesRoot, { recursive: true }); - const rootDir = fs.mkdtempSync(path.join(trustedBundledFixturesRoot, `.${prefix}`)); - trustedBundledFixtureDirs.push(rootDir); +type TrustedBundledPluginFixture = { + bundledPluginsDir: string; + pluginId: string; + pluginRoot: string; +}; + +function nextTrustedPluginId(prefix: string): string { + return `${prefix}${trustedPluginIdCounter++}`; +} + +function createTrustedBundledPluginsRoot(kind: "dist" | "dist-runtime" = "dist"): string { + const rootDir = path.join(packageRoot, kind, "extensions"); + fs.mkdirSync(rootDir, { recursive: true }); return rootDir; } -function writePluginPackageJson(pluginDir: string, name = "demo"): void { - writeJsonFile(path.join(pluginDir, "package.json"), { - name: `@openclaw/plugin-${name}`, +function writeFixturePackageJson(pluginRoot: string, pluginId: string): void { + writeJsonFile(path.join(pluginRoot, "package.json"), { + name: `@openclaw/${pluginId}`, version: "0.0.0", type: "module", }); } -function createBundledPluginDir(prefix: string, marker: string): string { - const rootDir = createTrustedBundledFixtureRoot(prefix); - const pluginDir = path.join(rootDir, "demo"); - fs.mkdirSync(pluginDir, { recursive: true }); - writePluginPackageJson(pluginDir); +function createBundledPluginFixture(params: { + prefix: string; + marker: string; + kind?: "dist" | "dist-runtime"; + pluginId?: string; +}): TrustedBundledPluginFixture { + const bundledPluginsDir = createTrustedBundledPluginsRoot(params.kind); + const pluginId = params.pluginId ?? nextTrustedPluginId(params.prefix); + const pluginRoot = path.join(bundledPluginsDir, pluginId); + fs.mkdirSync(pluginRoot, { recursive: true }); + trustedBundledPluginFixtureRoots.push(pluginRoot); + writeFixturePackageJson(pluginRoot, pluginId); fs.writeFileSync( - path.join(pluginDir, "api.js"), - `export const marker = ${JSON.stringify(marker)};\n`, + path.join(pluginRoot, "api.js"), + `export const marker = ${JSON.stringify(params.marker)};\n`, "utf8", ); - return rootDir; + return { bundledPluginsDir, pluginId, pluginRoot }; } -function useBundledPluginDirOverrideForTest(dir: string): void { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; - setBundledPluginsDirOverrideForTest(dir); -} - -function createThrowingPluginDir(prefix: string): string { - const rootDir = createTrustedBundledFixtureRoot(prefix); - const pluginDir = path.join(rootDir, "bad"); - fs.mkdirSync(pluginDir, { recursive: true }); - writePluginPackageJson(pluginDir, "bad"); +function createThrowingPluginFixture(prefix: string): TrustedBundledPluginFixture { + const bundledPluginsDir = createTrustedBundledPluginsRoot(); + const pluginId = nextTrustedPluginId(prefix); + const pluginRoot = path.join(bundledPluginsDir, pluginId); + fs.mkdirSync(pluginRoot, { recursive: true }); + trustedBundledPluginFixtureRoots.push(pluginRoot); + writeFixturePackageJson(pluginRoot, pluginId); fs.writeFileSync( - path.join(pluginDir, "api.js"), - `throw new Error("plugin load failure");\n`, + path.join(pluginRoot, "api.js"), + 'throw new Error("plugin load failure");\n', "utf8", ); - return rootDir; + return { bundledPluginsDir, pluginId, pluginRoot }; } -function createCircularPluginDir(prefix: string): string { - const rootDir = createTrustedBundledFixtureRoot(prefix); - const pluginDir = path.join(rootDir, "demo"); - fs.mkdirSync(pluginDir, { recursive: true }); - writePluginPackageJson(pluginDir); +function createCircularPluginFixture(prefix: string): TrustedBundledPluginFixture { + const bundledPluginsDir = createTrustedBundledPluginsRoot(); + const pluginId = nextTrustedPluginId(prefix); + const pluginRoot = path.join(bundledPluginsDir, pluginId); + fs.mkdirSync(pluginRoot, { recursive: true }); + trustedBundledPluginFixtureRoots.push(pluginRoot); + writeFixturePackageJson(pluginRoot, pluginId); fs.writeFileSync( - path.join(rootDir, "facade.mjs"), + path.join(pluginRoot, "facade.mjs"), [ `const loadBundledPluginPublicSurfaceModuleSync = globalThis.${FACADE_LOADER_GLOBAL};`, `if (typeof loadBundledPluginPublicSurfaceModuleSync !== "function") {`, ' throw new Error("missing facade loader test loader");', "}", - `export const marker = loadBundledPluginPublicSurfaceModuleSync({ dirName: "demo", artifactBasename: "api.js" }).marker;`, + `export const marker = loadBundledPluginPublicSurfaceModuleSync({ dirName: ${JSON.stringify( + pluginId, + )}, artifactBasename: "api.js" }).marker;`, "", ].join("\n"), "utf8", ); fs.writeFileSync( - path.join(pluginDir, "helper.js"), + path.join(pluginRoot, "helper.js"), ['import { marker } from "../facade.mjs";', "export const circularMarker = marker;", ""].join( "\n", ), "utf8", ); fs.writeFileSync( - path.join(pluginDir, "api.js"), + path.join(pluginRoot, "api.js"), ['import "./helper.js";', 'export const marker = "circular-ok";', ""].join("\n"), "utf8", ); - return rootDir; + return { bundledPluginsDir, pluginId, pluginRoot }; } function writeJsonFile(filePath: string, value: unknown): void { @@ -129,6 +146,22 @@ function writeJsonFile(filePath: string, value: unknown): void { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } +function writeStagedRuntimeDepPackage(params: { + installRoot: string; + name: string; + version: string; + source?: string; +}): void { + const depRoot = path.join(params.installRoot, "node_modules", params.name); + writeJsonFile(path.join(depRoot, "package.json"), { + name: params.name, + version: params.version, + type: "module", + exports: "./index.js", + }); + fs.writeFileSync(path.join(depRoot, "index.js"), params.source ?? "export {};\n", "utf8"); +} + function createPackagedBundledPluginDirWithStagedRuntimeDep(params: { marker: string; prefix: string; @@ -137,20 +170,21 @@ function createPackagedBundledPluginDirWithStagedRuntimeDep(params: { env: NodeJS.ProcessEnv; installRoot: string; modulePath: string; - packageRoot: string; + pluginId: string; pluginRoot: string; stageRoot: string; } { - const packageRoot = path.resolve("."); - const pluginRoot = path.join(trustedBundledFixturesRoot, "demo"); + const bundledPluginsDir = createTrustedBundledPluginsRoot(); + const pluginId = nextTrustedPluginId(params.prefix); + const pluginRoot = path.join(bundledPluginsDir, pluginId); const stageRoot = createTempDirSync(`${params.prefix}stage-`); const env = { ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: trustedBundledFixturesRoot, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir, OPENCLAW_PLUGIN_STAGE_DIR: stageRoot, }; - trustedBundledFixtureDirs.push(pluginRoot); fs.mkdirSync(pluginRoot, { recursive: true }); + trustedBundledPluginFixtureRoots.push(pluginRoot); writeJsonFile(path.join(pluginRoot, "package.json"), { name: "@openclaw/plugin-demo", @@ -175,45 +209,33 @@ function createPackagedBundledPluginDirWithStagedRuntimeDep(params: { const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env, }); - const depRoot = path.join(installRoot, "node_modules", STAGED_RUNTIME_DEP_NAME); - writeJsonFile(path.join(depRoot, "package.json"), { + writeStagedRuntimeDepPackage({ + installRoot, name: STAGED_RUNTIME_DEP_NAME, version: "1.0.0", - type: "module", - exports: "./index.js", + source: `export const marker = ${JSON.stringify(params.marker)};\n`, }); - fs.writeFileSync( - path.join(depRoot, "index.js"), - `export const marker = ${JSON.stringify(params.marker)};\n`, - "utf8", - ); + writeStagedRuntimeDepPackage({ installRoot, name: "semver", version: "7.7.4" }); + writeStagedRuntimeDepPackage({ installRoot, name: "tslog", version: "4.10.2" }); return { - bundledPluginsDir: trustedBundledFixturesRoot, + bundledPluginsDir, env, installRoot, modulePath, - packageRoot, + pluginId, pluginRoot, stageRoot, }; } - -beforeEach(() => { - delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; - delete process.env.OPENCLAW_PLUGIN_STAGE_DIR; -}); - afterEach(() => { vi.restoreAllMocks(); - for (const dir of trustedBundledFixtureDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } resetFacadeLoaderStateForTest(); setFacadeLoaderJitiFactoryForTest(undefined); - setBundledPluginsDirOverrideForTest(undefined); clearBundledRuntimeDependencyNodePaths(); + for (const dir of trustedBundledPluginFixtureRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } delete (globalThis as typeof globalThis & Record)[FACADE_LOADER_GLOBAL]; if (originalBundledPluginsDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; @@ -233,37 +255,47 @@ afterEach(() => { }); describe("plugin-sdk facade loader", () => { - it("honors trusted bundled plugin dir overrides", () => { - const overrideA = createBundledPluginDir("openclaw-facade-loader-a-", "override-a"); - const overrideB = createBundledPluginDir("openclaw-facade-loader-b-", "override-b"); + it("honors trusted bundled plugin dir overrides under the package root", () => { + const pluginId = nextTrustedPluginId("openclaw-facade-loader-override-"); + const overrideA = createBundledPluginFixture({ + pluginId, + kind: "dist", + prefix: "openclaw-facade-loader-a-", + marker: "override-a", + }); + const overrideB = createBundledPluginFixture({ + pluginId, + kind: "dist-runtime", + prefix: "openclaw-facade-loader-b-", + marker: "override-b", + }); - useBundledPluginDirOverrideForTest(overrideA); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = overrideA.bundledPluginsDir; const fromA = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ - dirName: "demo", + dirName: pluginId, artifactBasename: "api.js", }); expect(fromA.marker).toBe("override-a"); - useBundledPluginDirOverrideForTest(overrideB); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = overrideB.bundledPluginsDir; const fromB = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ - dirName: "demo", + dirName: pluginId, artifactBasename: "api.js", }); expect(fromB.marker).toBe("override-b"); }); it("falls back to package source surfaces when an override dir lacks a bundled plugin", () => { - const overrideDir = createTrustedBundledFixtureRoot("openclaw-facade-loader-empty-"); - useBundledPluginDirOverrideForTest(overrideDir); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = createTempDirSync("openclaw-facade-loader-empty-"); const loaded = loadBundledPluginPublicSurfaceModuleSync<{ - definePluginEntry: unknown; + emptyPluginConfigSchema: unknown; }>({ - dirName: "thread-ownership", + dirName: "diagnostics-prometheus", artifactBasename: "api.js", }); - expect(loaded.definePluginEntry).toEqual(expect.any(Function)); + expect(loaded.emptyPluginConfigSchema).toEqual(expect.any(Function)); }); it("keeps bundled facade loads disabled when bundled plugins are disabled", () => { @@ -279,37 +311,33 @@ describe("plugin-sdk facade loader", () => { }); it("shares loaded facade ids with facade-runtime", () => { - const dir = createBundledPluginDir("openclaw-facade-loader-ids-", "identity-check"); - useBundledPluginDirOverrideForTest(dir); + const fixture = createBundledPluginFixture({ + prefix: "openclaw-facade-loader-ids-", + marker: "identity-check", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; const first = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ - dirName: "demo", + dirName: fixture.pluginId, artifactBasename: "api.js", }); const second = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ - dirName: "demo", + dirName: fixture.pluginId, artifactBasename: "api.js", }); expect(first).toBe(second); expect(first.marker).toBe("identity-check"); - expect(listImportedBundledPluginFacadeIds()).toEqual(["demo"]); - expect(listImportedFacadeRuntimeIds()).toEqual(["demo"]); + expect(listImportedBundledPluginFacadeIds()).toEqual([fixture.pluginId]); + expect(listImportedFacadeRuntimeIds()).toEqual([fixture.pluginId]); }); it("uses the runtime-supported Jiti boundary for Windows dist facade loads", () => { - const bundledPluginsDir = createTrustedBundledFixtureRoot( - "openclaw-facade-loader-windows-dist-", - ); - const pluginDir = path.join(bundledPluginsDir, "demo"); - fs.mkdirSync(pluginDir, { recursive: true }); - writePluginPackageJson(pluginDir); - fs.writeFileSync( - path.join(pluginDir, "api.js"), - 'export const marker = "windows-dist-ok";\n', - "utf8", - ); - useBundledPluginDirOverrideForTest(bundledPluginsDir); + const fixture = createBundledPluginFixture({ + prefix: "openclaw-facade-loader-windows-", + marker: "windows-dist-ok", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; const createJitiCalls: Parameters[] = []; setFacadeLoaderJitiFactoryForTest(((...args) => { @@ -324,7 +352,7 @@ describe("plugin-sdk facade loader", () => { try { expect( loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ - dirName: "demo", + dirName: fixture.pluginId, artifactBasename: "api.js", }).marker, ).toBe("windows-dist-ok"); @@ -346,7 +374,7 @@ describe("plugin-sdk facade loader", () => { marker: "staged", prefix: "openclaw-facade-loader-runtime-deps-", }); - useBundledPluginDirOverrideForTest(fixture.bundledPluginsDir); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; process.env.OPENCLAW_PLUGIN_STAGE_DIR = fixture.stageRoot; await expect(import(pathToFileURL(fixture.modulePath).href)).rejects.toMatchObject({ @@ -357,7 +385,7 @@ describe("plugin-sdk facade loader", () => { marker: string; moduleUrl: string; }>({ - dirName: "demo", + dirName: fixture.pluginId, artifactBasename: "api.js", }); @@ -365,43 +393,42 @@ describe("plugin-sdk facade loader", () => { expect(fs.existsSync(path.join(fixture.pluginRoot, "node_modules"))).toBe(false); expect(fs.realpathSync(fileURLToPath(loaded.moduleUrl))).toBe( fs.realpathSync( - path.join(fixture.installRoot, "dist-runtime", "extensions", "demo", "api.js"), + path.join(fixture.installRoot, "dist", "extensions", fixture.pluginId, "api.js"), ), ); }); it("loads built bundled async public surfaces through staged runtime deps", async () => { const fixture = createPackagedBundledPluginDirWithStagedRuntimeDep({ - marker: "staged", + marker: "async-staged", prefix: "openclaw-facade-loader-built-async-", }); - setBundledPluginsDirOverrideForTest(fixture.bundledPluginsDir); const loaded = await loadBundledPluginPublicSurfaceModule<{ marker: string; moduleUrl: string; }>({ - dirName: "demo", + dirName: fixture.pluginId, artifactBasename: "api.js", env: fixture.env, }); - expect(loaded.marker).toBe("facade:staged"); + expect(loaded.marker).toBe("facade:async-staged"); expect(fs.realpathSync(fileURLToPath(loaded.moduleUrl))).toBe( fs.realpathSync( - path.join(fixture.installRoot, "dist-runtime", "extensions", "demo", "api.js"), + path.join(fixture.installRoot, "dist", "extensions", fixture.pluginId, "api.js"), ), ); }); it("breaks circular facade re-entry during module evaluation", () => { - const dir = createCircularPluginDir("openclaw-facade-loader-circular-"); - useBundledPluginDirOverrideForTest(dir); + const fixture = createCircularPluginFixture("openclaw-facade-loader-circular-"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; (globalThis as typeof globalThis & Record)[FACADE_LOADER_GLOBAL] = loadBundledPluginPublicSurfaceModuleSync; const loaded = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ - dirName: "demo", + dirName: fixture.pluginId, artifactBasename: "api.js", }); @@ -409,12 +436,12 @@ describe("plugin-sdk facade loader", () => { }); it("clears the cache on load failure so retries re-execute", () => { - const dir = createThrowingPluginDir("openclaw-facade-loader-throw-"); - useBundledPluginDirOverrideForTest(dir); + const fixture = createThrowingPluginFixture("openclaw-facade-loader-throw-"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; expect(() => loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ - dirName: "bad", + dirName: fixture.pluginId, artifactBasename: "api.js", }), ).toThrow("plugin load failure"); @@ -423,7 +450,7 @@ describe("plugin-sdk facade loader", () => { expect(() => loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ - dirName: "bad", + dirName: fixture.pluginId, artifactBasename: "api.js", }), ).toThrow("plugin load failure"); diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts index cd17250109a..9231a6791f6 100644 --- a/src/plugins/command-registration.ts +++ b/src/plugins/command-registration.ts @@ -28,6 +28,8 @@ function getReservedCommands(): Set { "help", "commands", "status", + "diagnostics", + "codex", "whoami", "context", "btw", @@ -114,6 +116,14 @@ export function validatePluginCommandDefinition( if (!command.description.trim()) { return "Command description cannot be empty"; } + if (command.ownership === "reserved") { + if (!opts?.allowReservedCommandNames) { + return "Reserved command ownership is only available to bundled reserved commands"; + } + if (!isReservedCommandName(command.name)) { + return `Reserved command ownership requires a reserved command name: ${normalizeOptionalLowercaseString(command.name) ?? ""}`; + } + } if (command.agentPromptGuidance !== undefined && !Array.isArray(command.agentPromptGuidance)) { return "Agent prompt guidance must be an array of strings"; } @@ -191,6 +201,12 @@ export function registerPluginCommand( if (isPluginCommandRegistryLocked()) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } + if (command.ownership === "reserved") { + return { + ok: false, + error: "Reserved command ownership is only available to bundled reserved commands", + }; + } const definitionError = validatePluginCommandDefinition(command, opts); if (definitionError) { @@ -198,6 +214,7 @@ export function registerPluginCommand( } const name = command.name.trim(); + const normalizedName = normalizeLowercaseStringOrEmpty(name); const description = command.description.trim(); const normalizedCommand = { ...command, @@ -208,7 +225,7 @@ export function registerPluginCommand( : {}), }; const invocationKeys = listPluginInvocationKeys(normalizedCommand); - const key = `/${normalizeLowercaseStringOrEmpty(name)}`; + const key = `/${normalizedName}`; // Check for duplicate registration for (const invocationKey of invocationKeys) { diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index 99ab301a134..60287bf9b21 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -50,6 +50,10 @@ export function clearPluginCommandsForPlugin(pluginId: string): void { } } +export function isTrustedReservedCommandOwner(command: RegisteredPluginCommand): boolean { + return command.ownership === "reserved"; +} + export function listRegisteredPluginCommands(): RegisteredPluginCommand[] { return Array.from(pluginCommands.values()); } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 27bc3c75c80..55ce469ab55 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -12,7 +12,9 @@ import { matchPluginCommand, registerPluginCommand, } from "./commands.js"; +import { createPluginRegistry, type PluginRecord } from "./registry.js"; import { setActivePluginRegistry } from "./runtime.js"; +import type { PluginRuntime } from "./runtime/types.js"; type CommandsModule = typeof import("./commands.js"); @@ -31,6 +33,59 @@ function createVoiceCommand(overrides: Partial[1], +) { + const pluginRegistry = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: true, + }); + pluginRegistry.registerCommand(createBundledPluginRecord(command.name), command); +} + function registerVoiceCommandForTest( overrides: Partial[1]> = {}, ) { @@ -415,7 +470,7 @@ describe("registerPluginCommand", () => { it("keeps reserved command bypass scoped to the primary command name", () => { const result = registerPluginCommand( - "bundled-plugin", + "status", createVoiceCommand({ name: "status", nativeNames: { @@ -432,6 +487,135 @@ describe("registerPluginCommand", () => { }); }); + it("reserves the bundled Codex command name", () => { + const result = registerPluginCommand("demo-plugin", { + name: "codex", + description: "Fake Codex command", + handler: async () => ({ text: "ok" }), + }); + + expect(result).toEqual({ + ok: false, + error: 'Command name "codex" is reserved by a built-in command', + }); + }); + + it("rejects reserved ownership on non-reserved direct command registrations", () => { + const result = registerPluginCommand( + "demo-plugin", + { + name: "voice", + description: "Voice command", + ownership: "reserved", + handler: async () => ({ text: "ok" }), + }, + { allowReservedCommandNames: true }, + ); + + expect(result).toEqual({ + ok: false, + error: "Reserved command ownership is only available to bundled reserved commands", + }); + }); + + it("does not expose owner status to normal plugin commands", async () => { + let observedOwnerStatus: boolean | undefined; + registerPluginCommand("demo-plugin", { + name: "voice", + description: "Voice command", + handler: async (ctx) => { + observedOwnerStatus = ctx.senderIsOwner; + return { text: "ok" }; + }, + }); + const match = matchPluginCommand("/voice"); + expect(match).toBeTruthy(); + + await executePluginCommand({ + command: match!.command, + channel: "telegram", + isAuthorizedSender: true, + senderIsOwner: true, + commandBody: "/voice", + config: {}, + }); + + expect(observedOwnerStatus).toBeUndefined(); + }); + + it("does not allow direct reserved command registrations to claim owner status", () => { + const result = registerPluginCommand( + "codex", + { + name: "codex", + description: "Codex command", + ownership: "reserved", + handler: async () => ({ text: "ok" }), + }, + { allowReservedCommandNames: true }, + ); + + expect(result).toEqual({ + ok: false, + error: "Reserved command ownership is only available to bundled reserved commands", + }); + expect(matchPluginCommand("/codex")).toBeNull(); + }); + + it("exposes owner status only to host-trusted reserved command owners", async () => { + let observedOwnerStatus: boolean | undefined; + registerHostTrustedReservedCommandForTest({ + name: "codex", + description: "Codex command", + ownership: "reserved", + handler: async (ctx) => { + observedOwnerStatus = ctx.senderIsOwner; + return { text: "ok" }; + }, + }); + const match = matchPluginCommand("/codex"); + expect(match).toBeTruthy(); + + await executePluginCommand({ + command: match!.command, + channel: "telegram", + isAuthorizedSender: true, + senderIsOwner: true, + commandBody: "/codex", + config: {}, + }); + + expect(observedOwnerStatus).toBe(true); + }); + + it("rejects mismatched reserved command owners", () => { + const pluginRegistry = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: true, + }); + pluginRegistry.registerCommand(createBundledPluginRecord("bundled-plugin"), { + name: "codex", + description: "Codex command", + ownership: "reserved", + handler: async () => ({ text: "ok" }), + }); + + expect(pluginRegistry.registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "bundled-plugin", + message: + 'command registration failed: Reserved command ownership requires plugin id "bundled-plugin" to match reserved command name "codex"', + }), + ); + }); + it("shares plugin commands across duplicate module instances", async () => { const first = await importCommandsModule(`first-${Date.now()}`); const second = await importCommandsModule(`second-${Date.now()}`); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 0b50df6c6db..0b4d3aef04d 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -13,12 +13,14 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { clearPluginCommands, clearPluginCommandsForPlugin, + isReservedCommandName, listPluginInvocationKeys, registerPluginCommand, validateCommandName, validatePluginCommandDefinition, } from "./command-registration.js"; import { + isTrustedReservedCommandOwner, pluginCommands, setPluginCommandRegistryLocked, type RegisteredPluginCommand, @@ -175,6 +177,7 @@ export async function executePluginCommand(params: { channel: string; channelId?: PluginCommandContext["channelId"]; isAuthorizedSender: boolean; + senderIsOwner?: boolean; gatewayClientScopes?: PluginCommandContext["gatewayClientScopes"]; sessionKey?: PluginCommandContext["sessionKey"]; sessionId?: PluginCommandContext["sessionId"]; @@ -186,6 +189,10 @@ export async function executePluginCommand(params: { accountId?: PluginCommandContext["accountId"]; messageThreadId?: PluginCommandContext["messageThreadId"]; threadParentId?: PluginCommandContext["threadParentId"]; + diagnosticsSessions?: PluginCommandContext["diagnosticsSessions"]; + diagnosticsUploadApproved?: PluginCommandContext["diagnosticsUploadApproved"]; + diagnosticsPreviewOnly?: PluginCommandContext["diagnosticsPreviewOnly"]; + diagnosticsPrivateRouted?: PluginCommandContext["diagnosticsPrivateRouted"]; }): Promise { const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params; @@ -232,12 +239,41 @@ export async function executePluginCommand(params: { threadParentId: params.threadParentId, }); const effectiveAccountId = bindingConversation?.accountId ?? params.accountId; + const senderIsOwnerForCommand = + isTrustedReservedCommandOwner(command) && + command.ownership === "reserved" && + isReservedCommandName(command.name) && + command.pluginId === normalizeLowercaseStringOrEmpty(command.name) + ? params.senderIsOwner + : undefined; + const diagnosticsPrivateRoutedForCommand = + isTrustedReservedCommandOwner(command) && + command.ownership === "reserved" && + isReservedCommandName(command.name) && + command.pluginId === normalizeLowercaseStringOrEmpty(command.name) + ? params.diagnosticsPrivateRouted + : undefined; + const diagnosticsUploadApprovedForCommand = + isTrustedReservedCommandOwner(command) && + command.ownership === "reserved" && + isReservedCommandName(command.name) && + command.pluginId === normalizeLowercaseStringOrEmpty(command.name) + ? params.diagnosticsUploadApproved + : undefined; + const diagnosticsPreviewOnlyForCommand = + isTrustedReservedCommandOwner(command) && + command.ownership === "reserved" && + isReservedCommandName(command.name) && + command.pluginId === normalizeLowercaseStringOrEmpty(command.name) + ? params.diagnosticsPreviewOnly + : undefined; const ctx: PluginCommandContext = { senderId, channel, channelId: params.channelId, isAuthorizedSender, + ...(senderIsOwnerForCommand === undefined ? {} : { senderIsOwner: senderIsOwnerForCommand }), gatewayClientScopes: params.gatewayClientScopes, sessionKey: params.sessionKey, sessionId: params.sessionId, @@ -250,6 +286,16 @@ export async function executePluginCommand(params: { accountId: effectiveAccountId, messageThreadId: params.messageThreadId, threadParentId: params.threadParentId, + diagnosticsSessions: params.diagnosticsSessions, + ...(diagnosticsUploadApprovedForCommand === undefined + ? {} + : { diagnosticsUploadApproved: diagnosticsUploadApprovedForCommand }), + ...(diagnosticsPreviewOnlyForCommand === undefined + ? {} + : { diagnosticsPreviewOnly: diagnosticsPreviewOnlyForCommand }), + ...(diagnosticsPrivateRoutedForCommand === undefined + ? {} + : { diagnosticsPrivateRouted: diagnosticsPrivateRoutedForCommand }), requestConversationBinding: async (bindingParams) => { if (!command.pluginRoot || !bindingConversation) { return { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 878f70db48a..2ea3890410a 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -26,7 +26,10 @@ import { } from "../infra/node-commands.js"; import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; import { getDetachedTaskLifecycleRuntimeRegistration, registerDetachedTaskLifecycleRuntime, @@ -46,7 +49,7 @@ import { registerPluginCommand, validatePluginCommandDefinition, } from "./command-registration.js"; -import { clearPluginCommandsForPlugin } from "./command-registry-state.js"; +import { clearPluginCommandsForPlugin, pluginCommands } from "./command-registry-state.js"; import { getRegisteredCompactionProvider, registerCompactionProvider, @@ -1329,6 +1332,15 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } + if (allowReservedCommandNames && record.id !== normalizeLowercaseStringOrEmpty(name)) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `command registration failed: Reserved command ownership requires plugin id "${record.id}" to match reserved command name "${normalizeLowercaseStringOrEmpty(name)}"`, + }); + return; + } // For snapshot (non-activating) loads, record the command locally without touching the // global plugin command registry so running gateway commands stay intact. @@ -1350,11 +1362,17 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } } else { - const result = registerPluginCommand(record.id, command, { - pluginName: record.name, - pluginRoot: record.rootDir, - allowReservedCommandNames, - }); + const { ownership: _ownership, ...commandForRegistration } = command; + void _ownership; + const result = registerPluginCommand( + record.id, + allowReservedCommandNames ? commandForRegistration : command, + { + pluginName: record.name, + pluginRoot: record.rootDir, + allowReservedCommandNames, + }, + ); if (!result.ok) { pushDiagnostic({ level: "error", @@ -1364,6 +1382,12 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } + if (allowReservedCommandNames) { + const registeredCommand = pluginCommands.get(`/${name.toLowerCase()}`); + if (registeredCommand?.pluginId === record.id) { + registeredCommand.ownership = "reserved"; + } + } } record.commands.push(name); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index d5be5ac7261..affc846212a 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1838,6 +1838,27 @@ export type OpenClawPluginGatewayMethod = { // Plugin Commands // ============================================================================= +export type PluginCommandDiagnosticsSession = { + /** Stable host session key when available. */ + sessionKey?: string; + /** Ephemeral OpenClaw session id when available. */ + sessionId?: string; + /** Transcript file for this OpenClaw session when available. */ + sessionFile?: string; + /** Embedded agent harness selected for this session. */ + agentHarnessId?: string; + /** Channel/provider for this session when available. */ + channel?: string; + /** Provider channel id when available. */ + channelId?: ChannelId; + /** Account id for multi-account channels when available. */ + accountId?: string; + /** Thread/topic id when available. */ + messageThreadId?: string | number; + /** Parent conversation id for thread-capable channels when available. */ + threadParentId?: string; +}; + /** * Context passed to plugin command handlers. */ @@ -1850,6 +1871,8 @@ export type PluginCommandContext = { channelId?: ChannelId; /** Whether the sender is on the allowlist */ isAuthorizedSender: boolean; + /** Whether the sender is an owner for owner-only command surfaces. */ + senderIsOwner?: boolean; /** Gateway client scopes for internal control-plane callers */ gatewayClientScopes?: string[]; /** Stable host session key for the active conversation when available. */ @@ -1874,6 +1897,14 @@ export type PluginCommandContext = { messageThreadId?: string | number; /** Parent conversation id for thread-capable channels */ threadParentId?: string; + /** Sensitive diagnostics-only session inventory for owner-gated commands. */ + diagnosticsSessions?: PluginCommandDiagnosticsSession[]; + /** Internal diagnostics-only marker that exec approval already authorized upload. */ + diagnosticsUploadApproved?: boolean; + /** Internal diagnostics-only marker to preview upload effects without exposing ids. */ + diagnosticsPreviewOnly?: boolean; + /** Internal diagnostics-only marker for owner-private routed confirmations. */ + diagnosticsPrivateRouted?: boolean; requestConversationBinding: ( params?: PluginConversationBindingRequestParams, ) => Promise; diff --git a/src/trajectory/command-export.ts b/src/trajectory/command-export.ts new file mode 100644 index 00000000000..ee4e327fa51 --- /dev/null +++ b/src/trajectory/command-export.ts @@ -0,0 +1,168 @@ +import fs from "node:fs"; +import path from "node:path"; +import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js"; + +export type TrajectoryCommandExportSummary = { + outputDir: string; + displayPath: string; + sessionId: string; + eventCount: number; + runtimeEventCount: number; + transcriptEventCount: number; + files: string[]; +}; + +function isPathInsideOrEqual(baseDir: string, candidate: string): boolean { + const relative = path.relative(baseDir, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function validateExistingExportDirectory(params: { + dir: string; + label: string; + realWorkspace: string; +}): string { + const linkStat = fs.lstatSync(params.dir); + if (linkStat.isSymbolicLink() || !linkStat.isDirectory()) { + throw new Error(`${params.label} must be a real directory inside the workspace`); + } + const realDir = fs.realpathSync(params.dir); + if (!isPathInsideOrEqual(params.realWorkspace, realDir)) { + throw new Error("Trajectory exports directory must stay inside the workspace"); + } + return realDir; +} + +function mkdirIfMissingThenValidate(params: { + dir: string; + label: string; + realWorkspace: string; +}): string { + if (!fs.existsSync(params.dir)) { + try { + fs.mkdirSync(params.dir, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + } + } + return validateExistingExportDirectory(params); +} + +function resolveTrajectoryExportBaseDir(workspaceDir: string): { + baseDir: string; + realBase: string; +} { + const workspacePath = path.resolve(workspaceDir); + const realWorkspace = fs.realpathSync(workspacePath); + const stateDir = path.join(workspacePath, ".openclaw"); + mkdirIfMissingThenValidate({ + dir: stateDir, + label: "OpenClaw state directory", + realWorkspace, + }); + const baseDir = path.join(stateDir, "trajectory-exports"); + const realBase = mkdirIfMissingThenValidate({ + dir: baseDir, + label: "Trajectory exports directory", + realWorkspace, + }); + return { baseDir: path.resolve(baseDir), realBase }; +} + +export function resolveTrajectoryCommandOutputDir(params: { + outputPath?: string; + workspaceDir: string; + sessionId: string; +}): string { + const { baseDir, realBase } = resolveTrajectoryExportBaseDir(params.workspaceDir); + const raw = params.outputPath?.trim(); + if (!raw) { + const defaultDir = resolveDefaultTrajectoryExportDir({ + workspaceDir: params.workspaceDir, + sessionId: params.sessionId, + }); + return path.join(baseDir, path.basename(defaultDir)); + } + if (path.isAbsolute(raw) || raw.startsWith("~")) { + throw new Error("Output path must be relative to the workspace trajectory exports directory"); + } + const resolvedBase = path.resolve(baseDir); + const outputDir = path.resolve(resolvedBase, raw); + const relative = path.relative(resolvedBase, outputDir); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Output path must stay inside the workspace trajectory exports directory"); + } + let existingParent = outputDir; + while (!fs.existsSync(existingParent)) { + const next = path.dirname(existingParent); + if (next === existingParent) { + break; + } + existingParent = next; + } + const realExistingParent = fs.realpathSync(existingParent); + if (!isPathInsideOrEqual(realBase, realExistingParent)) { + throw new Error("Output path must stay inside the real trajectory exports directory"); + } + return outputDir; +} + +export function exportTrajectoryForCommand(params: { + outputDir?: string; + outputPath?: string; + sessionFile: string; + sessionId: string; + sessionKey: string; + workspaceDir: string; +}): TrajectoryCommandExportSummary { + const outputDir = + params.outputDir ?? + resolveTrajectoryCommandOutputDir({ + outputPath: params.outputPath, + workspaceDir: params.workspaceDir, + sessionId: params.sessionId, + }); + const bundle = exportTrajectoryBundle({ + outputDir, + sessionFile: params.sessionFile, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + }); + const relativePath = path.relative(params.workspaceDir, bundle.outputDir); + const displayPath = + relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath) + ? relativePath + : path.basename(bundle.outputDir); + const files = ["manifest.json", "events.jsonl", "session-branch.json"]; + if (bundle.events.some((event) => event.type === "context.compiled")) { + files.push("system-prompt.txt", "tools.json"); + } + files.push(...bundle.supplementalFiles); + return { + outputDir: bundle.outputDir, + displayPath, + sessionId: params.sessionId, + eventCount: bundle.manifest.eventCount, + runtimeEventCount: bundle.manifest.runtimeEventCount, + transcriptEventCount: bundle.manifest.transcriptEventCount, + files, + }; +} + +export function formatTrajectoryCommandExportSummary( + summary: TrajectoryCommandExportSummary, +): string { + return [ + "✅ Trajectory exported!", + "", + `📦 Bundle: ${summary.displayPath}`, + `🧵 Session: ${summary.sessionId}`, + `📊 Events: ${summary.eventCount}`, + `🧪 Runtime events: ${summary.runtimeEventCount}`, + `📝 Transcript events: ${summary.transcriptEventCount}`, + `📁 Files: ${summary.files.join(", ")}`, + ].join("\n"); +}