mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 14:02:56 +08:00
Wire diagnostics through the core chat command (#72936)
* feat: wire codex diagnostics feedback * fix: harden codex diagnostics hints * fix: neutralize codex diagnostics output * fix: tighten codex diagnostics safeguards * fix: bound codex diagnostics feedback output * fix: tighten codex diagnostics throttling * fix: confirm codex diagnostics uploads * docs: clarify codex diagnostics add-on * fix: route diagnostics through core command * fix: tighten diagnostics authorization * fix: pin diagnostics to bundled codex command * fix: limit owner status in plugin commands * fix: scope diagnostics confirmations * fix: scope codex diagnostics cooldowns * fix: harden codex diagnostics ownership scopes * fix: harden diagnostics command trust and display * fix: keep diagnostics command trust internal * fix: clarify diagnostics exec boundary * fix: consume codex diagnostics confirmations atomically * test: include codex diagnostics binding metadata * test: use string codex binding timestamps * fix: keep reserved command trust host-only * fix: harden diagnostics trust and resume hints * wire diagnostics through exec approval * fix: keep diagnostics tests aligned with bundled root trust * fix telegram diagnostics owner auth * route trajectory exports through exec approval * fix trajectory exec command encoding * fix telegram group owner auth * fix export trajectory approval hardening * fix pairing command owner bootstrap * fix telegram owner exec approvals * fix: make diagnostics approval flow pasteable * fix: route native sensitive command followups * fix: invoke diagnostics exports with current cli * fix: refresh exec approval protocol models * fix: list codex diagnostics from thread bindings * fix: fold codex diagnostics into exec approval * fix: preserve diagnostics approval line breaks * docs: clarify diagnostics codex workflow
This commit is contained in:
@@ -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 <thread-id>` 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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <CODE>
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
<Note>
|
||||
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).
|
||||
</Note>
|
||||
|
||||
## 2) Node device pairing (iOS/Android/macOS/headless nodes)
|
||||
|
||||
@@ -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:<your user id>`.
|
||||
|
||||
### 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`
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ Notes:
|
||||
- Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.<provider>`.
|
||||
- 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.
|
||||
|
||||
@@ -57,12 +57,19 @@ Options:
|
||||
- `--account <accountId>`: 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 <channel>`.
|
||||
- `pairing list` supports `--account <accountId>` for multi-account channels.
|
||||
- `pairing approve` supports `--account <accountId>` and `--notify`.
|
||||
- If only one pairing-capable channel is configured, `pairing approve <code>` 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
|
||||
|
||||
|
||||
@@ -26,6 +26,17 @@ Scope selection:
|
||||
- `--all-agents`: aggregate all configured agent stores
|
||||
- `--store <path>`: 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
|
||||
|
||||
@@ -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 <thread-id>` 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:
|
||||
|
||||
@@ -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 `<id>` here" | `/codex resume <id>` |
|
||||
| "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 <thread-id>` 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 <thread-id>` 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 <thread-id>`
|
||||
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 <thread-id>
|
||||
```
|
||||
|
||||
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 <thread-id>`. 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.
|
||||
|
||||
@@ -92,7 +92,7 @@ There are two related systems:
|
||||
Enables `/restart` plus gateway restart tool actions.
|
||||
</ParamField>
|
||||
<ParamField path="commands.ownerAllowFrom" type="string[]">
|
||||
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.
|
||||
</ParamField>
|
||||
<ParamField path="channels.<channel>.commands.enforceOwnerForCommands" type="boolean" default="false">
|
||||
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 <duration|off>` and `/session max-age <duration|off>` 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`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Model and run controls">
|
||||
@@ -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 <thread-id>` commands. See [Diagnostics Export](/gateway/diagnostics).
|
||||
- `/crestodian <request>` 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 <camera|screen|writes|all> [duration]|disarm` temporarily arms high-risk phone node commands.
|
||||
- `/voice status|list [limit]|set <voiceId|name>` 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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, number>();
|
||||
const lastCodexDiagnosticsUploadByScope = new Map<string, number>();
|
||||
const pendingCodexDiagnosticsConfirmations = new Map<string, PendingCodexDiagnosticsConfirmation>();
|
||||
const pendingCodexDiagnosticsConfirmationTokensByScope = new Map<string, string[]>();
|
||||
|
||||
export function resetCodexDiagnosticsFeedbackStateForTests(): void {
|
||||
lastCodexDiagnosticsUploadByThread.clear();
|
||||
lastCodexDiagnosticsUploadByScope.clear();
|
||||
pendingCodexDiagnosticsConfirmations.clear();
|
||||
pendingCodexDiagnosticsConfirmationTokensByScope.clear();
|
||||
}
|
||||
|
||||
export async function handleCodexSubcommand(
|
||||
ctx: PluginCommandContext,
|
||||
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> },
|
||||
@@ -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<str
|
||||
return readCodexConversationBindingData(binding)?.sessionFile ?? ctx.sessionFile;
|
||||
}
|
||||
|
||||
async function handleCodexDiagnosticsFeedback(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
pluginConfig: unknown,
|
||||
args: string,
|
||||
commandPrefix: string,
|
||||
): Promise<PluginCommandResult> {
|
||||
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<PluginCommandResult> {
|
||||
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 <thread-id> 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<string> {
|
||||
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 <thread-id> 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<string> {
|
||||
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<string> {
|
||||
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 <thread-id> 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<string> {
|
||||
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 <thread-id> 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<boolean> {
|
||||
if (await resolveControlSessionFile(ctx)) {
|
||||
return true;
|
||||
}
|
||||
return (ctx.diagnosticsSessions ?? []).some((session) => Boolean(session.sessionFile));
|
||||
}
|
||||
|
||||
async function resolveCodexDiagnosticsTargets(
|
||||
deps: CodexCommandDeps,
|
||||
ctx: PluginCommandContext,
|
||||
): Promise<CodexDiagnosticsTarget[]> {
|
||||
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<string>();
|
||||
const seenThreadIds = new Set<string>();
|
||||
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<CodexDiagnosticsTarget[]> {
|
||||
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<string, string> {
|
||||
const tags: Record<string, string> = {
|
||||
source: CODEX_DIAGNOSTICS_SOURCE,
|
||||
};
|
||||
addTag(tags, "channel", ctx.channel);
|
||||
return tags;
|
||||
}
|
||||
|
||||
function addTag(tags: Record<string, string>, 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 || "<unknown>";
|
||||
}
|
||||
|
||||
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<string, number>,
|
||||
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<string, number>, 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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`");
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("native command auth in groups", () => {
|
||||
telegramCfg?: TelegramAccountConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
storeAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
groupConfig?: Record<string, unknown>;
|
||||
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: {
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
} {
|
||||
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",
|
||||
|
||||
@@ -128,6 +128,7 @@ export function createNativeCommandsHarness(params?: {
|
||||
telegramCfg?: TelegramAccountConfig;
|
||||
allowFrom?: string[];
|
||||
groupAllowFrom?: string[];
|
||||
storeAllowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
nativeEnabled?: boolean;
|
||||
groupConfig?: Record<string, unknown>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -35,18 +35,22 @@ function normalizeTelegramDirectApproverId(value: string | number): string | und
|
||||
return chatId;
|
||||
}
|
||||
|
||||
function resolveTelegramOwnerApprovers(cfg: OpenClawConfig): Array<string | number> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BuildExecApprovalFollowupTargetMock>(() => 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<SendExecApprovalFollowupResult>(async () => undefined),
|
||||
);
|
||||
const enforceStrictInlineEvalApprovalBoundaryMock = vi.hoisted(() =>
|
||||
vi.fn<StrictInlineEvalBoundary>((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",
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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, unknown>): 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, unknown>): 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<string | undefined> {
|
||||
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<ProcessGatewayAllowlistResult> {
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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<string | undefined>;
|
||||
|
||||
export type ExecElevatedDefaults = {
|
||||
enabled: boolean;
|
||||
allowed: boolean;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
624
src/auto-reply/reply/commands-diagnostics.test.ts
Normal file
624
src/auto-reply/reply/commands-diagnostics.test.ts
Normal file
@@ -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> = {},
|
||||
): 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<typeof registerPluginCommand>[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<unknown>,
|
||||
) {
|
||||
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<string | undefined>;
|
||||
};
|
||||
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 <thread-id> 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();
|
||||
});
|
||||
});
|
||||
614
src/auto-reply/reply/commands-diagnostics.ts
Normal file
614
src/auto-reply/reply/commands-diagnostics.ts
Normal file
@@ -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<PrivateCommandRouteTarget[]>;
|
||||
deliverPrivateDiagnosticsReply: (params: {
|
||||
commandParams: HandleCommandsParams;
|
||||
targets: PrivateCommandRouteTarget[];
|
||||
reply: ReplyPayload;
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type GatewayDiagnosticsApprovalResult =
|
||||
| { status: "pending" }
|
||||
| { status: "reply"; reply: ReplyPayload };
|
||||
|
||||
type CodexDiagnosticsApprovalIntegration = {
|
||||
approvalText?: string;
|
||||
approvalFollowup?: () => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
const defaultDiagnosticsCommandDeps: DiagnosticsCommandDeps = {
|
||||
createExecTool,
|
||||
resolvePrivateDiagnosticsTargets: resolvePrivateDiagnosticsTargetsForCommand,
|
||||
deliverPrivateDiagnosticsReply: deliverPrivateDiagnosticsReply,
|
||||
};
|
||||
|
||||
export function createDiagnosticsCommandHandler(
|
||||
deps: Partial<DiagnosticsCommandDeps> = {},
|
||||
): 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 || "<unknown>"}`,
|
||||
);
|
||||
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<ReplyPayload | undefined> {
|
||||
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<PrivateCommandRouteTarget[]> {
|
||||
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<boolean> {
|
||||
return await deliverPrivateCommandReply(params);
|
||||
}
|
||||
|
||||
async function requestGatewayDiagnosticsExportApproval(
|
||||
deps: DiagnosticsCommandDeps,
|
||||
params: HandleCommandsParams,
|
||||
options: { privateApprovalTarget?: PrivateCommandRouteTarget } = {},
|
||||
codexDiagnostics: CodexDiagnosticsApprovalIntegration = {},
|
||||
): Promise<GatewayDiagnosticsApprovalResult> {
|
||||
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<CodexDiagnosticsApprovalIntegration | undefined> {
|
||||
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<PluginCommandResult | undefined> {
|
||||
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<string, SessionEntry>();
|
||||
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`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PrivateCommandRouteTarget[]>;
|
||||
deliverPrivateTrajectoryReply: (params: {
|
||||
commandParams: HandleCommandsParams;
|
||||
targets: PrivateCommandRouteTarget[];
|
||||
reply: ReplyPayload;
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
|
||||
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<ExportTrajectoryCommandDeps> = {},
|
||||
): Promise<ReplyPayload> {
|
||||
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<ReplyPayload> {
|
||||
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<typeof exportTrajectoryBundle>;
|
||||
let summary: ReturnType<typeof exportTrajectoryForCommand>;
|
||||
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<PrivateCommandRouteTarget[]> {
|
||||
return await resolvePrivateCommandRouteTargets({
|
||||
commandParams: params,
|
||||
request: buildTrajectoryExportApprovalRequest(params, request),
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverPrivateTrajectoryReply(params: {
|
||||
commandParams: HandleCommandsParams;
|
||||
targets: PrivateCommandRouteTarget[];
|
||||
reply: ReplyPayload;
|
||||
}): Promise<boolean> {
|
||||
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<string> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) };
|
||||
};
|
||||
|
||||
17
src/auto-reply/reply/commands-openclaw-cli.ts
Normal file
17
src/auto-reply/reply/commands-openclaw-cli.ts
Normal file
@@ -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(" ");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
114
src/auto-reply/reply/commands-private-route.ts
Normal file
114
src/auto-reply/reply/commands-private-route.ts
Normal file
@@ -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<PrivateCommandRouteTarget[]> {
|
||||
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<boolean> {
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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 <key>", "Session key to export")
|
||||
.option("--output <path>", "Output directory name inside .openclaw/trajectory-exports")
|
||||
.option("--workspace <path>", "Workspace root for the export (default: current directory)")
|
||||
.option("--store <path>", "Path to session store (default: resolved from session key)")
|
||||
.option("--agent <id>", "Agent id for resolving the default session store")
|
||||
.option("--request-json-base64 <payload>", "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")
|
||||
|
||||
54
src/commands/doctor-command-owner.test.ts
Normal file
54
src/commands/doctor-command-owner.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
51
src/commands/doctor-command-owner.ts
Normal file
51
src/commands/doctor-command-owner.ts
Normal file
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
142
src/commands/export-trajectory.ts
Normal file
142
src/commands/export-trajectory.ts
Normal file
@@ -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<ExportTrajectoryCommandOptions> {
|
||||
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<void> {
|
||||
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<typeof exportTrajectoryForCommand>;
|
||||
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));
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +184,11 @@ async function runGatewayAuthHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
note("Gateway token configured.", "Gateway auth");
|
||||
}
|
||||
|
||||
async function runCommandOwnerHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteCommandOwnerHealth } = await import("../commands/doctor-command-owner.js");
|
||||
noteCommandOwnerHealth(ctx.cfg);
|
||||
}
|
||||
|
||||
async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
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",
|
||||
|
||||
@@ -138,6 +138,29 @@ async function waitForPath(filePath: string, timeoutMs = 60_000): Promise<void>
|
||||
throw new Error(`timed out waiting for ${filePath}`);
|
||||
}
|
||||
|
||||
async function approveTrajectoryExport(client: GatewayClient): Promise<string> {
|
||||
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<void>> = [];
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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()])),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> })?.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);
|
||||
|
||||
@@ -68,6 +68,7 @@ function buildExecViewBase<TPhase extends ApprovalPhase>(
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Parameters<typeof setFacadeLoaderJitiFactoryForTest>[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<string, unknown>)[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<FacadeLoaderJitiFactory>[] = [];
|
||||
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<string, unknown>)[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");
|
||||
|
||||
@@ -28,6 +28,8 @@ function getReservedCommands(): Set<string> {
|
||||
"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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<Parameters<typeof registerPluginC
|
||||
};
|
||||
}
|
||||
|
||||
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<typeof registerPluginCommand>[1],
|
||||
) {
|
||||
const pluginRegistry = createPluginRegistry({
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
},
|
||||
runtime: {} as PluginRuntime,
|
||||
activateGlobalSideEffects: true,
|
||||
});
|
||||
pluginRegistry.registerCommand(createBundledPluginRecord(command.name), command);
|
||||
}
|
||||
|
||||
function registerVoiceCommandForTest(
|
||||
overrides: Partial<Parameters<typeof registerPluginCommand>[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()}`);
|
||||
|
||||
@@ -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<PluginCommandResult> {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PluginConversationBindingRequestResult>;
|
||||
|
||||
168
src/trajectory/command-export.ts
Normal file
168
src/trajectory/command-export.ts
Normal file
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user