mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Merge branch 'main' into dashboard-v2-views-refactor
This commit is contained in:
31
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
31
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -76,6 +76,37 @@ body:
|
||||
label: Install method
|
||||
description: How OpenClaw was installed or launched.
|
||||
placeholder: npm global / pnpm dev / docker / mac app
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: Effective model under test.
|
||||
placeholder: minimax/text-01 / openrouter/anthropic/claude-opus-4.1 / anthropic/claude-sonnet-4.5
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: provider_chain
|
||||
attributes:
|
||||
label: Provider / routing chain
|
||||
description: Effective request path through gateways, proxies, providers, or model routers.
|
||||
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: config_location
|
||||
attributes:
|
||||
label: Config file / key location
|
||||
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
|
||||
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
|
||||
- type: textarea
|
||||
id: provider_setup_details
|
||||
attributes:
|
||||
label: Additional provider/model setup details
|
||||
description: Optional. Include redacted routing details, per-agent overrides, auth-profile interactions, env/config context, or anything else needed to explain the effective provider/model setup. Do not include API keys, tokens, or passwords.
|
||||
placeholder: |
|
||||
Default route is openclaw -> cloudflare-ai-gateway -> minimax.
|
||||
Previous setup was openclaw -> cloudflare-ai-gateway -> openrouter -> minimax.
|
||||
Relevant config lives in ~/.openclaw/openclaw.json under models.providers.minimax and models.providers.cloudflare-ai-gateway.
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
|
||||
1
.github/workflows/auto-response.yml
vendored
1
.github/workflows/auto-response.yml
vendored
@@ -51,6 +51,7 @@ jobs:
|
||||
},
|
||||
{
|
||||
label: "r: no-ci-pr",
|
||||
close: true,
|
||||
message:
|
||||
"Please don't make PRs for test failures on main.\n\n" +
|
||||
"The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
|
||||
|
||||
8
.github/workflows/docker-release.yml
vendored
8
.github/workflows/docker-release.yml
vendored
@@ -109,8 +109,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
|
||||
- name: Build and push amd64 slim image
|
||||
id: build-slim
|
||||
@@ -124,8 +122,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
|
||||
# Build arm64 images (default + slim share the build stage cache)
|
||||
build-arm64:
|
||||
@@ -214,8 +210,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
|
||||
- name: Build and push arm64 slim image
|
||||
id: build-slim
|
||||
@@ -229,8 +223,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
|
||||
# Create multi-platform manifests
|
||||
create-manifest:
|
||||
|
||||
@@ -9,7 +9,19 @@ Input
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (review-only)
|
||||
Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
|
||||
Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
|
||||
|
||||
0. Truthfulness + reality gate (required for bug-fix claims)
|
||||
- Do not trust the issue text or PR summary by default; verify in code and evidence.
|
||||
- If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof).
|
||||
- Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong).
|
||||
- Verify fix targets the same code path as the root cause.
|
||||
- Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence.
|
||||
- Hallucination/BS red flags (treat as BLOCKER until disproven):
|
||||
- claimed behavior not present in repo,
|
||||
- issue/PR says "fixes #..." but changed files do not touch implicated path,
|
||||
- only docs/comments changed for a runtime bug claim,
|
||||
- vague AI-generated rationale without concrete evidence.
|
||||
|
||||
1. Identify PR meta + context
|
||||
|
||||
@@ -56,6 +68,7 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
|
||||
- Any deprecations, docs, types, or lint rules we should adjust?
|
||||
|
||||
8. Key questions to answer explicitly
|
||||
- Is the core claim substantiated by evidence, or is it likely invalid/hallucinated?
|
||||
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
|
||||
- Any blocking concerns (must-fix before merge)?
|
||||
- Is this PR ready to land, or does it need work?
|
||||
@@ -65,18 +78,32 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION
|
||||
- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION
|
||||
- 1–3 sentence rationale.
|
||||
|
||||
B) What changed
|
||||
B) Claim verification matrix (required)
|
||||
|
||||
- Fill this table:
|
||||
|
||||
| Field | Evidence |
|
||||
| ----------------------------------------------- | -------- |
|
||||
| Claimed problem | ... |
|
||||
| Evidence observed (repro/log/test/code) | ... |
|
||||
| Root cause location (`path:line`) | ... |
|
||||
| Why this fix addresses that root cause | ... |
|
||||
| Regression coverage (test name or manual proof) | ... |
|
||||
|
||||
- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`.
|
||||
|
||||
C) What changed
|
||||
|
||||
- Brief bullet summary of the diff/behavioral changes.
|
||||
|
||||
C) What's good
|
||||
D) What's good
|
||||
|
||||
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
|
||||
|
||||
D) Concerns / questions (actionable)
|
||||
E) Concerns / questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as:
|
||||
@@ -84,17 +111,19 @@ D) Concerns / questions (actionable)
|
||||
- IMPORTANT (should fix before merge)
|
||||
- NIT (optional)
|
||||
- For each: point to the file/area and propose a concrete fix or alternative.
|
||||
- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly.
|
||||
|
||||
E) Tests
|
||||
F) Tests
|
||||
|
||||
- What exists.
|
||||
- What's missing (specific scenarios).
|
||||
- State clearly whether there is a regression test for the claimed bug.
|
||||
|
||||
F) Follow-ups (optional)
|
||||
G) Follow-ups (optional)
|
||||
|
||||
- Non-blocking refactors/tickets to open later.
|
||||
|
||||
G) Suggested PR comment (optional)
|
||||
H) Suggested PR comment (optional)
|
||||
|
||||
- Offer: "Want me to draft a PR comment to the author?"
|
||||
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
"filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
|
||||
"is_verified": false,
|
||||
"line_number": 1763
|
||||
"line_number": 1859
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [
|
||||
@@ -266,7 +266,7 @@
|
||||
"filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
|
||||
"is_verified": false,
|
||||
"line_number": 1763
|
||||
"line_number": 1859
|
||||
}
|
||||
],
|
||||
"docs/.i18n/zh-CN.tm.jsonl": [
|
||||
@@ -11659,7 +11659,7 @@
|
||||
"filename": "src/agents/tools/web-search.ts",
|
||||
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
|
||||
"is_verified": false,
|
||||
"line_number": 292
|
||||
"line_number": 291
|
||||
}
|
||||
],
|
||||
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
|
||||
@@ -13013,5 +13013,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-03-09T08:37:13Z"
|
||||
"generated_at": "2026-03-10T03:11:06Z"
|
||||
}
|
||||
|
||||
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
|
||||
@@ -18,7 +18,7 @@ excluded:
|
||||
- coverage
|
||||
- "*.playground"
|
||||
# Generated (protocol-gen-swift.ts)
|
||||
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
|
||||
- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
|
||||
# Generated (generate-host-env-security-policy-swift.mjs)
|
||||
- apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -10,6 +10,35 @@
|
||||
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
|
||||
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
|
||||
|
||||
## Auto-close labels (issues and PRs)
|
||||
|
||||
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
|
||||
- Do not manually close + manually comment for these reasons.
|
||||
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
|
||||
- `r:*` labels can be used on both issues and PRs.
|
||||
|
||||
- `r: skill`: close with guidance to publish skills on Clawhub.
|
||||
- `r: support`: close with redirect to Discord support + stuck FAQ.
|
||||
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
|
||||
- `r: too-many-prs`: close when author exceeds active PR limit.
|
||||
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
|
||||
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
|
||||
- `r: moltbook`: close + lock as off-topic (not affiliated).
|
||||
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
|
||||
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
|
||||
|
||||
## PR truthfulness and bug-fix validation
|
||||
|
||||
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
||||
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
|
||||
- Minimum merge gate for bug-fix PRs:
|
||||
1. symptom evidence (repro/log/failing test),
|
||||
2. verified root cause in code with file/line,
|
||||
3. fix touches the implicated code path,
|
||||
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
|
||||
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
|
||||
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -14,6 +14,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
|
||||
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
|
||||
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
|
||||
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
|
||||
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
|
||||
@@ -41,6 +43,14 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
|
||||
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
|
||||
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
|
||||
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
|
||||
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
|
||||
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
|
||||
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
|
||||
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
|
||||
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
|
||||
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
|
||||
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
@@ -463,6 +473,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
|
||||
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
|
||||
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
|
||||
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
|
||||
@@ -760,6 +760,34 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
|
||||
- `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Exec approvals in Telegram">
|
||||
Telegram supports exec approvals in approver DMs and can optionally post approval prompts in the originating chat or topic.
|
||||
|
||||
Config path:
|
||||
|
||||
- `channels.telegram.execApprovals.enabled`
|
||||
- `channels.telegram.execApprovals.approvers`
|
||||
- `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
|
||||
- `agentFilter`, `sessionFilter`
|
||||
|
||||
Approvers must be numeric Telegram user IDs. When `enabled` is false or `approvers` is empty, Telegram does not act as an exec approval client. Approval requests fall back to other configured approval routes or the exec approval fallback policy.
|
||||
|
||||
Delivery rules:
|
||||
|
||||
- `target: "dm"` sends approval prompts only to configured approver DMs
|
||||
- `target: "channel"` sends the prompt back to the originating Telegram chat/topic
|
||||
- `target: "both"` sends to approver DMs and the originating chat/topic
|
||||
|
||||
Only configured approvers can approve or deny. Non-approvers cannot use `/approve` and cannot use Telegram approval buttons.
|
||||
|
||||
Channel delivery shows the command text in the chat, so only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for both the approval prompt and the post-approval follow-up.
|
||||
|
||||
Inline approval buttons also depend on `channels.telegram.capabilities.inlineButtons` allowing the target surface (`dm`, `group`, or `all`).
|
||||
|
||||
Related docs: [Exec approvals](/tools/exec-approvals)
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Troubleshooting
|
||||
@@ -859,10 +887,16 @@ Primary reference:
|
||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (group fields + topic-only `agentId`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
|
||||
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
|
||||
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
|
||||
- `channels.telegram.execApprovals.enabled`: enable Telegram as a chat-based exec approval client for this account.
|
||||
- `channels.telegram.execApprovals.approvers`: Telegram user IDs allowed to approve or deny exec requests. Required when exec approvals are enabled.
|
||||
- `channels.telegram.execApprovals.target`: `dm | channel | both` (default: `dm`). `channel` and `both` preserve the originating Telegram topic when present.
|
||||
- `channels.telegram.execApprovals.agentFilter`: optional agent ID filter for forwarded approval prompts.
|
||||
- `channels.telegram.execApprovals.sessionFilter`: optional session key filter (substring or regex) for forwarded approval prompts.
|
||||
- `channels.telegram.accounts.<account>.execApprovals`: per-account override for Telegram exec approval routing and approver authorization.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
|
||||
@@ -894,6 +928,7 @@ Telegram-specific high-signal fields:
|
||||
|
||||
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
|
||||
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `blockStreaming`
|
||||
|
||||
@@ -38,7 +38,8 @@ Examples of inactive surfaces:
|
||||
- Top-level channel credentials that no enabled account inherits.
|
||||
- Disabled tool/feature surfaces.
|
||||
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
|
||||
In auto mode (provider unset), provider-specific keys are also active for provider auto-detection.
|
||||
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
|
||||
After selection, non-selected provider keys are treated as inactive until selected.
|
||||
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
|
||||
- `gateway.mode=remote`
|
||||
- `gateway.remote.url` is configured
|
||||
|
||||
@@ -1489,10 +1489,16 @@ Set `cli.banner.taglineMode` in config:
|
||||
|
||||
### How do I enable web search and web fetch
|
||||
|
||||
`web_fetch` works without an API key. `web_search` requires a Brave Search API
|
||||
key. **Recommended:** run `openclaw configure --section web` to store it in
|
||||
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
|
||||
Gateway process.
|
||||
`web_fetch` works without an API key. `web_search` requires a key for your
|
||||
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
|
||||
**Recommended:** run `openclaw configure --section web` and choose a provider.
|
||||
Environment alternatives:
|
||||
|
||||
- Brave: `BRAVE_API_KEY`
|
||||
- Gemini: `GEMINI_API_KEY`
|
||||
- Grok: `XAI_API_KEY`
|
||||
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -1500,6 +1506,7 @@ Gateway process.
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "brave",
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5,
|
||||
},
|
||||
|
||||
@@ -71,11 +71,14 @@ Optional legacy controls:
|
||||
|
||||
**Via config:** run `openclaw configure --section web`. It stores the key in
|
||||
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
|
||||
That field also accepts SecretRef objects.
|
||||
|
||||
**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
in the Gateway process environment. For a gateway install, put it in
|
||||
`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
|
||||
If `provider: "perplexity"` is configured and the Perplexity key SecretRef is unresolved with no env fallback, startup/reload fails fast.
|
||||
|
||||
## Tool parameters
|
||||
|
||||
These parameters apply to the native Perplexity Search API path.
|
||||
|
||||
@@ -80,10 +80,10 @@ See [Memory](/concepts/memory).
|
||||
`web_search` uses API keys and may incur usage charges depending on your provider:
|
||||
|
||||
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Gemini (Google Search)**: `GEMINI_API_KEY`
|
||||
- **Grok (xAI)**: `XAI_API_KEY`
|
||||
- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
|
||||
- **Gemini (Google Search)**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
|
||||
- **Grok (xAI)**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
|
||||
- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||
|
||||
**Brave Search free credit:** Each Brave plan includes $5/month in renewing
|
||||
free credit. The Search plan costs $5 per 1,000 requests, so the credit covers
|
||||
|
||||
@@ -31,6 +31,7 @@ Scope intent:
|
||||
- `talk.providers.*.apiKey`
|
||||
- `messages.tts.elevenlabs.apiKey`
|
||||
- `messages.tts.openai.apiKey`
|
||||
- `tools.web.fetch.firecrawl.apiKey`
|
||||
- `tools.web.search.apiKey`
|
||||
- `tools.web.search.gemini.apiKey`
|
||||
- `tools.web.search.grok.apiKey`
|
||||
@@ -102,7 +103,8 @@ Notes:
|
||||
- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces.
|
||||
- For web search:
|
||||
- In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active.
|
||||
- In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active.
|
||||
- In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active.
|
||||
- In auto mode, non-selected provider refs are treated as inactive until selected.
|
||||
|
||||
## Unsupported credentials
|
||||
|
||||
|
||||
@@ -454,6 +454,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.fetch.firecrawl.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "tools.web.fetch.firecrawl.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.search.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -309,6 +309,32 @@ Reply in chat:
|
||||
/approve <id> deny
|
||||
```
|
||||
|
||||
### Built-in chat approval clients
|
||||
|
||||
Discord and Telegram can also act as explicit exec approval clients with channel-specific config.
|
||||
|
||||
- Discord: `channels.discord.execApprovals.*`
|
||||
- Telegram: `channels.telegram.execApprovals.*`
|
||||
|
||||
These clients are opt-in. If a channel does not have exec approvals enabled, OpenClaw does not treat
|
||||
that channel as an approval surface just because the conversation happened there.
|
||||
|
||||
Shared behavior:
|
||||
|
||||
- only configured approvers can approve or deny
|
||||
- the requester does not need to be an approver
|
||||
- when channel delivery is enabled, approval prompts include the command text
|
||||
- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback`
|
||||
|
||||
Telegram defaults to approver DMs (`target: "dm"`). You can switch to `channel` or `both` when you
|
||||
want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum
|
||||
topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up.
|
||||
|
||||
See:
|
||||
|
||||
- [Discord](/channels/discord#exec-approvals-in-discord)
|
||||
- [Telegram](/channels/telegram#exec-approvals-in-telegram)
|
||||
|
||||
### macOS IPC flow
|
||||
|
||||
```
|
||||
|
||||
@@ -40,7 +40,8 @@ with JS-heavy sites or pages that block plain HTTP fetches.
|
||||
|
||||
Notes:
|
||||
|
||||
- `firecrawl.enabled` defaults to true when an API key is present.
|
||||
- `firecrawl.enabled` defaults to `true` unless explicitly set to `false`.
|
||||
- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`).
|
||||
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
|
||||
|
||||
## Stealth / bot circumvention
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Brave or Perplexity Search API key setup
|
||||
- You need provider API key setup
|
||||
- You want to use Gemini with Google Search grounding
|
||||
title: "Web Tools"
|
||||
---
|
||||
@@ -49,6 +49,12 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
|
||||
|
||||
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
|
||||
|
||||
Runtime SecretRef behavior:
|
||||
|
||||
- Web tool SecretRefs are resolved atomically at gateway startup/reload.
|
||||
- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected.
|
||||
- If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast.
|
||||
|
||||
## Setting up web search
|
||||
|
||||
Use `openclaw configure --section web` to set up your API key and choose a provider.
|
||||
@@ -77,9 +83,25 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks
|
||||
|
||||
### Where to store the key
|
||||
|
||||
**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider.
|
||||
**Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path:
|
||||
|
||||
**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
- Brave: `tools.web.search.apiKey`
|
||||
- Gemini: `tools.web.search.gemini.apiKey`
|
||||
- Grok: `tools.web.search.grok.apiKey`
|
||||
- Kimi: `tools.web.search.kimi.apiKey`
|
||||
- Perplexity: `tools.web.search.perplexity.apiKey`
|
||||
|
||||
All of these fields also support SecretRef objects.
|
||||
|
||||
**Via environment:** set provider env vars in the Gateway process environment:
|
||||
|
||||
- Brave: `BRAVE_API_KEY`
|
||||
- Gemini: `GEMINI_API_KEY`
|
||||
- Grok: `XAI_API_KEY`
|
||||
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
|
||||
For a gateway install, put these in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
|
||||
### Config examples
|
||||
|
||||
@@ -216,6 +238,7 @@ Search the web using your configured provider.
|
||||
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
|
||||
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
|
||||
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||
- All provider key fields above support SecretRef objects.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -310,6 +333,7 @@ Fetch a URL and extract readable content.
|
||||
|
||||
- `tools.web.fetch.enabled` must not be `false` (default: enabled)
|
||||
- Optional Firecrawl fallback: set `tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`.
|
||||
- `tools.web.fetch.firecrawl.apiKey` supports SecretRef objects.
|
||||
|
||||
### web_fetch config
|
||||
|
||||
@@ -351,6 +375,8 @@ Notes:
|
||||
|
||||
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
||||
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
||||
- Firecrawl SecretRefs are resolved only when Firecrawl is active (`tools.web.fetch.enabled !== false` and `tools.web.fetch.firecrawl.enabled !== false`).
|
||||
- If Firecrawl is active and its SecretRef is unresolved with no `FIRECRAWL_API_KEY` fallback, startup/reload fails fast.
|
||||
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
||||
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
|
||||
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`.
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
to: "chat_1",
|
||||
text: file,
|
||||
accountId: "main",
|
||||
mediaLocalRoots: [dir],
|
||||
});
|
||||
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||
@@ -59,6 +60,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
to: "chat_1",
|
||||
mediaUrl: file,
|
||||
accountId: "main",
|
||||
mediaLocalRoots: [dir],
|
||||
}),
|
||||
);
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -81,7 +81,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => {
|
||||
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
||||
// Scheme A compatibility shim:
|
||||
// when upstream accidentally returns a local image path as plain text,
|
||||
@@ -95,6 +95,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
mediaUrl: localImagePath,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
return { channel: "feishu", ...result };
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.2"
|
||||
"openclaw": ">=2026.3.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.2"
|
||||
"openclaw": ">=2026.3.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -57,18 +57,38 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin
|
||||
const probeTelegram = vi.fn(async () =>
|
||||
params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false },
|
||||
);
|
||||
const collectUnmentionedGroupIds = vi.fn(() => ({
|
||||
groupIds: [] as string[],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
}));
|
||||
const auditGroupMembership = vi.fn(async () => ({
|
||||
ok: true,
|
||||
checkedGroups: 0,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups: [],
|
||||
elapsedMs: 0,
|
||||
}));
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
monitorTelegramProvider,
|
||||
probeTelegram,
|
||||
collectUnmentionedGroupIds,
|
||||
auditGroupMembership,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
return { monitorTelegramProvider, probeTelegram };
|
||||
return {
|
||||
monitorTelegramProvider,
|
||||
probeTelegram,
|
||||
collectUnmentionedGroupIds,
|
||||
auditGroupMembership,
|
||||
};
|
||||
}
|
||||
|
||||
describe("telegramPlugin duplicate token guard", () => {
|
||||
@@ -149,6 +169,85 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes account proxy and network settings into Telegram probes", async () => {
|
||||
const { probeTelegram } = installGatewayRuntime({
|
||||
probeOk: true,
|
||||
botUsername: "opsbot",
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.telegram!.accounts!.ops = {
|
||||
...cfg.channels!.telegram!.accounts!.ops,
|
||||
proxy: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
};
|
||||
const account = telegramPlugin.config.resolveAccount(cfg, "ops");
|
||||
|
||||
await telegramPlugin.status!.probeAccount!({
|
||||
account,
|
||||
timeoutMs: 5000,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, {
|
||||
accountId: "ops",
|
||||
proxyUrl: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes account proxy and network settings into Telegram membership audits", async () => {
|
||||
const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({
|
||||
probeOk: true,
|
||||
botUsername: "opsbot",
|
||||
});
|
||||
|
||||
collectUnmentionedGroupIds.mockReturnValue({
|
||||
groupIds: ["-100123"],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.telegram!.accounts!.ops = {
|
||||
...cfg.channels!.telegram!.accounts!.ops,
|
||||
proxy: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
groups: {
|
||||
"-100123": { requireMention: false },
|
||||
},
|
||||
};
|
||||
const account = telegramPlugin.config.resolveAccount(cfg, "ops");
|
||||
|
||||
await telegramPlugin.status!.auditAccount!({
|
||||
account,
|
||||
timeoutMs: 5000,
|
||||
probe: { ok: true, bot: { id: 123 }, elapsedMs: 1 },
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(auditGroupMembership).toHaveBeenCalledWith({
|
||||
token: "token-ops",
|
||||
botId: 123,
|
||||
groupIds: ["-100123"],
|
||||
proxyUrl: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => {
|
||||
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-1" }));
|
||||
setTelegramRuntime({
|
||||
@@ -179,6 +278,41 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" });
|
||||
});
|
||||
|
||||
it("preserves buttons for outbound text payload sends", async () => {
|
||||
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-2" }));
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
sendMessageTelegram,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const result = await telegramPlugin.outbound!.sendPayload!({
|
||||
cfg: createCfg(),
|
||||
to: "12345",
|
||||
text: "",
|
||||
payload: {
|
||||
text: "Approval required",
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"12345",
|
||||
"Approval required",
|
||||
expect.objectContaining({
|
||||
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" });
|
||||
});
|
||||
|
||||
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.telegram!.accounts!.ops = {} as never;
|
||||
|
||||
@@ -91,6 +91,10 @@ const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
},
|
||||
};
|
||||
|
||||
type TelegramInlineButtons = ReadonlyArray<
|
||||
ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
|
||||
>;
|
||||
|
||||
const telegramConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom,
|
||||
@@ -317,6 +321,62 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 10,
|
||||
sendPayload: async ({
|
||||
cfg,
|
||||
to,
|
||||
payload,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
}) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseTelegramThreadId(threadId);
|
||||
const telegramData = payload.channelData?.telegram as
|
||||
| { buttons?: TelegramInlineButtons; quoteText?: string }
|
||||
| undefined;
|
||||
const quoteText =
|
||||
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
|
||||
const text = payload.text ?? "";
|
||||
const mediaUrls = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
const baseOpts = {
|
||||
verbose: false,
|
||||
cfg,
|
||||
mediaLocalRoots,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
quoteText,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
};
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
const result = await send(to, text, {
|
||||
...baseOpts,
|
||||
buttons: telegramData?.buttons,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
}
|
||||
|
||||
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
|
||||
for (let i = 0; i < mediaUrls.length; i += 1) {
|
||||
const mediaUrl = mediaUrls[i];
|
||||
const isFirst = i === 0;
|
||||
finalResult = await send(to, isFirst ? text : "", {
|
||||
...baseOpts,
|
||||
mediaUrl,
|
||||
...(isFirst ? { buttons: telegramData?.buttons } : {}),
|
||||
});
|
||||
}
|
||||
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||
@@ -378,11 +438,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
collectStatusIssues: collectTelegramStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
getTelegramRuntime().channel.telegram.probeTelegram(
|
||||
account.token,
|
||||
timeoutMs,
|
||||
account.config.proxy,
|
||||
),
|
||||
getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, {
|
||||
accountId: account.accountId,
|
||||
proxyUrl: account.config.proxy,
|
||||
network: account.config.network,
|
||||
}),
|
||||
auditAccount: async ({ account, timeoutMs, probe, cfg }) => {
|
||||
const groups =
|
||||
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
|
||||
@@ -408,6 +468,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
botId,
|
||||
groupIds,
|
||||
proxyUrl: account.config.proxy,
|
||||
network: account.config.network,
|
||||
timeoutMs,
|
||||
});
|
||||
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||
@@ -471,11 +532,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
const token = (account.token ?? "").trim();
|
||||
let telegramBotLabel = "";
|
||||
try {
|
||||
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(
|
||||
token,
|
||||
2500,
|
||||
account.config.proxy,
|
||||
);
|
||||
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(token, 2500, {
|
||||
accountId: account.accountId,
|
||||
proxyUrl: account.config.proxy,
|
||||
network: account.config.network,
|
||||
});
|
||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||
if (username) {
|
||||
telegramBotLabel = ` (@${username})`;
|
||||
|
||||
551
pnpm-lock.yaml
generated
551
pnpm-lock.yaml
generated
@@ -338,8 +338,8 @@ importers:
|
||||
specifier: ^10.6.1
|
||||
version: 10.6.1
|
||||
openclaw:
|
||||
specifier: '>=2026.3.2'
|
||||
version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
specifier: '>=2026.3.7'
|
||||
version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
|
||||
extensions/imessage: {}
|
||||
|
||||
@@ -399,8 +399,8 @@ importers:
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
openclaw:
|
||||
specifier: '>=2026.3.2'
|
||||
version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
specifier: '>=2026.3.7'
|
||||
version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
|
||||
extensions/memory-lancedb:
|
||||
dependencies:
|
||||
@@ -618,18 +618,10 @@ packages:
|
||||
'@aws-crypto/util@5.2.0':
|
||||
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
|
||||
|
||||
'@aws-sdk/client-bedrock-runtime@3.1000.0':
|
||||
resolution: {integrity: sha512-GA96wgTFB4Z5vhysm+hErbgiEWZ9JqAl09BxARajL7Oanpf0KvdIjxuLp2rD/XqEIks9yG/5Rh9XIAoCUUTZXw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-bedrock-runtime@3.1004.0':
|
||||
resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-bedrock@3.1000.0':
|
||||
resolution: {integrity: sha512-wGU8uJXrPW/hZuHdPNVe1kAFIBiKcslBcoDBN0eYBzS13um8p5jJiQJ9WsD1nSpKCmyx7qZXc6xjcbIQPyOrrA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-bedrock@3.1004.0':
|
||||
resolution: {integrity: sha512-JbfZSV85IL+43S7rPBmeMbvoOYXs1wmrfbEpHkDBjkvbukRQWtoetiPAXNSKDfFq1qVsoq8sWPdoerDQwlUO8w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -718,18 +710,10 @@ packages:
|
||||
resolution: {integrity: sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/eventstream-handler-node@3.972.9':
|
||||
resolution: {integrity: sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.972.6':
|
||||
resolution: {integrity: sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-eventstream@3.972.6':
|
||||
resolution: {integrity: sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-eventstream@3.972.7':
|
||||
resolution: {integrity: sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -786,10 +770,6 @@ packages:
|
||||
resolution: {integrity: sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-websocket@3.972.10':
|
||||
resolution: {integrity: sha512-uNqRpbL6djE+XXO4cQ+P8ra37cxNNBP+2IfkVOXu1xFdGMfW+uOTxBQuDPpP43i40PBRBXK5un79l/oYpbzYkA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-websocket@3.972.12':
|
||||
resolution: {integrity: sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
@@ -818,10 +798,6 @@ packages:
|
||||
resolution: {integrity: sha512-gQYI/Buwp0CAGQxY7mR5VzkP56rkWq2Y1ROkFuXh5XY94DsSjJw62B3I0N0lysQmtwiL2ht2KHI9NylM/RP4FA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1000.0':
|
||||
resolution: {integrity: sha512-eOI+8WPtWpLdlYBGs8OCK3k5uIMUHVsNG3AFO4kaRaZcKReJ/2OO6+2O2Dd/3vTzM56kRjSKe7mBOCwa4PdYqg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1004.0':
|
||||
resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -980,15 +956,9 @@ packages:
|
||||
'@cacheable/utils@2.3.4':
|
||||
resolution: {integrity: sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==}
|
||||
|
||||
'@clack/core@1.0.1':
|
||||
resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==}
|
||||
|
||||
'@clack/core@1.1.0':
|
||||
resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==}
|
||||
|
||||
'@clack/prompts@1.0.1':
|
||||
resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==}
|
||||
|
||||
'@clack/prompts@1.1.0':
|
||||
resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==}
|
||||
|
||||
@@ -1222,15 +1192,6 @@ packages:
|
||||
'@eshaz/web-worker@1.2.2':
|
||||
resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==}
|
||||
|
||||
'@google/genai@1.43.0':
|
||||
resolution: {integrity: sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@modelcontextprotocol/sdk': ^1.25.2
|
||||
peerDependenciesMeta:
|
||||
'@modelcontextprotocol/sdk':
|
||||
optional: true
|
||||
|
||||
'@google/genai@1.44.0':
|
||||
resolution: {integrity: sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -1644,38 +1605,20 @@ packages:
|
||||
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-agent-core@0.55.3':
|
||||
resolution: {integrity: sha512-rqbfpQ9BrP6BDiW+Ps3A8Z/p9+Md/pAfc/ECq8JP6cwnZL/jQgU355KWZKtF8zM9az1p0Q9hIWi9cQygVo6Auw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-agent-core@0.57.1':
|
||||
resolution: {integrity: sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-ai@0.55.3':
|
||||
resolution: {integrity: sha512-f9jWoDzJR9Wy/H8JPMbjoM4WvVUeFZ65QdYA9UHIfoOopDfwWE8F8JHQOj5mmmILMacXuzsqA3J7MYqNWZRvvQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-ai@0.57.1':
|
||||
resolution: {integrity: sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.55.3':
|
||||
resolution: {integrity: sha512-5SFbB7/BIp/Crjre7UNjUeNfpoU1KSW/i6LXa+ikJTBqI5LukWq2avE5l0v0M8Pg/dt1go2XCLrNFlQJiQDSPQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.57.1':
|
||||
resolution: {integrity: sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==}
|
||||
engines: {node: '>=20.6.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-tui@0.55.3':
|
||||
resolution: {integrity: sha512-Gh4wkYgiSPCJJaB/4wEWSL7Ga8bxSq1Crp1RPRT4vKybE/DG0W/MQr5VJDvktarxtJrD16ixScwE4dzdox/PIA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-tui@0.57.1':
|
||||
resolution: {integrity: sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -1692,9 +1635,6 @@ packages:
|
||||
resolution: {integrity: sha512-570oJr93l1RcCNNaMVpOm+PgQkRgno/F65nH1aCWLIKLnw0o7iPoj+8Z5b7mnLMidg9lldVSCcf0dBxqTGE1/w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mistralai/mistralai@1.10.0':
|
||||
resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==}
|
||||
|
||||
'@mistralai/mistralai@1.14.1':
|
||||
resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==}
|
||||
|
||||
@@ -3198,93 +3138,6 @@ packages:
|
||||
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.9':
|
||||
resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.9':
|
||||
resolution: {integrity: sha512-OE16OZjv7F/JrD7Mzw5eL2gY2vXRPC8S7ZrmkcMyz/sHHJsGHlT+L7X5s56Bec1YDTVmzAsH4UBuvVBoXuIWEQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.9':
|
||||
resolution: {integrity: sha512-z7oORvAPExikFkH6tvHhbUdZd77MYZp9VqbCpKEiI+sisWFVXgHde7F7iH3G4Bz6gUYJfgvKhWXiDRc+0SC4dg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.9':
|
||||
resolution: {integrity: sha512-f1LzGyRGlM414KpXml3OgWVSd7CgylcdYaFj/zDBb8bvWjxyvsI9iMeuPfe/cduloxRj8dELde/yCDZtFR6PdQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.9':
|
||||
resolution: {integrity: sha512-k6p3JY2b8rD6j0V9Ql7kBUMR4eJdcpriNwiHltLzmtGuz/nK5RGQdkEP68gTLc+Uj3xs5Cy0jRKmv2xJQBR4sA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.9':
|
||||
resolution: {integrity: sha512-xDaAFUC/1+n/YayNwKsqKOBMuW0KI6F0SjgWU+krYTQTVmAKNjOM80IjemrVoqTpBOxBsT80zEtct2wj11CE3Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.9':
|
||||
resolution: {integrity: sha512-t1VxFBzWExPNpsNY/9oStdAAuHqFvwZvIO2YPYyVNstxfi2KmAbHMweHUW7xb2ppXuhVQZ4VGmmeXiXcXqhPBw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.9':
|
||||
resolution: {integrity: sha512-Xvlr+nBPzuFV4PXHufddlt08JsEyu0p8mX2DpqdPxdpysYIH4I8V86yJiS4tk04a6pLBDd8IxTbBwvXJKqd/LQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.9':
|
||||
resolution: {integrity: sha512-6Uunc/NxiEkg1reroAKZAGfOtjl1CGa7hfTTVClb2f+DiA8ZRQWBh+3lgkq/0IeL262B4F14X8QRv5Bsv128qw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.9':
|
||||
resolution: {integrity: sha512-fFQ/n3aWt1lXhxSdy+Ge3gi5bR3VETMVsWhH0gwBALUKrbo3ZzgSktm4lNrXE9i0ncMz/CDpZ5i0wt/N3XphEQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.9':
|
||||
resolution: {integrity: sha512-xWvzej8YCVlUvzlpmqJMIf0XmLlHqulKZ2e7WNe2TxQmsK+o0zTZqiQYs2MwaEbrNXBhYlHDkdpuwoXkJdscNQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.9':
|
||||
resolution: {integrity: sha512-sTqry/DfltX2OdW1CTLKa3dFYN5FloAEb2yhGsY1i5+Bms6OhwByXfALvyMHYVo61Th2+sD+9BJpQffHFKDA3w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.9':
|
||||
resolution: {integrity: sha512-twD3LwlkGnSwphsCtpGb5ztpBIWEvGdc0iujoVkdzZ6nJiq5p8iaLjJMO4hBm9h3s28fc+1Qd7AMVnagiOasnA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.9':
|
||||
resolution: {integrity: sha512-eMnXbv4GoTngWYY538i/qHz2BS+RgSXFsvKltPzKqnqzPzhQZIY7TemEJn3D5yWGfW4qHve9u23rz93FQqnQMA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey@0.1.9':
|
||||
resolution: {integrity: sha512-vNZk5y+IsxjwzTAXikvzz5pqMLb35YytC64nVF2MAFVhjpXu9ITOKUriZ0JG/llwzCAi56jb5x0cXDRIyE2A2A==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -4210,9 +4063,6 @@ packages:
|
||||
discord-api-types@0.38.37:
|
||||
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
|
||||
|
||||
discord-api-types@0.38.40:
|
||||
resolution: {integrity: sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==}
|
||||
|
||||
discord-api-types@0.38.41:
|
||||
resolution: {integrity: sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==}
|
||||
|
||||
@@ -4614,10 +4464,6 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
grammy@1.41.0:
|
||||
resolution: {integrity: sha512-CAAu74SLT+/QCg40FBhUuYJalVsxxCN3D0c31TzhFBsWWTdXrMXYjGsKngBdfvN6hQ/VzHczluj/ugZVetFNCQ==}
|
||||
engines: {node: ^12.20.0 || >=14.13.1}
|
||||
|
||||
grammy@1.41.1:
|
||||
resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==}
|
||||
engines: {node: ^12.20.0 || >=14.13.1}
|
||||
@@ -5466,18 +5312,6 @@ packages:
|
||||
oniguruma-to-es@4.3.4:
|
||||
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
|
||||
|
||||
openai@6.10.0:
|
||||
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openai@6.26.0:
|
||||
resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
|
||||
hasBin: true
|
||||
@@ -5502,8 +5336,8 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openclaw@2026.3.2:
|
||||
resolution: {integrity: sha512-Gkqx24m7PF1DUXPI968DuC9n52lTZ5hI3X5PIi0HosC7J7d6RLkgVppj1mxvgiQAWMp41E41elvoi/h4KBjFcQ==}
|
||||
openclaw@2026.3.8:
|
||||
resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -6746,9 +6580,6 @@ packages:
|
||||
zod@3.25.75:
|
||||
resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
@@ -6818,58 +6649,6 @@ snapshots:
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-bedrock-runtime@3.1000.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.973.15
|
||||
'@aws-sdk/credential-provider-node': 3.972.14
|
||||
'@aws-sdk/eventstream-handler-node': 3.972.9
|
||||
'@aws-sdk/middleware-eventstream': 3.972.6
|
||||
'@aws-sdk/middleware-host-header': 3.972.6
|
||||
'@aws-sdk/middleware-logger': 3.972.6
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.6
|
||||
'@aws-sdk/middleware-user-agent': 3.972.15
|
||||
'@aws-sdk/middleware-websocket': 3.972.10
|
||||
'@aws-sdk/region-config-resolver': 3.972.6
|
||||
'@aws-sdk/token-providers': 3.1000.0
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@aws-sdk/util-endpoints': 3.996.3
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.6
|
||||
'@aws-sdk/util-user-agent-node': 3.973.0
|
||||
'@smithy/config-resolver': 4.4.9
|
||||
'@smithy/core': 3.23.6
|
||||
'@smithy/eventstream-serde-browser': 4.2.10
|
||||
'@smithy/eventstream-serde-config-resolver': 4.3.10
|
||||
'@smithy/eventstream-serde-node': 4.2.10
|
||||
'@smithy/fetch-http-handler': 5.3.11
|
||||
'@smithy/hash-node': 4.2.10
|
||||
'@smithy/invalid-dependency': 4.2.10
|
||||
'@smithy/middleware-content-length': 4.2.10
|
||||
'@smithy/middleware-endpoint': 4.4.20
|
||||
'@smithy/middleware-retry': 4.4.37
|
||||
'@smithy/middleware-serde': 4.2.11
|
||||
'@smithy/middleware-stack': 4.2.10
|
||||
'@smithy/node-config-provider': 4.3.10
|
||||
'@smithy/node-http-handler': 4.4.12
|
||||
'@smithy/protocol-http': 5.3.10
|
||||
'@smithy/smithy-client': 4.12.0
|
||||
'@smithy/types': 4.13.0
|
||||
'@smithy/url-parser': 4.2.10
|
||||
'@smithy/util-base64': 4.3.1
|
||||
'@smithy/util-body-length-browser': 4.2.1
|
||||
'@smithy/util-body-length-node': 4.2.2
|
||||
'@smithy/util-defaults-mode-browser': 4.3.36
|
||||
'@smithy/util-defaults-mode-node': 4.2.39
|
||||
'@smithy/util-endpoints': 3.3.1
|
||||
'@smithy/util-middleware': 4.2.10
|
||||
'@smithy/util-retry': 4.2.10
|
||||
'@smithy/util-stream': 4.5.15
|
||||
'@smithy/util-utf8': 4.2.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-bedrock-runtime@3.1004.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
@@ -6922,51 +6701,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-bedrock@3.1000.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.973.15
|
||||
'@aws-sdk/credential-provider-node': 3.972.14
|
||||
'@aws-sdk/middleware-host-header': 3.972.6
|
||||
'@aws-sdk/middleware-logger': 3.972.6
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.6
|
||||
'@aws-sdk/middleware-user-agent': 3.972.15
|
||||
'@aws-sdk/region-config-resolver': 3.972.6
|
||||
'@aws-sdk/token-providers': 3.1000.0
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@aws-sdk/util-endpoints': 3.996.3
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.6
|
||||
'@aws-sdk/util-user-agent-node': 3.973.0
|
||||
'@smithy/config-resolver': 4.4.9
|
||||
'@smithy/core': 3.23.6
|
||||
'@smithy/fetch-http-handler': 5.3.11
|
||||
'@smithy/hash-node': 4.2.10
|
||||
'@smithy/invalid-dependency': 4.2.10
|
||||
'@smithy/middleware-content-length': 4.2.10
|
||||
'@smithy/middleware-endpoint': 4.4.20
|
||||
'@smithy/middleware-retry': 4.4.37
|
||||
'@smithy/middleware-serde': 4.2.11
|
||||
'@smithy/middleware-stack': 4.2.10
|
||||
'@smithy/node-config-provider': 4.3.10
|
||||
'@smithy/node-http-handler': 4.4.12
|
||||
'@smithy/protocol-http': 5.3.10
|
||||
'@smithy/smithy-client': 4.12.0
|
||||
'@smithy/types': 4.13.0
|
||||
'@smithy/url-parser': 4.2.10
|
||||
'@smithy/util-base64': 4.3.1
|
||||
'@smithy/util-body-length-browser': 4.2.1
|
||||
'@smithy/util-body-length-node': 4.2.2
|
||||
'@smithy/util-defaults-mode-browser': 4.3.36
|
||||
'@smithy/util-defaults-mode-node': 4.2.39
|
||||
'@smithy/util-endpoints': 3.3.1
|
||||
'@smithy/util-middleware': 4.2.10
|
||||
'@smithy/util-retry': 4.2.10
|
||||
'@smithy/util-utf8': 4.2.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-bedrock@3.1004.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
@@ -7324,13 +7058,6 @@ snapshots:
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/eventstream-handler-node@3.972.9':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@smithy/eventstream-codec': 4.2.10
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.972.6':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.4
|
||||
@@ -7341,13 +7068,6 @@ snapshots:
|
||||
'@smithy/util-config-provider': 4.2.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-eventstream@3.972.6':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@smithy/protocol-http': 5.3.10
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-eventstream@3.972.7':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.5
|
||||
@@ -7471,21 +7191,6 @@ snapshots:
|
||||
'@smithy/util-retry': 4.2.11
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-websocket@3.972.10':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@aws-sdk/util-format-url': 3.972.6
|
||||
'@smithy/eventstream-codec': 4.2.10
|
||||
'@smithy/eventstream-serde-browser': 4.2.10
|
||||
'@smithy/fetch-http-handler': 5.3.11
|
||||
'@smithy/protocol-http': 5.3.10
|
||||
'@smithy/signature-v4': 5.3.10
|
||||
'@smithy/types': 4.13.0
|
||||
'@smithy/util-base64': 4.3.1
|
||||
'@smithy/util-hex-encoding': 4.2.1
|
||||
'@smithy/util-utf8': 4.2.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-websocket@3.972.12':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.5
|
||||
@@ -7623,18 +7328,6 @@ snapshots:
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1000.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.15
|
||||
'@aws-sdk/nested-clients': 3.996.3
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@smithy/property-provider': 4.2.10
|
||||
'@smithy/shared-ini-file-loader': 4.4.5
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/token-providers@3.1004.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.18
|
||||
@@ -7858,21 +7551,10 @@ snapshots:
|
||||
hashery: 1.5.0
|
||||
keyv: 5.6.0
|
||||
|
||||
'@clack/core@1.0.1':
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/core@1.1.0':
|
||||
dependencies:
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/prompts@1.0.1':
|
||||
dependencies:
|
||||
'@clack/core': 1.0.1
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/prompts@1.1.0':
|
||||
dependencies:
|
||||
'@clack/core': 1.1.0
|
||||
@@ -8100,17 +7782,6 @@ snapshots:
|
||||
'@eshaz/web-worker@1.2.2':
|
||||
optional: true
|
||||
|
||||
'@google/genai@1.43.0':
|
||||
dependencies:
|
||||
google-auth-library: 10.6.1
|
||||
p-retry: 4.6.2
|
||||
protobufjs: 7.5.4
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@google/genai@1.44.0':
|
||||
dependencies:
|
||||
google-auth-library: 10.6.1
|
||||
@@ -8122,21 +7793,11 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@grammyjs/runner@2.0.3(grammy@1.41.0)':
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
grammy: 1.41.0
|
||||
|
||||
'@grammyjs/runner@2.0.3(grammy@1.41.1)':
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
grammy: 1.41.1
|
||||
|
||||
'@grammyjs/transformer-throttler@1.2.1(grammy@1.41.0)':
|
||||
dependencies:
|
||||
bottleneck: 2.19.5
|
||||
grammy: 1.41.0
|
||||
|
||||
'@grammyjs/transformer-throttler@1.2.1(grammy@1.41.1)':
|
||||
dependencies:
|
||||
bottleneck: 2.19.5
|
||||
@@ -8501,18 +8162,6 @@ snapshots:
|
||||
std-env: 3.10.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
'@mariozechner/pi-agent-core@0.55.3(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
@@ -8525,30 +8174,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.55.3(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.1000.0
|
||||
'@google/genai': 1.43.0
|
||||
'@mistralai/mistralai': 1.10.0
|
||||
'@sinclair/typebox': 0.34.48
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
chalk: 5.6.2
|
||||
openai: 6.10.0(ws@8.19.0)(zod@4.3.6)
|
||||
partial-json: 0.1.7
|
||||
proxy-agent: 6.5.0
|
||||
undici: 7.22.0
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.57.1(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||
@@ -8573,37 +8198,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.55.3(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
'@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.55.3
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
diff: 8.0.3
|
||||
extract-zip: 2.0.1
|
||||
file-type: 21.3.0
|
||||
glob: 13.0.6
|
||||
hosted-git-info: 9.0.2
|
||||
ignore: 7.0.5
|
||||
marked: 15.0.12
|
||||
minimatch: 10.2.4
|
||||
proper-lockfile: 4.1.2
|
||||
strip-ansi: 7.2.0
|
||||
yaml: 2.8.2
|
||||
optionalDependencies:
|
||||
'@mariozechner/clipboard': 0.3.2
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
@@ -8636,15 +8230,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-tui@0.55.3':
|
||||
dependencies:
|
||||
'@types/mime-types': 2.1.4
|
||||
chalk: 5.6.2
|
||||
get-east-asian-width: 1.5.0
|
||||
koffi: 2.15.1
|
||||
marked: 15.0.12
|
||||
mime-types: 3.0.2
|
||||
|
||||
'@mariozechner/pi-tui@0.57.1':
|
||||
dependencies:
|
||||
'@types/mime-types': 2.1.4
|
||||
@@ -8684,11 +8269,6 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@mistralai/mistralai@1.10.0':
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
|
||||
'@mistralai/mistralai@1.14.1':
|
||||
dependencies:
|
||||
ws: 8.19.0
|
||||
@@ -10291,67 +9871,6 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.9':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.1
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey@0.1.9':
|
||||
optionalDependencies:
|
||||
'@snazzah/davey-android-arm-eabi': 0.1.9
|
||||
'@snazzah/davey-android-arm64': 0.1.9
|
||||
'@snazzah/davey-darwin-arm64': 0.1.9
|
||||
'@snazzah/davey-darwin-x64': 0.1.9
|
||||
'@snazzah/davey-freebsd-x64': 0.1.9
|
||||
'@snazzah/davey-linux-arm-gnueabihf': 0.1.9
|
||||
'@snazzah/davey-linux-arm64-gnu': 0.1.9
|
||||
'@snazzah/davey-linux-arm64-musl': 0.1.9
|
||||
'@snazzah/davey-linux-x64-gnu': 0.1.9
|
||||
'@snazzah/davey-linux-x64-musl': 0.1.9
|
||||
'@snazzah/davey-wasm32-wasi': 0.1.9
|
||||
'@snazzah/davey-win32-arm64-msvc': 0.1.9
|
||||
'@snazzah/davey-win32-ia32-msvc': 0.1.9
|
||||
'@snazzah/davey-win32-x64-msvc': 0.1.9
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.19':
|
||||
@@ -11364,8 +10883,6 @@ snapshots:
|
||||
|
||||
discord-api-types@0.38.37: {}
|
||||
|
||||
discord-api-types@0.38.40: {}
|
||||
|
||||
discord-api-types@0.38.41: {}
|
||||
|
||||
doctypes@1.1.0: {}
|
||||
@@ -11876,16 +11393,6 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
grammy@1.41.0:
|
||||
dependencies:
|
||||
'@grammyjs/types': 3.25.0
|
||||
abort-controller: 3.0.0
|
||||
debug: 4.4.3
|
||||
node-fetch: 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
grammy@1.41.1:
|
||||
dependencies:
|
||||
'@grammyjs/types': 3.25.0
|
||||
@@ -12287,7 +11794,8 @@ snapshots:
|
||||
|
||||
klona@2.0.6: {}
|
||||
|
||||
koffi@2.15.1: {}
|
||||
koffi@2.15.1:
|
||||
optional: true
|
||||
|
||||
leac@0.6.0: {}
|
||||
|
||||
@@ -12806,11 +12314,6 @@ snapshots:
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
openai@6.10.0(ws@8.19.0)(zod@4.3.6):
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
zod: 4.3.6
|
||||
|
||||
openai@6.26.0(ws@8.19.0)(zod@4.3.6):
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
@@ -12821,29 +12324,28 @@ snapshots:
|
||||
ws: 8.19.0
|
||||
zod: 4.3.6
|
||||
|
||||
openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)):
|
||||
openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.14.1(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock': 3.1000.0
|
||||
'@agentclientprotocol/sdk': 0.15.0(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock': 3.1004.0
|
||||
'@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)
|
||||
'@clack/prompts': 1.0.1
|
||||
'@clack/prompts': 1.1.0
|
||||
'@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
|
||||
'@grammyjs/runner': 2.0.3(grammy@1.41.0)
|
||||
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.0)
|
||||
'@grammyjs/runner': 2.0.3(grammy@1.41.1)
|
||||
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1)
|
||||
'@homebridge/ciao': 1.3.5
|
||||
'@larksuiteoapi/node-sdk': 1.59.0
|
||||
'@line/bot-sdk': 10.6.0
|
||||
'@lydell/node-pty': 1.2.0-beta.3
|
||||
'@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.55.3
|
||||
'@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.57.1
|
||||
'@mozilla/readability': 0.6.0
|
||||
'@napi-rs/canvas': 0.1.95
|
||||
'@sinclair/typebox': 0.34.48
|
||||
'@slack/bolt': 4.6.0(@types/express@5.0.6)
|
||||
'@slack/web-api': 7.14.1
|
||||
'@snazzah/davey': 0.1.9
|
||||
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
||||
ajv: 8.18.0
|
||||
chalk: 5.6.2
|
||||
@@ -12851,13 +12353,11 @@ snapshots:
|
||||
cli-highlight: 2.1.11
|
||||
commander: 14.0.3
|
||||
croner: 10.0.1
|
||||
discord-api-types: 0.38.40
|
||||
discord-api-types: 0.38.41
|
||||
dotenv: 17.3.1
|
||||
express: 5.2.1
|
||||
file-type: 21.3.0
|
||||
gaxios: 7.1.3
|
||||
google-auth-library: 10.6.1
|
||||
grammy: 1.41.0
|
||||
grammy: 1.41.1
|
||||
https-proxy-agent: 7.0.6
|
||||
ipaddr.js: 2.3.0
|
||||
jiti: 2.6.1
|
||||
@@ -12866,7 +12366,6 @@ snapshots:
|
||||
linkedom: 0.18.12
|
||||
long: 5.3.2
|
||||
markdown-it: 14.1.1
|
||||
node-domexception: '@nolyfill/domexception@1.0.28'
|
||||
node-edge-tts: 1.2.10
|
||||
node-llama-cpp: 3.16.2(typescript@5.9.3)
|
||||
opusscript: 0.1.1
|
||||
@@ -12876,16 +12375,14 @@ snapshots:
|
||||
qrcode-terminal: 0.12.0
|
||||
sharp: 0.34.5
|
||||
sqlite-vec: 0.1.7-alpha.2
|
||||
strip-ansi: 7.2.0
|
||||
tar: 7.5.10
|
||||
tslog: 4.10.2
|
||||
undici: 7.22.0
|
||||
ws: 8.19.0
|
||||
yaml: 2.8.2
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
'@discordjs/opus': 0.10.0
|
||||
transitivePeerDependencies:
|
||||
- '@discordjs/opus'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- '@types/express'
|
||||
- audio-decode
|
||||
@@ -14298,18 +13795,12 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
zod@3.25.75: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
61
src/agents/bash-tools.exec-approval-followup.ts
Normal file
61
src/agents/bash-tools.exec-approval-followup.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
type ExecApprovalFollowupParams = {
|
||||
approvalId: string;
|
||||
sessionKey?: string;
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
resultText: string;
|
||||
};
|
||||
|
||||
export function buildExecApprovalFollowupPrompt(resultText: string): string {
|
||||
return [
|
||||
"An async command the user already approved has completed.",
|
||||
"Do not run the command again.",
|
||||
"",
|
||||
"Exact completion details:",
|
||||
resultText.trim(),
|
||||
"",
|
||||
"Reply to the user in a helpful way.",
|
||||
"If it succeeded, share the relevant output.",
|
||||
"If it failed, explain what went wrong.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function sendExecApprovalFollowup(
|
||||
params: ExecApprovalFollowupParams,
|
||||
): Promise<boolean> {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
const resultText = params.resultText.trim();
|
||||
if (!sessionKey || !resultText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const channel = params.turnSourceChannel?.trim();
|
||||
const to = params.turnSourceTo?.trim();
|
||||
const threadId =
|
||||
params.turnSourceThreadId != null && params.turnSourceThreadId !== ""
|
||||
? String(params.turnSourceThreadId)
|
||||
: undefined;
|
||||
|
||||
await callGatewayTool(
|
||||
"agent",
|
||||
{ timeoutMs: 60_000 },
|
||||
{
|
||||
sessionKey,
|
||||
message: buildExecApprovalFollowupPrompt(resultText),
|
||||
deliver: true,
|
||||
bestEffortDeliver: true,
|
||||
channel: channel && to ? channel : undefined,
|
||||
to: channel && to ? to : undefined,
|
||||
accountId: channel && to ? params.turnSourceAccountId?.trim() || undefined : undefined,
|
||||
threadId: channel && to ? threadId : undefined,
|
||||
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
|
||||
import {
|
||||
hasConfiguredExecApprovalDmRoute,
|
||||
resolveExecApprovalInitiatingSurfaceState,
|
||||
} from "../infra/exec-approval-surface.js";
|
||||
import {
|
||||
addAllowlistEntry,
|
||||
type ExecAsk,
|
||||
@@ -13,6 +19,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { markBackgrounded, tail } from "./bash-process-registry.js";
|
||||
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
@@ -25,9 +32,9 @@ import {
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import {
|
||||
buildApprovalPendingMessage,
|
||||
DEFAULT_NOTIFY_TAIL_CHARS,
|
||||
createApprovalSlug,
|
||||
emitExecSystemEvent,
|
||||
normalizeNotifyOutput,
|
||||
runExecProcess,
|
||||
} from "./bash-tools.exec-runtime.js";
|
||||
@@ -141,8 +148,6 @@ export async function processGatewayAllowlist(
|
||||
const {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
contextKey,
|
||||
noticeSeconds,
|
||||
warningText,
|
||||
expiresAtMs: defaultExpiresAtMs,
|
||||
preResolvedDecision: defaultPreResolvedDecision,
|
||||
@@ -174,19 +179,37 @@ export async function processGatewayAllowlist(
|
||||
});
|
||||
expiresAtMs = registration.expiresAtMs;
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
|
||||
channel: params.turnSourceChannel,
|
||||
accountId: params.turnSourceAccountId,
|
||||
});
|
||||
const cfg = loadConfig();
|
||||
const sentApproverDms =
|
||||
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
|
||||
hasConfiguredExecApprovalDmRoute(cfg);
|
||||
const unavailableReason =
|
||||
preResolvedDecision === null
|
||||
? "no-approval-route"
|
||||
: initiatingSurface.kind === "disabled"
|
||||
? "initiating-platform-disabled"
|
||||
: initiatingSurface.kind === "unsupported"
|
||||
? "initiating-platform-unsupported"
|
||||
: null;
|
||||
|
||||
void (async () => {
|
||||
const decision = await resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
),
|
||||
void sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
}),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
return;
|
||||
@@ -230,13 +253,15 @@ export async function processGatewayAllowlist(
|
||||
}
|
||||
|
||||
if (deniedReason) {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,32 +287,21 @@ export async function processGatewayAllowlist(
|
||||
timeoutSec: effectiveTimeout,
|
||||
});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
markBackgrounded(run.session);
|
||||
|
||||
let runningTimer: NodeJS.Timeout | null = null;
|
||||
if (params.approvalRunningNoticeMs > 0) {
|
||||
runningTimer = setTimeout(() => {
|
||||
emitExecSystemEvent(
|
||||
`Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
);
|
||||
}, params.approvalRunningNoticeMs);
|
||||
}
|
||||
|
||||
const outcome = await run.promise;
|
||||
if (runningTimer) {
|
||||
clearTimeout(runningTimer);
|
||||
}
|
||||
const output = normalizeNotifyOutput(
|
||||
tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
|
||||
);
|
||||
@@ -295,7 +309,15 @@ export async function processGatewayAllowlist(
|
||||
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})`;
|
||||
emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey });
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: summary,
|
||||
}).catch(() => {});
|
||||
})();
|
||||
|
||||
return {
|
||||
@@ -304,19 +326,45 @@ export async function processGatewayAllowlist(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warningText}Approval required (id ${approvalSlug}). ` +
|
||||
"Approve to run; updates will arrive after completion.",
|
||||
unavailableReason !== null
|
||||
? (buildExecApprovalUnavailableReplyPayload({
|
||||
warningText,
|
||||
reason: unavailableReason,
|
||||
channelLabel: initiatingSurface.channelLabel,
|
||||
sentApproverDms,
|
||||
}).text ?? "")
|
||||
: buildApprovalPendingMessage({
|
||||
warningText,
|
||||
approvalSlug,
|
||||
approvalId,
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
host: "gateway",
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "approval-pending",
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
host: "gateway",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
},
|
||||
details:
|
||||
unavailableReason !== null
|
||||
? ({
|
||||
status: "approval-unavailable",
|
||||
reason: unavailableReason,
|
||||
channelLabel: initiatingSurface.channelLabel,
|
||||
sentApproverDms,
|
||||
host: "gateway",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
warningText,
|
||||
} satisfies ExecToolDetails)
|
||||
: ({
|
||||
status: "approval-pending",
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
host: "gateway",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
warningText,
|
||||
} satisfies ExecToolDetails),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
|
||||
import {
|
||||
hasConfiguredExecApprovalDmRoute,
|
||||
resolveExecApprovalInitiatingSurfaceState,
|
||||
} from "../infra/exec-approval-surface.js";
|
||||
import {
|
||||
type ExecApprovalsFile,
|
||||
type ExecAsk,
|
||||
@@ -12,6 +18,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
import { buildNodeShellCommand } from "../infra/node-shell.js";
|
||||
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
@@ -23,7 +30,12 @@ import {
|
||||
resolveApprovalDecisionOrUndefined,
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
|
||||
import {
|
||||
buildApprovalPendingMessage,
|
||||
DEFAULT_NOTIFY_TAIL_CHARS,
|
||||
createApprovalSlug,
|
||||
normalizeNotifyOutput,
|
||||
} from "./bash-tools.exec-runtime.js";
|
||||
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
||||
@@ -187,6 +199,7 @@ export async function executeNodeHostCommand(
|
||||
approvedByAsk: boolean,
|
||||
approvalDecision: "allow-once" | "allow-always" | null,
|
||||
runId?: string,
|
||||
suppressNotifyOnExit?: boolean,
|
||||
) =>
|
||||
({
|
||||
nodeId,
|
||||
@@ -202,6 +215,7 @@ export async function executeNodeHostCommand(
|
||||
approved: approvedByAsk,
|
||||
approvalDecision: approvalDecision ?? undefined,
|
||||
runId: runId ?? undefined,
|
||||
suppressNotifyOnExit: suppressNotifyOnExit === true ? true : undefined,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
}) satisfies Record<string, unknown>;
|
||||
@@ -210,8 +224,6 @@ export async function executeNodeHostCommand(
|
||||
const {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
contextKey,
|
||||
noticeSeconds,
|
||||
warningText,
|
||||
expiresAtMs: defaultExpiresAtMs,
|
||||
preResolvedDecision: defaultPreResolvedDecision,
|
||||
@@ -243,16 +255,37 @@ export async function executeNodeHostCommand(
|
||||
});
|
||||
expiresAtMs = registration.expiresAtMs;
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
|
||||
channel: params.turnSourceChannel,
|
||||
accountId: params.turnSourceAccountId,
|
||||
});
|
||||
const cfg = loadConfig();
|
||||
const sentApproverDms =
|
||||
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
|
||||
hasConfiguredExecApprovalDmRoute(cfg);
|
||||
const unavailableReason =
|
||||
preResolvedDecision === null
|
||||
? "no-approval-route"
|
||||
: initiatingSurface.kind === "disabled"
|
||||
? "initiating-platform-disabled"
|
||||
: initiatingSurface.kind === "unsupported"
|
||||
? "initiating-platform-unsupported"
|
||||
: null;
|
||||
|
||||
void (async () => {
|
||||
const decision = await resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
),
|
||||
void sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
}),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
return;
|
||||
@@ -278,44 +311,67 @@ export async function executeNodeHostCommand(
|
||||
}
|
||||
|
||||
if (deniedReason) {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let runningTimer: NodeJS.Timeout | null = null;
|
||||
if (params.approvalRunningNoticeMs > 0) {
|
||||
runningTimer = setTimeout(() => {
|
||||
emitExecSystemEvent(
|
||||
`Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
);
|
||||
}, params.approvalRunningNoticeMs);
|
||||
}
|
||||
|
||||
try {
|
||||
await callGatewayTool(
|
||||
const raw = await callGatewayTool<{
|
||||
payload?: {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string | null;
|
||||
exitCode?: number | null;
|
||||
timedOut?: boolean;
|
||||
};
|
||||
}>(
|
||||
"node.invoke",
|
||||
{ timeoutMs: invokeTimeoutMs },
|
||||
buildInvokeParams(approvedByAsk, approvalDecision, approvalId),
|
||||
buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true),
|
||||
);
|
||||
const payload =
|
||||
raw?.payload && typeof raw.payload === "object"
|
||||
? (raw.payload as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string | null;
|
||||
exitCode?: number | null;
|
||||
timedOut?: boolean;
|
||||
})
|
||||
: {};
|
||||
const combined = [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n");
|
||||
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
|
||||
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
|
||||
const summary = output
|
||||
? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}`
|
||||
: `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`;
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: summary,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (runningTimer) {
|
||||
clearTimeout(runningTimer);
|
||||
}
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -324,20 +380,48 @@ export async function executeNodeHostCommand(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warningText}Approval required (id ${approvalSlug}). ` +
|
||||
"Approve to run; updates will arrive after completion.",
|
||||
unavailableReason !== null
|
||||
? (buildExecApprovalUnavailableReplyPayload({
|
||||
warningText,
|
||||
reason: unavailableReason,
|
||||
channelLabel: initiatingSurface.channelLabel,
|
||||
sentApproverDms,
|
||||
}).text ?? "")
|
||||
: buildApprovalPendingMessage({
|
||||
warningText,
|
||||
approvalSlug,
|
||||
approvalId,
|
||||
command: prepared.cmdText,
|
||||
cwd: runCwd,
|
||||
host: "node",
|
||||
nodeId,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "approval-pending",
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
host: "node",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
nodeId,
|
||||
},
|
||||
details:
|
||||
unavailableReason !== null
|
||||
? ({
|
||||
status: "approval-unavailable",
|
||||
reason: unavailableReason,
|
||||
channelLabel: initiatingSurface.channelLabel,
|
||||
sentApproverDms,
|
||||
host: "node",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
nodeId,
|
||||
warningText,
|
||||
} satisfies ExecToolDetails)
|
||||
: ({
|
||||
status: "approval-pending",
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
host: "node",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
nodeId,
|
||||
warningText,
|
||||
} satisfies ExecToolDetails),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,40 @@ export function createApprovalSlug(id: string) {
|
||||
return id.slice(0, APPROVAL_SLUG_LENGTH);
|
||||
}
|
||||
|
||||
export function buildApprovalPendingMessage(params: {
|
||||
warningText?: string;
|
||||
approvalSlug: string;
|
||||
approvalId: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
host: "gateway" | "node";
|
||||
nodeId?: string;
|
||||
}) {
|
||||
let fence = "```";
|
||||
while (params.command.includes(fence)) {
|
||||
fence += "`";
|
||||
}
|
||||
const commandBlock = `${fence}sh\n${params.command}\n${fence}`;
|
||||
const lines: string[] = [];
|
||||
const warningText = params.warningText?.trim();
|
||||
if (warningText) {
|
||||
lines.push(warningText, "");
|
||||
}
|
||||
lines.push(`Approval required (id ${params.approvalSlug}, full ${params.approvalId}).`);
|
||||
lines.push(`Host: ${params.host}`);
|
||||
if (params.nodeId) {
|
||||
lines.push(`Node: ${params.nodeId}`);
|
||||
}
|
||||
lines.push(`CWD: ${params.cwd}`);
|
||||
lines.push("Command:");
|
||||
lines.push(commandBlock);
|
||||
lines.push("Mode: foreground (interactive approvals available).");
|
||||
lines.push("Background mode requires pre-approved policy (allow-always or ask=off).");
|
||||
lines.push(`Reply with: /approve ${params.approvalSlug} allow-once|allow-always|deny`);
|
||||
lines.push("If the short code is ambiguous, use the full id in /approve.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function resolveApprovalRunningNoticeMs(value?: number) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return DEFAULT_APPROVAL_RUNNING_NOTICE_MS;
|
||||
|
||||
@@ -60,4 +60,19 @@ export type ExecToolDetails =
|
||||
command: string;
|
||||
cwd?: string;
|
||||
nodeId?: string;
|
||||
warningText?: string;
|
||||
}
|
||||
| {
|
||||
status: "approval-unavailable";
|
||||
reason:
|
||||
| "initiating-platform-disabled"
|
||||
| "initiating-platform-unsupported"
|
||||
| "no-approval-route";
|
||||
channelLabel?: string;
|
||||
sentApproverDms?: boolean;
|
||||
host: ExecHost;
|
||||
command: string;
|
||||
cwd?: string;
|
||||
nodeId?: string;
|
||||
warningText?: string;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearConfigCache } from "../config/config.js";
|
||||
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
@@ -63,6 +64,7 @@ describe("exec approvals", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
clearConfigCache();
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
@@ -77,6 +79,7 @@ describe("exec approvals", () => {
|
||||
|
||||
it("reuses approval id as the node runId", async () => {
|
||||
let invokeParams: unknown;
|
||||
let agentParams: unknown;
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
if (method === "exec.approval.request") {
|
||||
@@ -85,6 +88,10 @@ describe("exec approvals", () => {
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
if (method === "agent") {
|
||||
agentParams = params;
|
||||
return { status: "ok" };
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
const invoke = params as { command?: string };
|
||||
if (invoke.command === "system.run.prepare") {
|
||||
@@ -102,11 +109,24 @@ describe("exec approvals", () => {
|
||||
host: "node",
|
||||
ask: "always",
|
||||
approvalRunningNoticeMs: 0,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1", { command: "ls -la" });
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
const approvalId = (result.details as { approvalId: string }).approvalId;
|
||||
const details = result.details as { approvalId: string; approvalSlug: string };
|
||||
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(pendingText).toContain(
|
||||
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
|
||||
);
|
||||
expect(pendingText).toContain(`full ${details.approvalId}`);
|
||||
expect(pendingText).toContain("Host: node");
|
||||
expect(pendingText).toContain("Node: node-1");
|
||||
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
|
||||
expect(pendingText).toContain("Command:\n```sh\nls -la\n```");
|
||||
expect(pendingText).toContain("Mode: foreground (interactive approvals available).");
|
||||
expect(pendingText).toContain("Background mode requires pre-approved policy");
|
||||
const approvalId = details.approvalId;
|
||||
|
||||
await expect
|
||||
.poll(() => (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId, {
|
||||
@@ -114,6 +134,12 @@ describe("exec approvals", () => {
|
||||
interval: 20,
|
||||
})
|
||||
.toBe(approvalId);
|
||||
expect(
|
||||
(invokeParams as { params?: { suppressNotifyOnExit?: boolean } } | undefined)?.params,
|
||||
).toMatchObject({
|
||||
suppressNotifyOnExit: true,
|
||||
});
|
||||
await expect.poll(() => agentParams, { timeout: 2_000, interval: 20 }).toBeTruthy();
|
||||
});
|
||||
|
||||
it("skips approval when node allowlist is satisfied", async () => {
|
||||
@@ -287,11 +313,181 @@ describe("exec approvals", () => {
|
||||
|
||||
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
const details = result.details as { approvalId: string; approvalSlug: string };
|
||||
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(pendingText).toContain(
|
||||
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
|
||||
);
|
||||
expect(pendingText).toContain(`full ${details.approvalId}`);
|
||||
expect(pendingText).toContain("Host: gateway");
|
||||
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
|
||||
expect(pendingText).toContain("Command:\n```sh\necho ok\n```");
|
||||
await approvalSeen;
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
expect(calls).toContain("exec.approval.waitDecision");
|
||||
});
|
||||
|
||||
it("starts a direct agent follow-up after approved gateway exec completes", async () => {
|
||||
const agentCalls: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: (params as { id?: string })?.id };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
if (method === "agent") {
|
||||
agentCalls.push(params as Record<string, unknown>);
|
||||
return { status: "ok" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
ask: "always",
|
||||
approvalRunningNoticeMs: 0,
|
||||
sessionKey: "agent:main:main",
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-gw-followup", {
|
||||
command: "echo ok",
|
||||
workdir: process.cwd(),
|
||||
gatewayUrl: undefined,
|
||||
gatewayToken: undefined,
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 20 }).toBe(1);
|
||||
expect(agentCalls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
deliver: true,
|
||||
idempotencyKey: expect.stringContaining("exec-approval-followup:"),
|
||||
}),
|
||||
);
|
||||
expect(typeof agentCalls[0]?.message).toBe("string");
|
||||
expect(agentCalls[0]?.message).toContain(
|
||||
"An async command the user already approved has completed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a separate approval for each elevated command after allow-once", async () => {
|
||||
const requestCommands: string[] = [];
|
||||
const requestIds: string[] = [];
|
||||
const waitIds: string[] = [];
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
if (method === "exec.approval.request") {
|
||||
const request = params as { id?: string; command?: string };
|
||||
if (typeof request.command === "string") {
|
||||
requestCommands.push(request.command);
|
||||
}
|
||||
if (typeof request.id === "string") {
|
||||
requestIds.push(request.id);
|
||||
}
|
||||
return { status: "accepted", id: request.id };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
const wait = params as { id?: string };
|
||||
if (typeof wait.id === "string") {
|
||||
waitIds.push(wait.id);
|
||||
}
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
approvalRunningNoticeMs: 0,
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
||||
});
|
||||
|
||||
const first = await tool.execute("call-seq-1", {
|
||||
command: "npm view diver --json",
|
||||
elevated: true,
|
||||
});
|
||||
const second = await tool.execute("call-seq-2", {
|
||||
command: "brew outdated",
|
||||
elevated: true,
|
||||
});
|
||||
|
||||
expect(first.details.status).toBe("approval-pending");
|
||||
expect(second.details.status).toBe("approval-pending");
|
||||
expect(requestCommands).toEqual(["npm view diver --json", "brew outdated"]);
|
||||
expect(requestIds).toHaveLength(2);
|
||||
expect(requestIds[0]).not.toBe(requestIds[1]);
|
||||
expect(waitIds).toEqual(requestIds);
|
||||
});
|
||||
|
||||
it("shows full chained gateway commands in approval-pending message", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
calls.push(method);
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: (params as { id?: string })?.id };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: "deny" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
approvalRunningNoticeMs: 0,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-chain-gateway", {
|
||||
command: "npm view diver --json | jq .name && brew outdated",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(pendingText).toContain(
|
||||
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
|
||||
);
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it("shows full chained node commands in approval-pending message", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
calls.push(method);
|
||||
if (method === "node.invoke") {
|
||||
const invoke = params as { command?: string };
|
||||
if (invoke.command === "system.run.prepare") {
|
||||
return buildPreparedSystemRunPayload(params);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "node",
|
||||
ask: "always",
|
||||
security: "full",
|
||||
approvalRunningNoticeMs: 0,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-chain-node", {
|
||||
command: "npm view diver --json | jq .name && brew outdated",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(pendingText).toContain(
|
||||
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
|
||||
);
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it("waits for approval registration before returning approval-pending", async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveRegistration: ((value: unknown) => void) | undefined;
|
||||
@@ -354,6 +550,111 @@ describe("exec approvals", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an unavailable approval message instead of a local /approve prompt when discord exec approvals are disabled", async () => {
|
||||
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
execApprovals: { enabled: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: "approval-id" };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: null };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
ask: "always",
|
||||
approvalRunningNoticeMs: 0,
|
||||
messageProvider: "discord",
|
||||
accountId: "default",
|
||||
currentChannelId: "1234567890",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-unavailable", {
|
||||
command: "npm view diver name version description",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-unavailable");
|
||||
const text = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(text).toContain("chat exec approvals are not enabled on Discord");
|
||||
expect(text).toContain("Web UI or terminal UI");
|
||||
expect(text).not.toContain("/approve");
|
||||
expect(text).not.toContain("npm view diver name version description");
|
||||
expect(text).not.toContain("Pending command:");
|
||||
expect(text).not.toContain("Host:");
|
||||
expect(text).not.toContain("CWD:");
|
||||
});
|
||||
|
||||
it("tells Telegram users that allowed approvers were DMed when Telegram approvals are disabled but Discord DM approvals are enabled", async () => {
|
||||
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
execApprovals: { enabled: false },
|
||||
},
|
||||
discord: {
|
||||
enabled: true,
|
||||
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: "approval-id" };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: null };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
ask: "always",
|
||||
approvalRunningNoticeMs: 0,
|
||||
messageProvider: "telegram",
|
||||
accountId: "default",
|
||||
currentChannelId: "-1003841603622",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-tg-unavailable", {
|
||||
command: "npm view diver name version description",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-unavailable");
|
||||
const text = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(text).toContain("Approval required. I sent the allowed approvers DMs.");
|
||||
expect(text).not.toContain("/approve");
|
||||
expect(text).not.toContain("npm view diver name version description");
|
||||
expect(text).not.toContain("Pending command:");
|
||||
expect(text).not.toContain("Host:");
|
||||
expect(text).not.toContain("CWD:");
|
||||
});
|
||||
|
||||
it("denies node obfuscated command when approval request times out", async () => {
|
||||
vi.mocked(detectCommandObfuscation).mockReturnValue({
|
||||
detected: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolvePluginTools } from "../plugins/tools.js";
|
||||
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js";
|
||||
import type { GatewayMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveSessionAgentId } from "./agent-scope.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
@@ -72,6 +73,7 @@ export function createOpenClawTools(
|
||||
} & SpawnedToolContext,
|
||||
): AnyAgentTool[] {
|
||||
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
|
||||
const runtimeWebTools = getActiveRuntimeWebToolsMetadata();
|
||||
const imageTool = options?.agentDir?.trim()
|
||||
? createImageTool({
|
||||
config: options?.config,
|
||||
@@ -100,10 +102,12 @@ export function createOpenClawTools(
|
||||
const webSearchTool = createWebSearchTool({
|
||||
config: options?.config,
|
||||
sandboxed: options?.sandboxed,
|
||||
runtimeWebSearch: runtimeWebTools?.search,
|
||||
});
|
||||
const webFetchTool = createWebFetchTool({
|
||||
config: options?.config,
|
||||
sandboxed: options?.sandboxed,
|
||||
runtimeFirecrawl: runtimeWebTools?.fetch.firecrawl,
|
||||
});
|
||||
const messageTool = options?.disableMessageTool
|
||||
? null
|
||||
|
||||
135
src/agents/openclaw-tools.web-runtime.test.ts
Normal file
135
src/agents/openclaw-tools.web-runtime.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
activateSecretsRuntimeSnapshot,
|
||||
clearSecretsRuntimeSnapshot,
|
||||
prepareSecretsRuntimeSnapshot,
|
||||
} from "../secrets/runtime.js";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
vi.mock("../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
}));
|
||||
|
||||
function asConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
|
||||
function findTool(name: string, config: OpenClawConfig) {
|
||||
const allTools = createOpenClawTools({ config, sandboxed: true });
|
||||
const tool = allTools.find((candidate) => candidate.name === name);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) {
|
||||
throw new Error(`missing ${name} tool`);
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
|
||||
return {
|
||||
get: (key) => map[key.toLowerCase()] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareAndActivate(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
|
||||
const snapshot = await prepareSecretsRuntimeSnapshot({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
agentDirs: ["/tmp/openclaw-agent-main"],
|
||||
loadAuthStore: () => ({ version: 1, profiles: {} }),
|
||||
});
|
||||
activateSecretsRuntimeSnapshot(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe("openclaw tools runtime web metadata wiring", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = priorFetch;
|
||||
clearSecretsRuntimeSnapshot();
|
||||
});
|
||||
|
||||
it("uses runtime-selected provider when higher-precedence provider ref is unresolved", async () => {
|
||||
const snapshot = await prepareAndActivate({
|
||||
config: asConfig({
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_KEY_REF" },
|
||||
gemini: {
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_WEB_KEY_REF" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
GEMINI_WEB_KEY_REF: "gemini-runtime-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.webTools.search.selectedProvider).toBe("gemini");
|
||||
|
||||
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: "runtime gemini ok" }] },
|
||||
groundingMetadata: { groundingChunks: [] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response),
|
||||
);
|
||||
global.fetch = withFetchPreconnect(mockFetch);
|
||||
|
||||
const webSearch = findTool("web_search", snapshot.config);
|
||||
const result = await webSearch.execute("call-runtime-search", { query: "runtime search" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com");
|
||||
expect((result.details as { provider?: string }).provider).toBe("gemini");
|
||||
});
|
||||
|
||||
it("skips Firecrawl key resolution when runtime marks Firecrawl inactive", async () => {
|
||||
const snapshot = await prepareAndActivate({
|
||||
config: asConfig({
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
firecrawl: {
|
||||
enabled: false,
|
||||
apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_KEY_REF" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: makeHeaders({ "content-type": "text/html; charset=utf-8" }),
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
"<html><body><article><h1>Runtime Off</h1><p>Use direct fetch.</p></article></body></html>",
|
||||
),
|
||||
} as Response),
|
||||
);
|
||||
global.fetch = withFetchPreconnect(mockFetch);
|
||||
|
||||
const webFetch = findTool("web_fetch", snapshot.config);
|
||||
await webFetch.execute("call-runtime-fetch", { url: "https://example.com/runtime-off" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://example.com/runtime-off");
|
||||
expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("api.firecrawl.dev");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
sanitizeForConsole,
|
||||
} from "./pi-embedded-error-observation.js";
|
||||
|
||||
const OBSERVATION_BEARER_TOKEN = "sk-redact-test-token";
|
||||
const OBSERVATION_COOKIE_VALUE = "session-cookie-token";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -29,27 +32,27 @@ describe("buildApiErrorObservationFields", () => {
|
||||
|
||||
it("forces token redaction for observation previews", () => {
|
||||
const observed = buildApiErrorObservationFields(
|
||||
"Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456",
|
||||
`Authorization: Bearer ${OBSERVATION_BEARER_TOKEN}`,
|
||||
);
|
||||
|
||||
expect(observed.rawErrorPreview).not.toContain("sk-abcdefghijklmnopqrstuvwxyz123456");
|
||||
expect(observed.rawErrorPreview).toContain("sk-abc");
|
||||
expect(observed.rawErrorPreview).not.toContain(OBSERVATION_BEARER_TOKEN);
|
||||
expect(observed.rawErrorPreview).toContain(OBSERVATION_BEARER_TOKEN.slice(0, 6));
|
||||
expect(observed.rawErrorHash).toMatch(/^sha256:/);
|
||||
});
|
||||
|
||||
it("redacts observation-only header and cookie formats", () => {
|
||||
const observed = buildApiErrorObservationFields(
|
||||
"x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456 Cookie: session=abcdefghijklmnopqrstuvwxyz123456",
|
||||
`x-api-key: ${OBSERVATION_BEARER_TOKEN} Cookie: session=${OBSERVATION_COOKIE_VALUE}`,
|
||||
);
|
||||
|
||||
expect(observed.rawErrorPreview).not.toContain("abcdefghijklmnopqrstuvwxyz123456");
|
||||
expect(observed.rawErrorPreview).not.toContain(OBSERVATION_COOKIE_VALUE);
|
||||
expect(observed.rawErrorPreview).toContain("x-api-key: ***");
|
||||
expect(observed.rawErrorPreview).toContain("Cookie: session=");
|
||||
});
|
||||
|
||||
it("does not let cookie redaction consume unrelated fields on the same line", () => {
|
||||
const observed = buildApiErrorObservationFields(
|
||||
"Cookie: session=abcdefghijklmnopqrstuvwxyz123456 status=503 request_id=req_cookie",
|
||||
`Cookie: session=${OBSERVATION_COOKIE_VALUE} status=503 request_id=req_cookie`,
|
||||
);
|
||||
|
||||
expect(observed.rawErrorPreview).toContain("Cookie: session=");
|
||||
|
||||
@@ -32,7 +32,7 @@ const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits";
|
||||
// Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400:
|
||||
// https://github.com/openclaw/openclaw/issues/23440
|
||||
const INSUFFICIENT_QUOTA_PAYLOAD =
|
||||
'{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}';
|
||||
'{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // pragma: allowlist secret
|
||||
// Together AI error code examples: https://docs.together.ai/docs/error-codes
|
||||
const TOGETHER_PAYMENT_REQUIRED_MESSAGE =
|
||||
"402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit.";
|
||||
@@ -42,7 +42,7 @@ const TOGETHER_ENGINE_OVERLOADED_MESSAGE =
|
||||
const GROQ_TOO_MANY_REQUESTS_MESSAGE =
|
||||
"429 Too Many Requests: Too many requests were sent in a given timeframe.";
|
||||
const GROQ_SERVICE_UNAVAILABLE_MESSAGE =
|
||||
"503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance.";
|
||||
"503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret
|
||||
|
||||
describe("isAuthPermanentErrorMessage", () => {
|
||||
it("matches permanent auth failure patterns", () => {
|
||||
|
||||
@@ -1457,6 +1457,7 @@ export async function runEmbeddedPiAgent(
|
||||
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
|
||||
inlineToolResultsAllowed: false,
|
||||
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
|
||||
});
|
||||
|
||||
// Timeout aborts can leave the run without any assistant payloads.
|
||||
@@ -1479,6 +1480,7 @@ export async function runEmbeddedPiAgent(
|
||||
systemPromptReport: attempt.systemPromptReport,
|
||||
},
|
||||
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
|
||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||
@@ -1526,6 +1528,7 @@ export async function runEmbeddedPiAgent(
|
||||
: undefined,
|
||||
},
|
||||
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
|
||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||
|
||||
@@ -870,6 +870,8 @@ export async function runEmbeddedAttempt(
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
trigger: params.trigger,
|
||||
memoryFlushWritePath: params.memoryFlushWritePath,
|
||||
abortSignal: runAbortController.signal,
|
||||
modelProvider: params.model.provider,
|
||||
modelId: params.modelId,
|
||||
@@ -1544,6 +1546,7 @@ export async function runEmbeddedAttempt(
|
||||
getMessagingToolSentTargets,
|
||||
getSuccessfulCronAdds,
|
||||
didSendViaMessagingTool,
|
||||
didSendDeterministicApprovalPrompt,
|
||||
getLastToolError,
|
||||
getUsageTotals,
|
||||
getCompactionCount,
|
||||
@@ -2058,6 +2061,7 @@ export async function runEmbeddedAttempt(
|
||||
lastAssistant,
|
||||
lastToolError: getLastToolError?.(),
|
||||
didSendViaMessagingTool: didSendViaMessagingTool(),
|
||||
didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPrompt(),
|
||||
messagingToolSentTexts: getMessagingToolSentTexts(),
|
||||
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
|
||||
messagingToolSentTargets: getMessagingToolSentTargets(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
|
||||
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||
import type { AgentStreamParams } from "../../../commands/agent/types.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { enqueueCommand } from "../../../process/command-queue.js";
|
||||
@@ -28,6 +29,8 @@ export type RunEmbeddedPiAgentParams = {
|
||||
agentAccountId?: string;
|
||||
/** What initiated this agent run: "user", "heartbeat", "cron", or "memory". */
|
||||
trigger?: string;
|
||||
/** Relative workspace path that memory-triggered writes are allowed to append to. */
|
||||
memoryFlushWritePath?: string;
|
||||
/** Delivery target (e.g. telegram:group:123:topic:456) for topic/thread routing. */
|
||||
messageTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
@@ -104,7 +107,7 @@ export type RunEmbeddedPiAgentParams = {
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onReasoningEnd?: () => void | Promise<void>;
|
||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
lane?: string;
|
||||
enqueue?: typeof enqueueCommand;
|
||||
|
||||
@@ -82,4 +82,13 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
|
||||
|
||||
expect(payloads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => {
|
||||
const payloads = buildPayloads({
|
||||
assistantTexts: ["Approval is needed. Please run /approve abc allow-once"],
|
||||
didSendDeterministicApprovalPrompt: true,
|
||||
});
|
||||
|
||||
expect(payloads).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,7 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
suppressToolErrorWarnings?: boolean;
|
||||
inlineToolResultsAllowed: boolean;
|
||||
didSendViaMessagingTool?: boolean;
|
||||
didSendDeterministicApprovalPrompt?: boolean;
|
||||
}): Array<{
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
@@ -125,14 +126,17 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
}> = [];
|
||||
|
||||
const useMarkdown = params.toolResultFormat === "markdown";
|
||||
const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true;
|
||||
const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
|
||||
const errorText = params.lastAssistant
|
||||
? formatAssistantErrorText(params.lastAssistant, {
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
})
|
||||
? suppressAssistantArtifacts
|
||||
? undefined
|
||||
: formatAssistantErrorText(params.lastAssistant, {
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
})
|
||||
: undefined;
|
||||
const rawErrorMessage = lastAssistantErrored
|
||||
? params.lastAssistant?.errorMessage?.trim() || undefined
|
||||
@@ -184,8 +188,9 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const reasoningText =
|
||||
params.lastAssistant && params.reasoningLevel === "on"
|
||||
const reasoningText = suppressAssistantArtifacts
|
||||
? ""
|
||||
: params.lastAssistant && params.reasoningLevel === "on"
|
||||
? formatReasoningMessage(extractAssistantThinking(params.lastAssistant))
|
||||
: "";
|
||||
if (reasoningText) {
|
||||
@@ -243,13 +248,14 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
}
|
||||
return isRawApiErrorPayload(trimmed);
|
||||
};
|
||||
const answerTexts = (
|
||||
params.assistantTexts.length
|
||||
? params.assistantTexts
|
||||
: fallbackAnswerText
|
||||
? [fallbackAnswerText]
|
||||
: []
|
||||
).filter((text) => !shouldSuppressRawErrorText(text));
|
||||
const answerTexts = suppressAssistantArtifacts
|
||||
? []
|
||||
: (params.assistantTexts.length
|
||||
? params.assistantTexts
|
||||
: fallbackAnswerText
|
||||
? [fallbackAnswerText]
|
||||
: []
|
||||
).filter((text) => !shouldSuppressRawErrorText(text));
|
||||
|
||||
let hasUserFacingAssistantReply = false;
|
||||
for (const text of answerTexts) {
|
||||
|
||||
@@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = {
|
||||
actionFingerprint?: string;
|
||||
};
|
||||
didSendViaMessagingTool: boolean;
|
||||
didSendDeterministicApprovalPrompt?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
|
||||
@@ -85,6 +85,9 @@ export function handleMessageUpdate(
|
||||
}
|
||||
|
||||
ctx.noteLastAssistant(msg);
|
||||
if (ctx.state.deterministicApprovalPromptSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assistantEvent = evt.assistantMessageEvent;
|
||||
const assistantRecord =
|
||||
@@ -261,6 +264,9 @@ export function handleMessageEnd(
|
||||
const assistantMessage = msg;
|
||||
ctx.noteLastAssistant(assistantMessage);
|
||||
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
|
||||
if (ctx.state.deterministicApprovalPromptSent) {
|
||||
return;
|
||||
}
|
||||
promoteThinkingTagsToBlocks(assistantMessage);
|
||||
|
||||
const rawText = extractAssistantText(assistantMessage);
|
||||
|
||||
@@ -28,6 +28,7 @@ function createMockContext(overrides?: {
|
||||
messagingToolSentTextsNormalized: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
deterministicApprovalPromptSent: false,
|
||||
},
|
||||
log: { debug: vi.fn(), warn: vi.fn() },
|
||||
shouldEmitToolResult: vi.fn(() => false),
|
||||
|
||||
@@ -45,6 +45,7 @@ function createTestContext(): {
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
successfulCronAdds: 0,
|
||||
deterministicApprovalPromptSent: false,
|
||||
},
|
||||
shouldEmitToolResult: () => false,
|
||||
shouldEmitToolOutput: () => false,
|
||||
@@ -175,6 +176,161 @@ describe("handleToolExecutionEnd cron.add commitment tracking", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleToolExecutionEnd exec approval prompts", () => {
|
||||
it("emits a deterministic approval payload and marks assistant output suppressed", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
|
||||
await handleToolExecutionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolName: "exec",
|
||||
toolCallId: "tool-exec-approval",
|
||||
isError: false,
|
||||
result: {
|
||||
details: {
|
||||
status: "approval-pending",
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
expiresAtMs: 1_800_000_000_000,
|
||||
host: "gateway",
|
||||
command: "npm view diver name version description",
|
||||
cwd: "/tmp/work",
|
||||
warningText: "Warning: heredoc execution requires explicit approval in allowlist mode.",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining("```txt\n/approve 12345678 allow-once\n```"),
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
|
||||
});
|
||||
|
||||
it("emits a deterministic unavailable payload when the initiating surface cannot approve", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
|
||||
await handleToolExecutionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolName: "exec",
|
||||
toolCallId: "tool-exec-unavailable",
|
||||
isError: false,
|
||||
result: {
|
||||
details: {
|
||||
status: "approval-unavailable",
|
||||
reason: "initiating-platform-disabled",
|
||||
channelLabel: "Discord",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining("chat exec approvals are not enabled on Discord"),
|
||||
}),
|
||||
);
|
||||
expect(onToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.not.stringContaining("/approve"),
|
||||
}),
|
||||
);
|
||||
expect(onToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.not.stringContaining("Pending command:"),
|
||||
}),
|
||||
);
|
||||
expect(onToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.not.stringContaining("Host:"),
|
||||
}),
|
||||
);
|
||||
expect(onToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.not.stringContaining("CWD:"),
|
||||
}),
|
||||
);
|
||||
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
|
||||
});
|
||||
|
||||
it("emits the shared approver-DM notice when another approval client received the request", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
const onToolResult = vi.fn();
|
||||
ctx.params.onToolResult = onToolResult;
|
||||
|
||||
await handleToolExecutionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolName: "exec",
|
||||
toolCallId: "tool-exec-unavailable-dm-redirect",
|
||||
isError: false,
|
||||
result: {
|
||||
details: {
|
||||
status: "approval-unavailable",
|
||||
reason: "initiating-platform-disabled",
|
||||
channelLabel: "Telegram",
|
||||
sentApproverDms: true,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "Approval required. I sent the allowed approvers DMs.",
|
||||
}),
|
||||
);
|
||||
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
|
||||
});
|
||||
|
||||
it("does not suppress assistant output when deterministic prompt delivery rejects", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
ctx.params.onToolResult = vi.fn(async () => {
|
||||
throw new Error("delivery failed");
|
||||
});
|
||||
|
||||
await handleToolExecutionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolName: "exec",
|
||||
toolCallId: "tool-exec-approval-reject",
|
||||
isError: false,
|
||||
result: {
|
||||
details: {
|
||||
status: "approval-pending",
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
expiresAtMs: 1_800_000_000_000,
|
||||
host: "gateway",
|
||||
command: "npm view diver name version description",
|
||||
cwd: "/tmp/work",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(ctx.state.deterministicApprovalPromptSent).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging tool media URL tracking", () => {
|
||||
it("tracks media arg from messaging tool as pending", async () => {
|
||||
const { ctx } = createTestContext();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import {
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
buildExecApprovalUnavailableReplyPayload,
|
||||
} from "../infra/exec-approval-reply.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
|
||||
import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
|
||||
@@ -139,7 +143,81 @@ function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] {
|
||||
return urls;
|
||||
}
|
||||
|
||||
function emitToolResultOutput(params: {
|
||||
function readExecApprovalPendingDetails(result: unknown): {
|
||||
approvalId: string;
|
||||
approvalSlug: string;
|
||||
expiresAtMs?: number;
|
||||
host: "gateway" | "node";
|
||||
command: string;
|
||||
cwd?: string;
|
||||
nodeId?: string;
|
||||
warningText?: string;
|
||||
} | null {
|
||||
if (!result || typeof result !== "object") {
|
||||
return null;
|
||||
}
|
||||
const outer = result as Record<string, unknown>;
|
||||
const details =
|
||||
outer.details && typeof outer.details === "object" && !Array.isArray(outer.details)
|
||||
? (outer.details as Record<string, unknown>)
|
||||
: outer;
|
||||
if (details.status !== "approval-pending") {
|
||||
return null;
|
||||
}
|
||||
const approvalId = typeof details.approvalId === "string" ? details.approvalId.trim() : "";
|
||||
const approvalSlug = typeof details.approvalSlug === "string" ? details.approvalSlug.trim() : "";
|
||||
const command = typeof details.command === "string" ? details.command : "";
|
||||
const host = details.host === "node" ? "node" : details.host === "gateway" ? "gateway" : null;
|
||||
if (!approvalId || !approvalSlug || !command || !host) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs: typeof details.expiresAtMs === "number" ? details.expiresAtMs : undefined,
|
||||
host,
|
||||
command,
|
||||
cwd: typeof details.cwd === "string" ? details.cwd : undefined,
|
||||
nodeId: typeof details.nodeId === "string" ? details.nodeId : undefined,
|
||||
warningText: typeof details.warningText === "string" ? details.warningText : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readExecApprovalUnavailableDetails(result: unknown): {
|
||||
reason: "initiating-platform-disabled" | "initiating-platform-unsupported" | "no-approval-route";
|
||||
warningText?: string;
|
||||
channelLabel?: string;
|
||||
sentApproverDms?: boolean;
|
||||
} | null {
|
||||
if (!result || typeof result !== "object") {
|
||||
return null;
|
||||
}
|
||||
const outer = result as Record<string, unknown>;
|
||||
const details =
|
||||
outer.details && typeof outer.details === "object" && !Array.isArray(outer.details)
|
||||
? (outer.details as Record<string, unknown>)
|
||||
: outer;
|
||||
if (details.status !== "approval-unavailable") {
|
||||
return null;
|
||||
}
|
||||
const reason =
|
||||
details.reason === "initiating-platform-disabled" ||
|
||||
details.reason === "initiating-platform-unsupported" ||
|
||||
details.reason === "no-approval-route"
|
||||
? details.reason
|
||||
: null;
|
||||
if (!reason) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
reason,
|
||||
warningText: typeof details.warningText === "string" ? details.warningText : undefined,
|
||||
channelLabel: typeof details.channelLabel === "string" ? details.channelLabel : undefined,
|
||||
sentApproverDms: details.sentApproverDms === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function emitToolResultOutput(params: {
|
||||
ctx: ToolHandlerContext;
|
||||
toolName: string;
|
||||
meta?: string;
|
||||
@@ -152,6 +230,46 @@ function emitToolResultOutput(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const approvalPending = readExecApprovalPendingDetails(result);
|
||||
if (!isToolError && approvalPending) {
|
||||
try {
|
||||
await ctx.params.onToolResult(
|
||||
buildExecApprovalPendingReplyPayload({
|
||||
approvalId: approvalPending.approvalId,
|
||||
approvalSlug: approvalPending.approvalSlug,
|
||||
command: approvalPending.command,
|
||||
cwd: approvalPending.cwd,
|
||||
host: approvalPending.host,
|
||||
nodeId: approvalPending.nodeId,
|
||||
expiresAtMs: approvalPending.expiresAtMs,
|
||||
warningText: approvalPending.warningText,
|
||||
}),
|
||||
);
|
||||
ctx.state.deterministicApprovalPromptSent = true;
|
||||
} catch {
|
||||
// ignore delivery failures
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const approvalUnavailable = readExecApprovalUnavailableDetails(result);
|
||||
if (!isToolError && approvalUnavailable) {
|
||||
try {
|
||||
await ctx.params.onToolResult?.(
|
||||
buildExecApprovalUnavailableReplyPayload({
|
||||
reason: approvalUnavailable.reason,
|
||||
warningText: approvalUnavailable.warningText,
|
||||
channelLabel: approvalUnavailable.channelLabel,
|
||||
sentApproverDms: approvalUnavailable.sentApproverDms,
|
||||
}),
|
||||
);
|
||||
ctx.state.deterministicApprovalPromptSent = true;
|
||||
} catch {
|
||||
// ignore delivery failures
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.shouldEmitToolOutput()) {
|
||||
const outputText = extractToolResultText(sanitizedResult);
|
||||
if (outputText) {
|
||||
@@ -427,7 +545,7 @@ export async function handleToolExecutionEnd(
|
||||
`embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
|
||||
);
|
||||
|
||||
emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult });
|
||||
await emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult });
|
||||
|
||||
// Run after_tool_call plugin hook (fire-and-forget)
|
||||
const hookRunnerAfter = ctx.hookRunner ?? getGlobalHookRunner();
|
||||
|
||||
@@ -76,6 +76,7 @@ export type EmbeddedPiSubscribeState = {
|
||||
pendingMessagingTargets: Map<string, MessagingToolSend>;
|
||||
successfulCronAdds: number;
|
||||
pendingMessagingMediaUrls: Map<string, string[]>;
|
||||
deterministicApprovalPromptSent: boolean;
|
||||
lastAssistant?: AgentMessage;
|
||||
};
|
||||
|
||||
@@ -155,6 +156,7 @@ export type ToolHandlerState = Pick<
|
||||
| "messagingToolSentMediaUrls"
|
||||
| "messagingToolSentTargets"
|
||||
| "successfulCronAdds"
|
||||
| "deterministicApprovalPromptSent"
|
||||
>;
|
||||
|
||||
export type ToolHandlerContext = {
|
||||
|
||||
@@ -78,6 +78,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
pendingMessagingTargets: new Map(),
|
||||
successfulCronAdds: 0,
|
||||
pendingMessagingMediaUrls: new Map(),
|
||||
deterministicApprovalPromptSent: false,
|
||||
};
|
||||
const usageTotals = {
|
||||
input: 0,
|
||||
@@ -598,6 +599,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
pendingMessagingTargets.clear();
|
||||
state.successfulCronAdds = 0;
|
||||
state.pendingMessagingMediaUrls.clear();
|
||||
state.deterministicApprovalPromptSent = false;
|
||||
resetAssistantMessageState(0);
|
||||
};
|
||||
|
||||
@@ -688,6 +690,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
|
||||
// which is generated AFTER the tool sends the actual answer.
|
||||
didSendViaMessagingTool: () => messagingToolSentTexts.length > 0,
|
||||
didSendDeterministicApprovalPrompt: () => state.deterministicApprovalPromptSent,
|
||||
getLastToolError: () => (state.lastToolError ? { ...state.lastToolError } : undefined),
|
||||
getUsageTotals,
|
||||
getCompactionCount: () => compactionCount,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { HookRunner } from "../plugins/hooks.js";
|
||||
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
||||
@@ -16,7 +17,7 @@ export type SubscribeEmbeddedPiSessionParams = {
|
||||
toolResultFormat?: ToolResultFormat;
|
||||
shouldEmitToolResult?: () => boolean;
|
||||
shouldEmitToolOutput?: () => boolean;
|
||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
|
||||
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
/** Called when a thinking/reasoning block ends (</think> tag processed). */
|
||||
onReasoningEnd?: () => void | Promise<void>;
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBaseToolHandlerState() {
|
||||
messagingToolSentTextsNormalized: [] as string[],
|
||||
messagingToolSentMediaUrls: [] as string[],
|
||||
messagingToolSentTargets: [] as unknown[],
|
||||
deterministicApprovalPromptSent: false,
|
||||
blockBuffer: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
appendFileWithinRoot,
|
||||
SafeOpenError,
|
||||
openFileWithinRoot,
|
||||
readFileWithinRoot,
|
||||
@@ -406,6 +407,161 @@ function mapContainerPathToWorkspaceRoot(params: {
|
||||
return path.resolve(params.root, ...relative.split("/").filter(Boolean));
|
||||
}
|
||||
|
||||
export function resolveToolPathAgainstWorkspaceRoot(params: {
|
||||
filePath: string;
|
||||
root: string;
|
||||
containerWorkdir?: string;
|
||||
}): string {
|
||||
const mapped = mapContainerPathToWorkspaceRoot(params);
|
||||
const candidate = mapped.startsWith("@") ? mapped.slice(1) : mapped;
|
||||
return path.isAbsolute(candidate)
|
||||
? path.resolve(candidate)
|
||||
: path.resolve(params.root, candidate || ".");
|
||||
}
|
||||
|
||||
type MemoryFlushAppendOnlyWriteOptions = {
|
||||
root: string;
|
||||
relativePath: string;
|
||||
containerWorkdir?: string;
|
||||
sandbox?: {
|
||||
root: string;
|
||||
bridge: SandboxFsBridge;
|
||||
};
|
||||
};
|
||||
|
||||
async function readOptionalUtf8File(params: {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"];
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
try {
|
||||
if (params.sandbox) {
|
||||
const stat = await params.sandbox.bridge.stat({
|
||||
filePath: params.relativePath,
|
||||
cwd: params.sandbox.root,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!stat) {
|
||||
return "";
|
||||
}
|
||||
const buffer = await params.sandbox.bridge.readFile({
|
||||
filePath: params.relativePath,
|
||||
cwd: params.sandbox.root,
|
||||
signal: params.signal,
|
||||
});
|
||||
return buffer.toString("utf-8");
|
||||
}
|
||||
return await fs.readFile(params.absolutePath, "utf-8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||
return "";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function appendMemoryFlushContent(params: {
|
||||
absolutePath: string;
|
||||
root: string;
|
||||
relativePath: string;
|
||||
content: string;
|
||||
sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"];
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
if (!params.sandbox) {
|
||||
await appendFileWithinRoot({
|
||||
rootDir: params.root,
|
||||
relativePath: params.relativePath,
|
||||
data: params.content,
|
||||
mkdir: true,
|
||||
prependNewlineIfNeeded: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await readOptionalUtf8File({
|
||||
absolutePath: params.absolutePath,
|
||||
relativePath: params.relativePath,
|
||||
sandbox: params.sandbox,
|
||||
signal: params.signal,
|
||||
});
|
||||
const separator =
|
||||
existing.length > 0 && !existing.endsWith("\n") && !params.content.startsWith("\n") ? "\n" : "";
|
||||
const next = `${existing}${separator}${params.content}`;
|
||||
if (params.sandbox) {
|
||||
const parent = path.posix.dirname(params.relativePath);
|
||||
if (parent && parent !== ".") {
|
||||
await params.sandbox.bridge.mkdirp({
|
||||
filePath: parent,
|
||||
cwd: params.sandbox.root,
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
await params.sandbox.bridge.writeFile({
|
||||
filePath: params.relativePath,
|
||||
cwd: params.sandbox.root,
|
||||
data: next,
|
||||
mkdir: true,
|
||||
signal: params.signal,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fs.mkdir(path.dirname(params.absolutePath), { recursive: true });
|
||||
await fs.writeFile(params.absolutePath, next, "utf-8");
|
||||
}
|
||||
|
||||
export function wrapToolMemoryFlushAppendOnlyWrite(
|
||||
tool: AnyAgentTool,
|
||||
options: MemoryFlushAppendOnlyWriteOptions,
|
||||
): AnyAgentTool {
|
||||
const allowedAbsolutePath = path.resolve(options.root, options.relativePath);
|
||||
return {
|
||||
...tool,
|
||||
description: `${tool.description} During memory flush, this tool may only append to ${options.relativePath}.`,
|
||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
const normalized = normalizeToolParams(args);
|
||||
const record =
|
||||
normalized ??
|
||||
(args && typeof args === "object" ? (args as Record<string, unknown>) : undefined);
|
||||
assertRequiredParams(record, CLAUDE_PARAM_GROUPS.write, tool.name);
|
||||
const filePath =
|
||||
typeof record?.path === "string" && record.path.trim() ? record.path : undefined;
|
||||
const content = typeof record?.content === "string" ? record.content : undefined;
|
||||
if (!filePath || content === undefined) {
|
||||
return tool.execute(toolCallId, normalized ?? args, signal, onUpdate);
|
||||
}
|
||||
|
||||
const resolvedPath = resolveToolPathAgainstWorkspaceRoot({
|
||||
filePath,
|
||||
root: options.root,
|
||||
containerWorkdir: options.containerWorkdir,
|
||||
});
|
||||
if (resolvedPath !== allowedAbsolutePath) {
|
||||
throw new Error(
|
||||
`Memory flush writes are restricted to ${options.relativePath}; use that path only.`,
|
||||
);
|
||||
}
|
||||
|
||||
await appendMemoryFlushContent({
|
||||
absolutePath: allowedAbsolutePath,
|
||||
root: options.root,
|
||||
relativePath: options.relativePath,
|
||||
content,
|
||||
sandbox: options.sandbox,
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `Appended content to ${options.relativePath}.` }],
|
||||
details: {
|
||||
path: options.relativePath,
|
||||
appendOnly: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapToolWorkspaceRootGuardWithOptions(
|
||||
tool: AnyAgentTool,
|
||||
root: string,
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
createSandboxedWriteTool,
|
||||
normalizeToolParams,
|
||||
patchToolSchemaForClaudeCompatibility,
|
||||
wrapToolMemoryFlushAppendOnlyWrite,
|
||||
wrapToolWorkspaceRootGuard,
|
||||
wrapToolWorkspaceRootGuardWithOptions,
|
||||
wrapToolParamNormalization,
|
||||
@@ -67,6 +68,7 @@ const TOOL_DENY_BY_MESSAGE_PROVIDER: Readonly<Record<string, readonly string[]>>
|
||||
voice: ["tts"],
|
||||
};
|
||||
const TOOL_DENY_FOR_XAI_PROVIDERS = new Set(["web_search"]);
|
||||
const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]);
|
||||
|
||||
function normalizeMessageProvider(messageProvider?: string): string | undefined {
|
||||
const normalized = messageProvider?.trim().toLowerCase();
|
||||
@@ -207,6 +209,10 @@ export function createOpenClawCodingTools(options?: {
|
||||
sessionId?: string;
|
||||
/** Stable run identifier for this agent invocation. */
|
||||
runId?: string;
|
||||
/** What initiated this run (for trigger-specific tool restrictions). */
|
||||
trigger?: string;
|
||||
/** Relative workspace path that memory-triggered writes may append to. */
|
||||
memoryFlushWritePath?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
@@ -258,6 +264,11 @@ export function createOpenClawCodingTools(options?: {
|
||||
}): AnyAgentTool[] {
|
||||
const execToolName = "exec";
|
||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||
const isMemoryFlushRun = options?.trigger === "memory";
|
||||
if (isMemoryFlushRun && !options?.memoryFlushWritePath) {
|
||||
throw new Error("memoryFlushWritePath required for memory-triggered tool runs");
|
||||
}
|
||||
const memoryFlushWritePath = isMemoryFlushRun ? options.memoryFlushWritePath : undefined;
|
||||
const {
|
||||
agentId,
|
||||
globalPolicy,
|
||||
@@ -322,7 +333,7 @@ export function createOpenClawCodingTools(options?: {
|
||||
const execConfig = resolveExecConfig({ cfg: options?.config, agentId });
|
||||
const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId });
|
||||
const fsPolicy = createToolFsPolicy({
|
||||
workspaceOnly: fsConfig.workspaceOnly,
|
||||
workspaceOnly: isMemoryFlushRun || fsConfig.workspaceOnly,
|
||||
});
|
||||
const sandboxRoot = sandbox?.workspaceDir;
|
||||
const sandboxFsBridge = sandbox?.fsBridge;
|
||||
@@ -515,7 +526,32 @@ export function createOpenClawCodingTools(options?: {
|
||||
sessionId: options?.sessionId,
|
||||
}),
|
||||
];
|
||||
const toolsForMessageProvider = applyMessageProviderToolPolicy(tools, options?.messageProvider);
|
||||
const toolsForMemoryFlush =
|
||||
isMemoryFlushRun && memoryFlushWritePath
|
||||
? tools.flatMap((tool) => {
|
||||
if (!MEMORY_FLUSH_ALLOWED_TOOL_NAMES.has(tool.name)) {
|
||||
return [];
|
||||
}
|
||||
if (tool.name === "write") {
|
||||
return [
|
||||
wrapToolMemoryFlushAppendOnlyWrite(tool, {
|
||||
root: sandboxRoot ?? workspaceRoot,
|
||||
relativePath: memoryFlushWritePath,
|
||||
containerWorkdir: sandbox?.containerWorkdir,
|
||||
sandbox:
|
||||
sandboxRoot && sandboxFsBridge
|
||||
? { root: sandboxRoot, bridge: sandboxFsBridge }
|
||||
: undefined,
|
||||
}),
|
||||
];
|
||||
}
|
||||
return [tool];
|
||||
})
|
||||
: tools;
|
||||
const toolsForMessageProvider = applyMessageProviderToolPolicy(
|
||||
toolsForMemoryFlush,
|
||||
options?.messageProvider,
|
||||
);
|
||||
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {
|
||||
modelProvider: options?.modelProvider,
|
||||
modelId: options?.modelId,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: () => undefined,
|
||||
getOAuthProviders: () => [],
|
||||
}));
|
||||
|
||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||
|
||||
describe("FS tools with workspaceOnly=false", () => {
|
||||
@@ -181,4 +187,50 @@ describe("FS tools with workspaceOnly=false", () => {
|
||||
}),
|
||||
).rejects.toThrow(/Path escapes (workspace|sandbox) root/);
|
||||
});
|
||||
|
||||
it("restricts memory-triggered writes to append-only canonical memory files", async () => {
|
||||
const allowedRelativePath = "memory/2026-03-07.md";
|
||||
const allowedAbsolutePath = path.join(workspaceDir, allowedRelativePath);
|
||||
await fs.mkdir(path.dirname(allowedAbsolutePath), { recursive: true });
|
||||
await fs.writeFile(allowedAbsolutePath, "seed");
|
||||
|
||||
const tools = createOpenClawCodingTools({
|
||||
workspaceDir,
|
||||
trigger: "memory",
|
||||
memoryFlushWritePath: allowedRelativePath,
|
||||
config: {
|
||||
tools: {
|
||||
exec: {
|
||||
applyPatch: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-5",
|
||||
});
|
||||
|
||||
const writeTool = tools.find((tool) => tool.name === "write");
|
||||
expect(writeTool).toBeDefined();
|
||||
expect(tools.map((tool) => tool.name).toSorted()).toEqual(["read", "write"]);
|
||||
|
||||
await expect(
|
||||
writeTool!.execute("test-call-memory-deny", {
|
||||
path: outsideFile,
|
||||
content: "should not write here",
|
||||
}),
|
||||
).rejects.toThrow(/Memory flush writes are restricted to memory\/2026-03-07\.md/);
|
||||
|
||||
const result = await writeTool!.execute("test-call-memory-append", {
|
||||
path: allowedRelativePath,
|
||||
content: "new note",
|
||||
});
|
||||
expect(hasToolError(result)).toBe(false);
|
||||
expect(result.content).toContainEqual({
|
||||
type: "text",
|
||||
text: "Appended content to memory/2026-03-07.md.",
|
||||
});
|
||||
await expect(fs.readFile(allowedAbsolutePath, "utf-8")).resolves.toBe("seed\nnew note");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,6 +464,9 @@ export function buildAgentSystemPrompt(params: {
|
||||
"Keep narration brief and value-dense; avoid repeating obvious steps.",
|
||||
"Use plain human language for narration unless in a technical context.",
|
||||
"When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.",
|
||||
"When exec returns approval-pending, include the concrete /approve command from tool output (with allow-once|allow-always|deny) and do not ask for a different or rotated code.",
|
||||
"Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.",
|
||||
"When approvals are required, preserve and show the full command/script exactly as provided (including chained operators like &&, ||, |, ;, or multiline shells) so the user can approve what will actually run.",
|
||||
"",
|
||||
...safetySection,
|
||||
"## OpenClaw CLI Quick Reference",
|
||||
|
||||
@@ -84,6 +84,47 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
|
||||
expect(details?.contentType).toBe("text/html");
|
||||
});
|
||||
|
||||
it("bypasses Firecrawl when runtime metadata marks Firecrawl inactive", async () => {
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
htmlResponse(
|
||||
"<html><body><article><h1>Runtime Off</h1><p>Use direct fetch.</p></article></body></html>",
|
||||
),
|
||||
);
|
||||
global.fetch = withFetchPreconnect(fetchSpy);
|
||||
|
||||
const tool = createWebFetchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_FIRECRAWL_KEY_REF",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: false,
|
||||
runtimeFirecrawl: {
|
||||
active: false,
|
||||
apiKeySource: "secretRef",
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
|
||||
await tool?.execute?.("call", { url: "https://example.com/runtime-firecrawl-off" });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://example.com/runtime-firecrawl-off");
|
||||
});
|
||||
|
||||
it("logs x-markdown-tokens when header is present", async () => {
|
||||
const logSpy = vi.spyOn(logger, "logDebug").mockImplementation(() => {});
|
||||
const fetchSpy = vi
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
|
||||
import { SsrFBlockedError } from "../../infra/net/ssrf.js";
|
||||
import { logDebug } from "../../logger.js";
|
||||
import type { RuntimeWebFetchFirecrawlMetadata } from "../../secrets/runtime-web-tools.js";
|
||||
import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
import { stringEnum } from "../schema/typebox.js";
|
||||
@@ -71,7 +73,7 @@ type WebFetchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer
|
||||
type FirecrawlFetchConfig =
|
||||
| {
|
||||
enabled?: boolean;
|
||||
apiKey?: string;
|
||||
apiKey?: unknown;
|
||||
baseUrl?: string;
|
||||
onlyMainContent?: boolean;
|
||||
maxAgeMs?: number;
|
||||
@@ -136,10 +138,14 @@ function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig {
|
||||
}
|
||||
|
||||
function resolveFirecrawlApiKey(firecrawl?: FirecrawlFetchConfig): string | undefined {
|
||||
const fromConfig =
|
||||
firecrawl && "apiKey" in firecrawl && typeof firecrawl.apiKey === "string"
|
||||
? normalizeSecretInput(firecrawl.apiKey)
|
||||
: "";
|
||||
const fromConfigRaw =
|
||||
firecrawl && "apiKey" in firecrawl
|
||||
? normalizeResolvedSecretInputString({
|
||||
value: firecrawl.apiKey,
|
||||
path: "tools.web.fetch.firecrawl.apiKey",
|
||||
})
|
||||
: undefined;
|
||||
const fromConfig = normalizeSecretInput(fromConfigRaw);
|
||||
const fromEnv = normalizeSecretInput(process.env.FIRECRAWL_API_KEY);
|
||||
return fromConfig || fromEnv || undefined;
|
||||
}
|
||||
@@ -712,6 +718,7 @@ function resolveFirecrawlEndpoint(baseUrl: string): string {
|
||||
export function createWebFetchTool(options?: {
|
||||
config?: OpenClawConfig;
|
||||
sandboxed?: boolean;
|
||||
runtimeFirecrawl?: RuntimeWebFetchFirecrawlMetadata;
|
||||
}): AnyAgentTool | null {
|
||||
const fetch = resolveFetchConfig(options?.config);
|
||||
if (!resolveFetchEnabled({ fetch, sandboxed: options?.sandboxed })) {
|
||||
@@ -719,8 +726,14 @@ export function createWebFetchTool(options?: {
|
||||
}
|
||||
const readabilityEnabled = resolveFetchReadabilityEnabled(fetch);
|
||||
const firecrawl = resolveFirecrawlConfig(fetch);
|
||||
const firecrawlApiKey = resolveFirecrawlApiKey(firecrawl);
|
||||
const firecrawlEnabled = resolveFirecrawlEnabled({ firecrawl, apiKey: firecrawlApiKey });
|
||||
const runtimeFirecrawlActive = options?.runtimeFirecrawl?.active;
|
||||
const shouldResolveFirecrawlApiKey =
|
||||
runtimeFirecrawlActive === undefined ? firecrawl?.enabled !== false : runtimeFirecrawlActive;
|
||||
const firecrawlApiKey = shouldResolveFirecrawlApiKey
|
||||
? resolveFirecrawlApiKey(firecrawl)
|
||||
: undefined;
|
||||
const firecrawlEnabled =
|
||||
runtimeFirecrawlActive ?? resolveFirecrawlEnabled({ firecrawl, apiKey: firecrawlApiKey });
|
||||
const firecrawlBaseUrl = resolveFirecrawlBaseUrl(firecrawl);
|
||||
const firecrawlOnlyMainContent = resolveFirecrawlOnlyMainContent(firecrawl);
|
||||
const firecrawlMaxAgeMs = resolveFirecrawlMaxAgeMsOrDefault(firecrawl);
|
||||
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
resolveKimiBaseUrl,
|
||||
extractKimiCitations,
|
||||
resolveBraveMode,
|
||||
mapBraveLlmContextResults,
|
||||
} = __testing;
|
||||
|
||||
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
|
||||
@@ -393,3 +394,77 @@ describe("resolveBraveMode", () => {
|
||||
expect(resolveBraveMode({ mode: "invalid" })).toBe("web");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapBraveLlmContextResults", () => {
|
||||
it("maps plain string snippets correctly", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/page",
|
||||
title: "Example Page",
|
||||
snippets: ["first snippet", "second snippet"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(results).toEqual([
|
||||
{
|
||||
url: "https://example.com/page",
|
||||
title: "Example Page",
|
||||
snippets: ["first snippet", "second snippet"],
|
||||
siteName: "example.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out non-string and empty snippets", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com",
|
||||
title: "Test",
|
||||
snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(results[0].snippets).toEqual(["valid"]);
|
||||
});
|
||||
|
||||
it("handles missing snippets array", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [{ url: "https://example.com", title: "No Snippets" } as never],
|
||||
},
|
||||
});
|
||||
expect(results[0].snippets).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles empty grounding.generic", () => {
|
||||
expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles missing grounding.generic", () => {
|
||||
expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves siteName from URL hostname", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }],
|
||||
},
|
||||
});
|
||||
expect(results[0].siteName).toBe("docs.example.org");
|
||||
});
|
||||
|
||||
it("sets siteName to undefined for invalid URLs", () => {
|
||||
const results = mapBraveLlmContextResults({
|
||||
grounding: {
|
||||
generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }],
|
||||
},
|
||||
});
|
||||
expect(results[0].siteName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js";
|
||||
import { wrapWebContent } from "../../security/external-content.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
@@ -193,6 +194,33 @@ function createWebSearchSchema(params: {
|
||||
),
|
||||
} as const;
|
||||
|
||||
const perplexityStructuredFilterSchema = {
|
||||
country: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
||||
}),
|
||||
),
|
||||
language: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
|
||||
}),
|
||||
),
|
||||
date_after: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
date_before: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
} as const;
|
||||
|
||||
if (params.provider === "brave") {
|
||||
return Type.Object({
|
||||
...querySchema,
|
||||
@@ -221,7 +249,8 @@ function createWebSearchSchema(params: {
|
||||
}
|
||||
return Type.Object({
|
||||
...querySchema,
|
||||
...filterSchema,
|
||||
freshness: filterSchema.freshness,
|
||||
...perplexityStructuredFilterSchema,
|
||||
domain_filter: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description:
|
||||
@@ -272,8 +301,7 @@ type BraveSearchResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
type BraveLlmContextSnippet = { text: string };
|
||||
type BraveLlmContextResult = { url: string; title: string; snippets: BraveLlmContextSnippet[] };
|
||||
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
|
||||
type BraveLlmContextResponse = {
|
||||
grounding: { generic?: BraveLlmContextResult[] };
|
||||
sources?: { url?: string; hostname?: string; date?: string }[];
|
||||
@@ -368,6 +396,16 @@ type PerplexitySearchResponse = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
url_citation?: {
|
||||
url?: string;
|
||||
title?: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
citations?: string[];
|
||||
@@ -386,6 +424,38 @@ type PerplexitySearchApiResponse = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
|
||||
const normalizeUrl = (value: unknown): string | undefined => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const topLevel = (data.citations ?? [])
|
||||
.map(normalizeUrl)
|
||||
.filter((url): url is string => Boolean(url));
|
||||
if (topLevel.length > 0) {
|
||||
return [...new Set(topLevel)];
|
||||
}
|
||||
|
||||
const citations: string[] = [];
|
||||
for (const choice of data.choices ?? []) {
|
||||
for (const annotation of choice.message?.annotations ?? []) {
|
||||
if (annotation.type !== "url_citation") {
|
||||
continue;
|
||||
}
|
||||
const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url);
|
||||
if (url) {
|
||||
citations.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(citations)];
|
||||
}
|
||||
|
||||
function extractGrokContent(data: GrokSearchResponse): {
|
||||
text: string | undefined;
|
||||
annotationCitations: string[];
|
||||
@@ -743,6 +813,16 @@ function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePerplexitySchemaTransportHint(
|
||||
perplexity?: PerplexityConfig,
|
||||
): PerplexityTransport | undefined {
|
||||
const hasLegacyOverride = Boolean(
|
||||
(perplexity?.baseUrl && perplexity.baseUrl.trim()) ||
|
||||
(perplexity?.model && perplexity.model.trim()),
|
||||
);
|
||||
return hasLegacyOverride ? "chat_completions" : undefined;
|
||||
}
|
||||
|
||||
function resolveGrokConfig(search?: WebSearchConfig): GrokConfig {
|
||||
if (!search || typeof search !== "object") {
|
||||
return {};
|
||||
@@ -1214,7 +1294,8 @@ async function runPerplexitySearch(params: {
|
||||
|
||||
const data = (await res.json()) as PerplexitySearchResponse;
|
||||
const content = data.choices?.[0]?.message?.content ?? "No response";
|
||||
const citations = data.citations ?? [];
|
||||
// Prefer top-level citations; fall back to OpenRouter-style message annotations.
|
||||
const citations = extractPerplexityCitations(data);
|
||||
|
||||
return { content, citations };
|
||||
},
|
||||
@@ -1429,6 +1510,18 @@ async function runKimiSearch(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function mapBraveLlmContextResults(
|
||||
data: BraveLlmContextResponse,
|
||||
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
|
||||
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
|
||||
return genericResults.map((entry) => ({
|
||||
url: entry.url ?? "",
|
||||
title: entry.title ?? "",
|
||||
snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0),
|
||||
siteName: resolveSiteName(entry.url) || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async function runBraveLlmContextSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
@@ -1477,13 +1570,7 @@ async function runBraveLlmContextSearch(params: {
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BraveLlmContextResponse;
|
||||
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
|
||||
const mapped = genericResults.map((entry) => ({
|
||||
url: entry.url ?? "",
|
||||
title: entry.title ?? "",
|
||||
snippets: (entry.snippets ?? []).map((s) => s.text ?? "").filter(Boolean),
|
||||
siteName: resolveSiteName(entry.url) || undefined,
|
||||
}));
|
||||
const mapped = mapBraveLlmContextResults(data);
|
||||
|
||||
return { results: mapped, sources: data.sources };
|
||||
},
|
||||
@@ -1804,15 +1891,21 @@ async function runWebSearch(params: {
|
||||
export function createWebSearchTool(options?: {
|
||||
config?: OpenClawConfig;
|
||||
sandboxed?: boolean;
|
||||
runtimeWebSearch?: RuntimeWebSearchMetadata;
|
||||
}): AnyAgentTool | null {
|
||||
const search = resolveSearchConfig(options?.config);
|
||||
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = resolveSearchProvider(search);
|
||||
const provider =
|
||||
options?.runtimeWebSearch?.selectedProvider ??
|
||||
options?.runtimeWebSearch?.providerConfigured ??
|
||||
resolveSearchProvider(search);
|
||||
const perplexityConfig = resolvePerplexityConfig(search);
|
||||
const perplexityTransport = resolvePerplexityTransport(perplexityConfig);
|
||||
const perplexitySchemaTransportHint =
|
||||
options?.runtimeWebSearch?.perplexityTransport ??
|
||||
resolvePerplexitySchemaTransportHint(perplexityConfig);
|
||||
const grokConfig = resolveGrokConfig(search);
|
||||
const geminiConfig = resolveGeminiConfig(search);
|
||||
const kimiConfig = resolveKimiConfig(search);
|
||||
@@ -1821,9 +1914,9 @@ export function createWebSearchTool(options?: {
|
||||
|
||||
const description =
|
||||
provider === "perplexity"
|
||||
? perplexityTransport.transport === "chat_completions"
|
||||
? perplexitySchemaTransportHint === "chat_completions"
|
||||
? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search."
|
||||
: "Search the web using the Perplexity Search API. Returns structured results (title, URL, snippet) for fast research. Supports domain, region, language, and freshness filtering."
|
||||
: "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path."
|
||||
: provider === "grok"
|
||||
? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search."
|
||||
: provider === "kimi"
|
||||
@@ -1840,10 +1933,13 @@ export function createWebSearchTool(options?: {
|
||||
description,
|
||||
parameters: createWebSearchSchema({
|
||||
provider,
|
||||
perplexityTransport: provider === "perplexity" ? perplexityTransport.transport : undefined,
|
||||
perplexityTransport: provider === "perplexity" ? perplexitySchemaTransportHint : undefined,
|
||||
}),
|
||||
execute: async (_toolCallId, args) => {
|
||||
const perplexityRuntime = provider === "perplexity" ? perplexityTransport : undefined;
|
||||
// Resolve Perplexity auth/transport lazily at execution time so unrelated providers
|
||||
// do not touch Perplexity-only credential surfaces during tool construction.
|
||||
const perplexityRuntime =
|
||||
provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined;
|
||||
const apiKey =
|
||||
provider === "perplexity"
|
||||
? perplexityRuntime?.apiKey
|
||||
@@ -2122,4 +2218,5 @@ export const __testing = {
|
||||
extractKimiCitations,
|
||||
resolveRedirectUrl: resolveCitationRedirectUrl,
|
||||
resolveBraveMode,
|
||||
mapBraveLlmContextResults,
|
||||
} as const;
|
||||
|
||||
@@ -113,11 +113,13 @@ function installPerplexitySearchApiFetch(results?: Array<Record<string, unknown>
|
||||
});
|
||||
}
|
||||
|
||||
function installPerplexityChatFetch() {
|
||||
return installMockFetch({
|
||||
choices: [{ message: { content: "ok" } }],
|
||||
citations: ["https://example.com"],
|
||||
});
|
||||
function installPerplexityChatFetch(payload?: Record<string, unknown>) {
|
||||
return installMockFetch(
|
||||
payload ?? {
|
||||
choices: [{ message: { content: "ok" } }],
|
||||
citations: ["https://example.com"],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createProviderSuccessPayload(
|
||||
@@ -166,6 +168,39 @@ describe("web tools defaults", () => {
|
||||
const tool = createWebSearchTool({ config: {}, sandboxed: false });
|
||||
expect(tool?.name).toBe("web_search");
|
||||
});
|
||||
|
||||
it("prefers runtime-selected web_search provider over local provider config", async () => {
|
||||
const mockFetch = installMockFetch(createProviderSuccessPayload("gemini"));
|
||||
const tool = createWebSearchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
apiKey: "brave-config-test", // pragma: allowlist secret
|
||||
gemini: {
|
||||
apiKey: "gemini-config-test", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: true,
|
||||
runtimeWebSearch: {
|
||||
providerConfigured: "brave",
|
||||
providerSource: "auto-detect",
|
||||
selectedProvider: "gemini",
|
||||
selectedProviderKeySource: "secretRef",
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool?.execute?.("call-runtime-provider", { query: "runtime override" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com");
|
||||
expect((result?.details as { provider?: string } | undefined)?.provider).toBe("gemini");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search country and language parameters", () => {
|
||||
@@ -476,6 +511,42 @@ describe("web_search perplexity OpenRouter compatibility", () => {
|
||||
expect(body.search_recency_filter).toBe("week");
|
||||
});
|
||||
|
||||
it("falls back to message annotations when top-level citations are missing", async () => {
|
||||
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
|
||||
const mockFetch = installPerplexityChatFetch({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "ok",
|
||||
annotations: [
|
||||
{
|
||||
type: "url_citation",
|
||||
url_citation: { url: "https://example.com/a" },
|
||||
},
|
||||
{
|
||||
type: "url_citation",
|
||||
url_citation: { url: "https://example.com/b" },
|
||||
},
|
||||
{
|
||||
type: "url_citation",
|
||||
url_citation: { url: "https://example.com/a" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const tool = createPerplexitySearchTool();
|
||||
const result = await tool?.execute?.("call-1", { query: "test" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(result?.details).toMatchObject({
|
||||
provider: "perplexity",
|
||||
citations: ["https://example.com/a", "https://example.com/b"],
|
||||
content: expect.stringContaining("ok"),
|
||||
});
|
||||
});
|
||||
|
||||
it("fails loud for Search API-only filters on the compatibility path", async () => {
|
||||
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
|
||||
const mockFetch = installPerplexityChatFetch();
|
||||
@@ -489,20 +560,56 @@ describe("web_search perplexity OpenRouter compatibility", () => {
|
||||
expect(result?.details).toMatchObject({ error: "unsupported_domain_filter" });
|
||||
});
|
||||
|
||||
it("hides Search API-only schema params on the compatibility path", () => {
|
||||
it("keeps Search API schema params visible before runtime auth routing", () => {
|
||||
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
|
||||
const tool = createPerplexitySearchTool();
|
||||
const properties = (tool?.parameters as { properties?: Record<string, unknown> } | undefined)
|
||||
?.properties;
|
||||
|
||||
expect(properties?.freshness).toBeDefined();
|
||||
expect(properties?.country).toBeUndefined();
|
||||
expect(properties?.language).toBeUndefined();
|
||||
expect(properties?.date_after).toBeUndefined();
|
||||
expect(properties?.date_before).toBeUndefined();
|
||||
expect(properties?.domain_filter).toBeUndefined();
|
||||
expect(properties?.max_tokens).toBeUndefined();
|
||||
expect(properties?.max_tokens_per_page).toBeUndefined();
|
||||
expect(properties?.country).toBeDefined();
|
||||
expect(properties?.language).toBeDefined();
|
||||
expect(properties?.date_after).toBeDefined();
|
||||
expect(properties?.date_before).toBeDefined();
|
||||
expect(properties?.domain_filter).toBeDefined();
|
||||
expect(properties?.max_tokens).toBeDefined();
|
||||
expect(properties?.max_tokens_per_page).toBeDefined();
|
||||
expect(
|
||||
(
|
||||
properties?.country as
|
||||
| {
|
||||
description?: string;
|
||||
}
|
||||
| undefined
|
||||
)?.description,
|
||||
).toContain("Native Perplexity Search API only.");
|
||||
expect(
|
||||
(
|
||||
properties?.language as
|
||||
| {
|
||||
description?: string;
|
||||
}
|
||||
| undefined
|
||||
)?.description,
|
||||
).toContain("Native Perplexity Search API only.");
|
||||
expect(
|
||||
(
|
||||
properties?.date_after as
|
||||
| {
|
||||
description?: string;
|
||||
}
|
||||
| undefined
|
||||
)?.description,
|
||||
).toContain("Native Perplexity Search API only.");
|
||||
expect(
|
||||
(
|
||||
properties?.date_before as
|
||||
| {
|
||||
description?: string;
|
||||
}
|
||||
| undefined
|
||||
)?.description,
|
||||
).toContain("Native Perplexity Search API only.");
|
||||
});
|
||||
|
||||
it("keeps structured schema params on the native Search API path", () => {
|
||||
@@ -522,6 +629,61 @@ describe("web_search perplexity OpenRouter compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search Perplexity lazy resolution", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
global.fetch = priorFetch;
|
||||
});
|
||||
|
||||
it("does not read Perplexity credentials while creating non-Perplexity tools", () => {
|
||||
const perplexityConfig: Record<string, unknown> = {};
|
||||
Object.defineProperty(perplexityConfig, "apiKey", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error("perplexity-apiKey-getter-called");
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createWebSearchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "gemini",
|
||||
gemini: { apiKey: "gemini-config-test" },
|
||||
perplexity: perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: true,
|
||||
});
|
||||
|
||||
expect(tool?.name).toBe("web_search");
|
||||
});
|
||||
|
||||
it("defers Perplexity credential reads until execute", async () => {
|
||||
const perplexityConfig: Record<string, unknown> = {};
|
||||
Object.defineProperty(perplexityConfig, "apiKey", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error("perplexity-apiKey-getter-called");
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createPerplexitySearchTool(
|
||||
perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string },
|
||||
);
|
||||
|
||||
expect(tool?.name).toBe("web_search");
|
||||
await expect(tool?.execute?.("call-1", { query: "test" })).rejects.toThrow(
|
||||
/perplexity-apiKey-getter-called/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search kimi provider", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
@@ -694,7 +856,7 @@ describe("web_search external content wrapping", () => {
|
||||
const mockFetch = installBraveLlmContextFetch({
|
||||
title: "Context title",
|
||||
url: "https://example.com/ctx",
|
||||
snippets: [{ text: "Context chunk one" }, { text: "Context chunk two" }],
|
||||
snippets: ["Context chunk one", "Context chunk two"],
|
||||
});
|
||||
|
||||
const tool = createWebSearchTool({
|
||||
|
||||
@@ -445,8 +445,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
}
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
await onToolResult({
|
||||
...payload,
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import {
|
||||
hasAlreadyFlushedForCurrentCompaction,
|
||||
resolveMemoryFlushContextWindowTokens,
|
||||
resolveMemoryFlushRelativePathForRun,
|
||||
resolveMemoryFlushPromptForRun,
|
||||
resolveMemoryFlushSettings,
|
||||
shouldRunMemoryFlush,
|
||||
@@ -465,6 +466,11 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
});
|
||||
}
|
||||
let memoryCompactionCompleted = false;
|
||||
const memoryFlushNowMs = Date.now();
|
||||
const memoryFlushWritePath = resolveMemoryFlushRelativePathForRun({
|
||||
cfg: params.cfg,
|
||||
nowMs: memoryFlushNowMs,
|
||||
});
|
||||
const flushSystemPrompt = [
|
||||
params.followupRun.run.extraSystemPrompt,
|
||||
memoryFlushSettings.systemPrompt,
|
||||
@@ -495,9 +501,11 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
...senderContext,
|
||||
...runBaseParams,
|
||||
trigger: "memory",
|
||||
memoryFlushWritePath,
|
||||
prompt: resolveMemoryFlushPromptForRun({
|
||||
prompt: memoryFlushSettings.prompt,
|
||||
cfg: params.cfg,
|
||||
nowMs: memoryFlushNowMs,
|
||||
}),
|
||||
extraSystemPrompt: flushSystemPrompt,
|
||||
bootstrapPromptWarningSignaturesSeen,
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock("../../agents/agent-scope.js", () => ({
|
||||
}));
|
||||
|
||||
const {
|
||||
buildThreadingToolContext,
|
||||
buildEmbeddedRunBaseParams,
|
||||
buildEmbeddedRunContexts,
|
||||
resolveModelFallbackOptions,
|
||||
@@ -173,4 +174,44 @@ describe("agent-runner-utils", () => {
|
||||
expect(resolved.embeddedContext.messageProvider).toBe("telegram");
|
||||
expect(resolved.embeddedContext.messageTo).toBe("268300329");
|
||||
});
|
||||
|
||||
it("uses OriginatingTo for threading tool context on telegram native commands", () => {
|
||||
const context = buildThreadingToolContext({
|
||||
sessionCtx: {
|
||||
Provider: "telegram",
|
||||
To: "slash:8460800771",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:-1003841603622",
|
||||
MessageThreadId: 928,
|
||||
MessageSid: "2284",
|
||||
},
|
||||
config: { channels: { telegram: { allowFrom: ["*"] } } },
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(context).toMatchObject({
|
||||
currentChannelId: "telegram:-1003841603622",
|
||||
currentThreadTs: "928",
|
||||
currentMessageId: "2284",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses OriginatingTo for threading tool context on discord native commands", () => {
|
||||
const context = buildThreadingToolContext({
|
||||
sessionCtx: {
|
||||
Provider: "discord",
|
||||
To: "slash:1177378744822943744",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:123456789012345678",
|
||||
MessageSid: "msg-9",
|
||||
},
|
||||
config: {},
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(context).toMatchObject({
|
||||
currentChannelId: "channel:123456789012345678",
|
||||
currentMessageId: "msg-9",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,12 +23,20 @@ export function buildThreadingToolContext(params: {
|
||||
}): ChannelThreadingToolContext {
|
||||
const { sessionCtx, config, hasRepliedRef } = params;
|
||||
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
|
||||
const originProvider = resolveOriginMessageProvider({
|
||||
originatingChannel: sessionCtx.OriginatingChannel,
|
||||
provider: sessionCtx.Provider,
|
||||
});
|
||||
const originTo = resolveOriginMessageTo({
|
||||
originatingTo: sessionCtx.OriginatingTo,
|
||||
to: sessionCtx.To,
|
||||
});
|
||||
if (!config) {
|
||||
return {
|
||||
currentMessageId,
|
||||
};
|
||||
}
|
||||
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||
const rawProvider = originProvider?.trim().toLowerCase();
|
||||
if (!rawProvider) {
|
||||
return {
|
||||
currentMessageId,
|
||||
@@ -39,7 +47,7 @@ export function buildThreadingToolContext(params: {
|
||||
const dock = provider ? getChannelDock(provider) : undefined;
|
||||
if (!dock?.threading?.buildToolContext) {
|
||||
return {
|
||||
currentChannelId: sessionCtx.To?.trim() || undefined,
|
||||
currentChannelId: originTo?.trim() || undefined,
|
||||
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
||||
currentMessageId,
|
||||
hasRepliedRef,
|
||||
@@ -50,9 +58,9 @@ export function buildThreadingToolContext(params: {
|
||||
cfg: config,
|
||||
accountId: sessionCtx.AccountId,
|
||||
context: {
|
||||
Channel: sessionCtx.Provider,
|
||||
Channel: originProvider,
|
||||
From: sessionCtx.From,
|
||||
To: sessionCtx.To,
|
||||
To: originTo,
|
||||
ChatType: sessionCtx.ChatType,
|
||||
CurrentMessageId: currentMessageId,
|
||||
ReplyToId: sessionCtx.ReplyToId,
|
||||
|
||||
@@ -21,13 +21,14 @@ type AgentRunParams = {
|
||||
onAssistantMessageStart?: () => Promise<void> | void;
|
||||
onReasoningStream?: (payload: { text?: string }) => Promise<void> | void;
|
||||
onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
|
||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
|
||||
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
};
|
||||
|
||||
type EmbeddedRunParams = {
|
||||
prompt?: string;
|
||||
extraSystemPrompt?: string;
|
||||
memoryFlushWritePath?: string;
|
||||
bootstrapPromptWarningSignaturesSeen?: string[];
|
||||
bootstrapPromptWarningSignature?: string;
|
||||
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
|
||||
@@ -594,6 +595,40 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves channelData on forwarded tool results", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
|
||||
await params.onToolResult?.({
|
||||
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
||||
approvalSlug: "117ba06d",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
opts: { onToolResult },
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledWith({
|
||||
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
||||
approvalSlug: "117ba06d",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retries transient HTTP failures once with timer-driven backoff", async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
@@ -1577,9 +1612,14 @@ describe("runReplyAgent memory flush", () => {
|
||||
const flushCall = calls[0];
|
||||
expect(flushCall?.prompt).toContain("Write notes.");
|
||||
expect(flushCall?.prompt).toContain("NO_REPLY");
|
||||
expect(flushCall?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
|
||||
expect(flushCall?.prompt).toContain("MEMORY.md");
|
||||
expect(flushCall?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/);
|
||||
expect(flushCall?.extraSystemPrompt).toContain("extra system");
|
||||
expect(flushCall?.extraSystemPrompt).toContain("Flush memory now.");
|
||||
expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY");
|
||||
expect(flushCall?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md");
|
||||
expect(flushCall?.extraSystemPrompt).toContain("MEMORY.md");
|
||||
expect(calls[1]?.prompt).toBe("hello");
|
||||
});
|
||||
});
|
||||
@@ -1667,9 +1707,17 @@ describe("runReplyAgent memory flush", () => {
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
const calls: Array<{ prompt?: string }> = [];
|
||||
const calls: Array<{
|
||||
prompt?: string;
|
||||
extraSystemPrompt?: string;
|
||||
memoryFlushWritePath?: string;
|
||||
}> = [];
|
||||
state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
calls.push({ prompt: params.prompt });
|
||||
calls.push({
|
||||
prompt: params.prompt,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
memoryFlushWritePath: params.memoryFlushWritePath,
|
||||
});
|
||||
if (params.prompt?.includes("Pre-compaction memory flush.")) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
@@ -1696,6 +1744,10 @@ describe("runReplyAgent memory flush", () => {
|
||||
expect(calls[0]?.prompt).toContain("Pre-compaction memory flush.");
|
||||
expect(calls[0]?.prompt).toContain("Current time:");
|
||||
expect(calls[0]?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
|
||||
expect(calls[0]?.prompt).toContain("MEMORY.md");
|
||||
expect(calls[0]?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/);
|
||||
expect(calls[0]?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md");
|
||||
expect(calls[0]?.extraSystemPrompt).toContain("MEMORY.md");
|
||||
expect(calls[1]?.prompt).toBe("hello");
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
@@ -1952,3 +2004,4 @@ describe("runReplyAgent memory flush", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
isTelegramExecApprovalApprover,
|
||||
isTelegramExecApprovalClientEnabled,
|
||||
} from "../../telegram/exec-approvals.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
const COMMAND = "/approve";
|
||||
const COMMAND_REGEX = /^\/approve(?:\s|$)/i;
|
||||
const FOREIGN_COMMAND_MENTION_REGEX = /^\/approve@([^\s]+)(?:\s|$)/i;
|
||||
|
||||
const DECISION_ALIASES: Record<string, "allow-once" | "allow-always" | "deny"> = {
|
||||
allow: "allow-once",
|
||||
@@ -25,10 +30,14 @@ type ParsedApproveCommand =
|
||||
|
||||
function parseApproveCommand(raw: string): ParsedApproveCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith(COMMAND)) {
|
||||
if (FOREIGN_COMMAND_MENTION_REGEX.test(trimmed)) {
|
||||
return { ok: false, error: "❌ This /approve command targets a different Telegram bot." };
|
||||
}
|
||||
const commandMatch = trimmed.match(COMMAND_REGEX);
|
||||
if (!commandMatch) {
|
||||
return null;
|
||||
}
|
||||
const rest = trimmed.slice(COMMAND.length).trim();
|
||||
const rest = trimmed.slice(commandMatch[0].length).trim();
|
||||
if (!rest) {
|
||||
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
|
||||
}
|
||||
@@ -83,6 +92,29 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
||||
return { shouldContinue: false, reply: { text: parsed.error } };
|
||||
}
|
||||
|
||||
if (params.command.channel === "telegram") {
|
||||
if (
|
||||
!isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId })
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." },
|
||||
};
|
||||
}
|
||||
if (
|
||||
!isTelegramExecApprovalApprover({
|
||||
cfg: params.cfg,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ You are not authorized to approve exec requests on Telegram." },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const missingScope = requireGatewayClientScopeForInternalChannel(params, {
|
||||
label: "/approve",
|
||||
allowedScopes: ["operator.approvals", "operator.admin"],
|
||||
|
||||
@@ -26,6 +26,7 @@ export function buildCommandContext(params: {
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
const commandBodyNormalized = normalizeCommandBody(
|
||||
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized,
|
||||
{ botUsername: ctx.BotUsername },
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -105,27 +105,6 @@ vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
type ResetAcpSessionInPlaceResult = { ok: true } | { ok: false; skipped?: boolean; error?: string };
|
||||
|
||||
const resetAcpSessionInPlaceMock = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
async (_params: unknown): Promise<ResetAcpSessionInPlaceResult> => ({
|
||||
ok: false,
|
||||
skipped: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.mock("../../acp/persistent-bindings.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../acp/persistent-bindings.js")>(
|
||||
"../../acp/persistent-bindings.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resetAcpSessionInPlace: (params: unknown) => resetAcpSessionInPlaceMock(params),
|
||||
};
|
||||
});
|
||||
|
||||
import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.js";
|
||||
import type { HandleCommandsParams } from "./commands-types.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
|
||||
@@ -158,11 +137,6 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa
|
||||
return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAcpSessionInPlaceMock.mockReset();
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, skipped: true } as const);
|
||||
});
|
||||
|
||||
describe("handleCommands gating", () => {
|
||||
it("blocks gated commands when disabled or not elevated-allowlisted", async () => {
|
||||
const cases = typedCases<{
|
||||
@@ -316,6 +290,122 @@ describe("/approve command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts Telegram command mentions for /approve", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["*"],
|
||||
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve@bot abc12345 allow-once", cfg, {
|
||||
BotUsername: "bot",
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
});
|
||||
|
||||
callGatewayMock.mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc12345", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Telegram /approve mentions targeting a different bot", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["*"],
|
||||
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve@otherbot abc12345 allow-once", cfg, {
|
||||
BotUsername: "bot",
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
});
|
||||
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("targets a different Telegram bot");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces unknown or expired approval id errors", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["*"],
|
||||
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc12345 allow-once", cfg, {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
});
|
||||
|
||||
callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id"));
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("unknown or expired approval id");
|
||||
});
|
||||
|
||||
it("rejects Telegram /approve when telegram exec approvals are disabled", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { telegram: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc12345 allow-once", cfg, {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
});
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Telegram exec approvals are not enabled");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects Telegram /approve from non-approvers", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["*"],
|
||||
execApprovals: { enabled: true, approvers: ["999"], target: "dm" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc12345 allow-once", cfg, {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
});
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("not authorized to approve");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects gateway clients without approvals scope", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
@@ -1147,226 +1237,6 @@ describe("handleCommands hooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands ACP-bound /new and /reset", () => {
|
||||
const discordChannelId = "1478836151241412759";
|
||||
const buildDiscordBoundConfig = (): OpenClawConfig =>
|
||||
({
|
||||
commands: { text: true },
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: {
|
||||
kind: "channel",
|
||||
id: discordChannelId,
|
||||
},
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
},
|
||||
},
|
||||
],
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["*"],
|
||||
guilds: { "1459246755253325866": { channels: { [discordChannelId]: {} } } },
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig;
|
||||
|
||||
const buildDiscordBoundParams = (body: string) => {
|
||||
const params = buildParams(body, buildDiscordBoundConfig(), {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
AccountId: "default",
|
||||
SenderId: "12345",
|
||||
From: "discord:12345",
|
||||
To: discordChannelId,
|
||||
OriginatingTo: discordChannelId,
|
||||
SessionKey: "agent:main:acp:binding:discord:default:feedface",
|
||||
});
|
||||
params.sessionKey = "agent:main:acp:binding:discord:default:feedface";
|
||||
return params;
|
||||
};
|
||||
|
||||
it("handles /new as ACP in-place reset for bound conversations", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
|
||||
const result = await handleCommands(buildDiscordBoundParams("/new"));
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset in place");
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
reason: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("continues with trailing prompt text after successful ACP-bound /new", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
|
||||
const params = buildDiscordBoundParams("/new continue with deployment");
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply).toBeUndefined();
|
||||
const mutableCtx = params.ctx as Record<string, unknown>;
|
||||
expect(mutableCtx.BodyStripped).toBe("continue with deployment");
|
||||
expect(mutableCtx.CommandBody).toBe("continue with deployment");
|
||||
expect(mutableCtx.AcpDispatchTailAfterReset).toBe(true);
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles /reset failures without falling back to normal session reset flow", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
|
||||
const result = await handleCommands(buildDiscordBoundParams("/reset"));
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset failed");
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
reason: "reset",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit reset hooks when ACP reset fails", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
|
||||
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
|
||||
|
||||
const result = await handleCommands(buildDiscordBoundParams("/reset"));
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps existing /new behavior for non-ACP sessions", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const result = await handleCommands(buildParams("/new", cfg));
|
||||
|
||||
expect(result.shouldContinue).toBe(true);
|
||||
expect(resetAcpSessionInPlaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still targets configured ACP binding when runtime routing falls back to a non-ACP session", async () => {
|
||||
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
|
||||
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: discordChannelId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
const params = buildDiscordBoundParams("/new");
|
||||
params.sessionKey = fallbackSessionKey;
|
||||
params.ctx.SessionKey = fallbackSessionKey;
|
||||
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
|
||||
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset unavailable");
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionKey: configuredAcpSessionKey,
|
||||
reason: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits reset hooks for the ACP session key when routing falls back to non-ACP session", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
|
||||
const hookSpy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
|
||||
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
|
||||
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: discordChannelId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
const fallbackEntry = {
|
||||
sessionId: "fallback-session-id",
|
||||
sessionFile: "/tmp/fallback-session.jsonl",
|
||||
} as SessionEntry;
|
||||
const configuredEntry = {
|
||||
sessionId: "configured-acp-session-id",
|
||||
sessionFile: "/tmp/configured-acp-session.jsonl",
|
||||
} as SessionEntry;
|
||||
const params = buildDiscordBoundParams("/new");
|
||||
params.sessionKey = fallbackSessionKey;
|
||||
params.ctx.SessionKey = fallbackSessionKey;
|
||||
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
|
||||
params.sessionEntry = fallbackEntry;
|
||||
params.previousSessionEntry = fallbackEntry;
|
||||
params.sessionStore = {
|
||||
[fallbackSessionKey]: fallbackEntry,
|
||||
[configuredAcpSessionKey]: configuredEntry,
|
||||
};
|
||||
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset in place");
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "command",
|
||||
action: "new",
|
||||
sessionKey: configuredAcpSessionKey,
|
||||
context: expect.objectContaining({
|
||||
sessionEntry: configuredEntry,
|
||||
previousSessionEntry: configuredEntry,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
hookSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses active ACP command target when conversation binding context is missing", async () => {
|
||||
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
|
||||
const activeAcpTarget = "agent:codex:acp:binding:discord:default:feedface";
|
||||
const params = buildParams(
|
||||
"/new",
|
||||
{
|
||||
commands: { text: true },
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
{
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
AccountId: "default",
|
||||
SenderId: "12345",
|
||||
From: "discord:12345",
|
||||
},
|
||||
);
|
||||
params.sessionKey = "discord:slash:12345";
|
||||
params.ctx.SessionKey = "discord:slash:12345";
|
||||
params.ctx.CommandSource = "native";
|
||||
params.ctx.CommandTargetSessionKey = activeAcpTarget;
|
||||
params.ctx.To = "user:12345";
|
||||
params.ctx.OriginatingTo = "user:12345";
|
||||
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("ACP session reset in place");
|
||||
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionKey: activeAcpTarget,
|
||||
reason: "new",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands context", () => {
|
||||
it("returns expected details for /context commands", async () => {
|
||||
const cfg = {
|
||||
|
||||
@@ -543,6 +543,51 @@ describe("dispatchReplyFromConfig", () => {
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("delivers deterministic exec approval tool payloads in groups", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "telegram",
|
||||
ChatType: "group",
|
||||
});
|
||||
|
||||
const replyResolver = async (
|
||||
_ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
_cfg?: OpenClawConfig,
|
||||
) => {
|
||||
await opts?.onToolResult?.({
|
||||
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
||||
approvalSlug: "117ba06d",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
});
|
||||
return { text: "NO_REPLY" } satisfies ReplyPayload;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
||||
expect(firstToolResultPayload(dispatcher)).toEqual(
|
||||
expect.objectContaining({
|
||||
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
||||
approvalSlug: "117ba06d",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" });
|
||||
});
|
||||
|
||||
it("sends tool results via dispatcher in DM sessions", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
@@ -601,6 +646,50 @@ describe("dispatchReplyFromConfig", () => {
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("delivers deterministic exec approval tool payloads for native commands", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "telegram",
|
||||
CommandSource: "native",
|
||||
});
|
||||
|
||||
const replyResolver = async (
|
||||
_ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
_cfg?: OpenClawConfig,
|
||||
) => {
|
||||
await opts?.onToolResult?.({
|
||||
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
||||
approvalSlug: "117ba06d",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
});
|
||||
return { text: "NO_REPLY" } satisfies ReplyPayload;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
||||
expect(firstToolResultPayload(dispatcher)).toEqual(
|
||||
expect.objectContaining({
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
||||
approvalSlug: "117ba06d",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" });
|
||||
});
|
||||
|
||||
it("fast-aborts without calling the reply resolver", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: true,
|
||||
@@ -1539,6 +1628,47 @@ describe("dispatchReplyFromConfig", () => {
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("suppresses local discord exec approval tool prompts when discord approvals are enabled", async () => {
|
||||
setNoAbort();
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
AccountId: "default",
|
||||
});
|
||||
const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => {
|
||||
await options?.onToolResult?.({
|
||||
text: "Approval required.",
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
},
|
||||
});
|
||||
return { text: "done" } as ReplyPayload;
|
||||
});
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "done" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates same-agent inbound replies across main and direct session keys", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import { shouldSuppressLocalDiscordExecApprovalPrompt } from "../../discord/exec-approvals.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { fireAndForgetHook } from "../../hooks/fire-and-forget.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
@@ -365,9 +366,28 @@ export async function dispatchReplyFromConfig(params: {
|
||||
let blockCount = 0;
|
||||
|
||||
const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => {
|
||||
if (
|
||||
normalizeMessageChannel(ctx.Surface ?? ctx.Provider) === "discord" &&
|
||||
shouldSuppressLocalDiscordExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId: ctx.AccountId,
|
||||
payload,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (shouldSendToolSummaries) {
|
||||
return payload;
|
||||
}
|
||||
const execApproval =
|
||||
payload.channelData &&
|
||||
typeof payload.channelData === "object" &&
|
||||
!Array.isArray(payload.channelData)
|
||||
? payload.channelData.execApproval
|
||||
: undefined;
|
||||
if (execApproval && typeof execApproval === "object" && !Array.isArray(execApproval)) {
|
||||
return payload;
|
||||
}
|
||||
// Group/native flows intentionally suppress tool summary text, but media-only
|
||||
// tool results (for example TTS audio) must still be delivered.
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { DEFAULT_MEMORY_FLUSH_PROMPT, resolveMemoryFlushPromptForRun } from "./memory-flush.js";
|
||||
import {
|
||||
DEFAULT_MEMORY_FLUSH_PROMPT,
|
||||
resolveMemoryFlushPromptForRun,
|
||||
resolveMemoryFlushRelativePathForRun,
|
||||
} from "./memory-flush.js";
|
||||
|
||||
describe("resolveMemoryFlushPromptForRun", () => {
|
||||
const cfg = {
|
||||
@@ -35,6 +39,15 @@ describe("resolveMemoryFlushPromptForRun", () => {
|
||||
expect(prompt).toContain("Current time: already present");
|
||||
expect((prompt.match(/Current time:/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves the canonical relative memory path using user timezone", () => {
|
||||
const relativePath = resolveMemoryFlushRelativePathForRun({
|
||||
cfg,
|
||||
nowMs: Date.UTC(2026, 1, 16, 15, 0, 0),
|
||||
});
|
||||
|
||||
expect(relativePath).toBe("memory/2026-02-16.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_MEMORY_FLUSH_PROMPT", () => {
|
||||
|
||||
@@ -10,10 +10,23 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
|
||||
export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const MEMORY_FLUSH_TARGET_HINT =
|
||||
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).";
|
||||
const MEMORY_FLUSH_APPEND_ONLY_HINT =
|
||||
"If memory/YYYY-MM-DD.md already exists, APPEND new content only and do not overwrite existing entries.";
|
||||
const MEMORY_FLUSH_READ_ONLY_HINT =
|
||||
"Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.";
|
||||
const MEMORY_FLUSH_REQUIRED_HINTS = [
|
||||
MEMORY_FLUSH_TARGET_HINT,
|
||||
MEMORY_FLUSH_APPEND_ONLY_HINT,
|
||||
MEMORY_FLUSH_READ_ONLY_HINT,
|
||||
];
|
||||
|
||||
export const DEFAULT_MEMORY_FLUSH_PROMPT = [
|
||||
"Pre-compaction memory flush.",
|
||||
"Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed).",
|
||||
"IMPORTANT: If the file already exists, APPEND new content only — do not overwrite existing entries.",
|
||||
MEMORY_FLUSH_TARGET_HINT,
|
||||
MEMORY_FLUSH_READ_ONLY_HINT,
|
||||
MEMORY_FLUSH_APPEND_ONLY_HINT,
|
||||
"Do NOT create timestamped variant files (e.g., YYYY-MM-DD-HHMM.md); always use the canonical YYYY-MM-DD.md filename.",
|
||||
`If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
|
||||
].join(" ");
|
||||
@@ -21,6 +34,9 @@ export const DEFAULT_MEMORY_FLUSH_PROMPT = [
|
||||
export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
|
||||
"Pre-compaction memory flush turn.",
|
||||
"The session is near auto-compaction; capture durable memories to disk.",
|
||||
MEMORY_FLUSH_TARGET_HINT,
|
||||
MEMORY_FLUSH_READ_ONLY_HINT,
|
||||
MEMORY_FLUSH_APPEND_ONLY_HINT,
|
||||
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
|
||||
].join(" ");
|
||||
|
||||
@@ -40,14 +56,29 @@ function formatDateStampInTimezone(nowMs: number, timezone: string): string {
|
||||
return new Date(nowMs).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function resolveMemoryFlushRelativePathForRun(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
nowMs?: number;
|
||||
}): string {
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const { userTimezone } = resolveCronStyleNow(params.cfg ?? {}, nowMs);
|
||||
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
|
||||
return `memory/${dateStamp}.md`;
|
||||
}
|
||||
|
||||
export function resolveMemoryFlushPromptForRun(params: {
|
||||
prompt: string;
|
||||
cfg?: OpenClawConfig;
|
||||
nowMs?: number;
|
||||
}): string {
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const { userTimezone, timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs);
|
||||
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
|
||||
const { timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs);
|
||||
const dateStamp = resolveMemoryFlushRelativePathForRun({
|
||||
cfg: params.cfg,
|
||||
nowMs,
|
||||
})
|
||||
.replace(/^memory\//, "")
|
||||
.replace(/\.md$/, "");
|
||||
const withDate = params.prompt.replaceAll("YYYY-MM-DD", dateStamp).trimEnd();
|
||||
if (!withDate) {
|
||||
return timeLine;
|
||||
@@ -90,8 +121,12 @@ export function resolveMemoryFlushSettings(cfg?: OpenClawConfig): MemoryFlushSet
|
||||
const forceFlushTranscriptBytes =
|
||||
parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ??
|
||||
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES;
|
||||
const prompt = defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT;
|
||||
const systemPrompt = defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT;
|
||||
const prompt = ensureMemoryFlushSafetyHints(
|
||||
defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT,
|
||||
);
|
||||
const systemPrompt = ensureMemoryFlushSafetyHints(
|
||||
defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT,
|
||||
);
|
||||
const reserveTokensFloor =
|
||||
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
|
||||
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
|
||||
@@ -113,6 +148,16 @@ function ensureNoReplyHint(text: string): string {
|
||||
return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`;
|
||||
}
|
||||
|
||||
function ensureMemoryFlushSafetyHints(text: string): string {
|
||||
let next = text.trim();
|
||||
for (const hint of MEMORY_FLUSH_REQUIRED_HINTS) {
|
||||
if (!next.includes(hint)) {
|
||||
next = next ? `${next}\n\n${hint}` : hint;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resolveMemoryFlushContextWindowTokens(params: {
|
||||
modelId?: string;
|
||||
agentCfgContextTokens?: number;
|
||||
|
||||
@@ -203,6 +203,10 @@ describe("memory flush settings", () => {
|
||||
expect(settings?.forceFlushTranscriptBytes).toBe(DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES);
|
||||
expect(settings?.prompt.length).toBeGreaterThan(0);
|
||||
expect(settings?.systemPrompt.length).toBeGreaterThan(0);
|
||||
expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md");
|
||||
expect(settings?.prompt).toContain("MEMORY.md");
|
||||
expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md");
|
||||
expect(settings?.systemPrompt).toContain("MEMORY.md");
|
||||
});
|
||||
|
||||
it("respects disable flag", () => {
|
||||
@@ -230,6 +234,10 @@ describe("memory flush settings", () => {
|
||||
});
|
||||
expect(settings?.prompt).toContain("NO_REPLY");
|
||||
expect(settings?.systemPrompt).toContain("NO_REPLY");
|
||||
expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md");
|
||||
expect(settings?.prompt).toContain("MEMORY.md");
|
||||
expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md");
|
||||
expect(settings?.systemPrompt).toContain("MEMORY.md");
|
||||
});
|
||||
|
||||
it("falls back to defaults when numeric values are invalid", () => {
|
||||
|
||||
@@ -132,6 +132,8 @@ export type MsgContext = {
|
||||
Provider?: string;
|
||||
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
|
||||
Surface?: string;
|
||||
/** Platform bot username when command mentions should be normalized. */
|
||||
BotUsername?: string;
|
||||
WasMentioned?: boolean;
|
||||
CommandAuthorized?: boolean;
|
||||
CommandSource?: "text" | "native";
|
||||
|
||||
@@ -115,7 +115,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
quoteText,
|
||||
mediaLocalRoots,
|
||||
};
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
const result = await send(to, text, {
|
||||
...payloadOpts,
|
||||
|
||||
@@ -206,6 +206,119 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to local resolution for web search SecretRefs when gateway is unavailable", async () => {
|
||||
const envKey = "WEB_SEARCH_GEMINI_API_KEY_LOCAL_FALLBACK";
|
||||
const priorValue = process.env[envKey];
|
||||
process.env[envKey] = "gemini-local-fallback-key";
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "gemini",
|
||||
gemini: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "agent",
|
||||
targetIds: new Set(["tools.web.search.gemini.apiKey"]),
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe(
|
||||
"gemini-local-fallback-key",
|
||||
);
|
||||
expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("resolved_local");
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to local resolution for Firecrawl SecretRefs when gateway is unavailable", async () => {
|
||||
const envKey = "WEB_FETCH_FIRECRAWL_API_KEY_LOCAL_FALLBACK";
|
||||
const priorValue = process.env[envKey];
|
||||
process.env[envKey] = "firecrawl-local-fallback-key";
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
firecrawl: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "agent",
|
||||
targetIds: new Set(["tools.web.fetch.firecrawl.apiKey"]),
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.tools?.web?.fetch?.firecrawl?.apiKey).toBe(
|
||||
"firecrawl-local-fallback-key",
|
||||
);
|
||||
expect(result.targetStatesByPath["tools.web.fetch.firecrawl.apiKey"]).toBe("resolved_local");
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("marks web SecretRefs inactive when the web surface is disabled during local fallback", async () => {
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: false,
|
||||
gemini: {
|
||||
apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_DISABLED_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "agent",
|
||||
targetIds: new Set(["tools.web.search.gemini.apiKey"]),
|
||||
});
|
||||
|
||||
expect(result.hadUnresolvedTargets).toBe(false);
|
||||
expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("inactive_surface");
|
||||
expect(
|
||||
result.diagnostics.some((entry) =>
|
||||
entry.includes("tools.web.search.gemini.apiKey: tools.web.search is disabled."),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a version-skew hint when gateway does not support secrets.resolve", async () => {
|
||||
const envKey = "TALK_API_KEY_UNSUPPORTED";
|
||||
callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve"));
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getPath, setPathExistingStrict } from "../secrets/path-utils.js";
|
||||
import { resolveSecretRefValue } from "../secrets/resolve.js";
|
||||
import { collectConfigAssignments } from "../secrets/runtime-config-collectors.js";
|
||||
import { createResolverContext } from "../secrets/runtime-shared.js";
|
||||
import { resolveRuntimeWebTools } from "../secrets/runtime-web-tools.js";
|
||||
import { assertExpectedResolvedSecretValue } from "../secrets/secret-value.js";
|
||||
import { describeUnknownError } from "../secrets/shared.js";
|
||||
import {
|
||||
@@ -44,6 +45,15 @@ type GatewaySecretsResolveResult = {
|
||||
inactiveRefPaths?: string[];
|
||||
};
|
||||
|
||||
const WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES = [
|
||||
"tools.web.search",
|
||||
"tools.web.fetch.firecrawl",
|
||||
] as const;
|
||||
const WEB_RUNTIME_SECRET_PATH_PREFIXES = [
|
||||
"tools.web.search.",
|
||||
"tools.web.fetch.firecrawl.",
|
||||
] as const;
|
||||
|
||||
function dedupeDiagnostics(entries: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
@@ -58,6 +68,30 @@ function dedupeDiagnostics(entries: readonly string[]): string[] {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function targetsRuntimeWebPath(path: string): boolean {
|
||||
return WEB_RUNTIME_SECRET_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
|
||||
}
|
||||
|
||||
function targetsRuntimeWebResolution(params: {
|
||||
targetIds: ReadonlySet<string>;
|
||||
allowedPaths?: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (params.allowedPaths) {
|
||||
for (const path of params.allowedPaths) {
|
||||
if (targetsRuntimeWebPath(path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (const targetId of params.targetIds) {
|
||||
if (WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES.some((prefix) => targetId.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectConfiguredTargetRefPaths(params: {
|
||||
config: OpenClawConfig;
|
||||
targetIds: Set<string>;
|
||||
@@ -193,17 +227,40 @@ async function resolveCommandSecretRefsLocally(params: {
|
||||
sourceConfig,
|
||||
env: process.env,
|
||||
});
|
||||
const localResolutionDiagnostics: string[] = [];
|
||||
collectConfigAssignments({
|
||||
config: structuredClone(params.config),
|
||||
context,
|
||||
});
|
||||
if (
|
||||
targetsRuntimeWebResolution({ targetIds: params.targetIds, allowedPaths: params.allowedPaths })
|
||||
) {
|
||||
try {
|
||||
await resolveRuntimeWebTools({
|
||||
sourceConfig,
|
||||
resolvedConfig,
|
||||
context,
|
||||
});
|
||||
} catch (error) {
|
||||
if (params.mode === "strict") {
|
||||
throw error;
|
||||
}
|
||||
localResolutionDiagnostics.push(
|
||||
`${params.commandName}: failed to resolve web tool secrets locally (${describeUnknownError(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const inactiveRefPaths = new Set(
|
||||
context.warnings
|
||||
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
|
||||
.filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path))
|
||||
.map((warning) => warning.path),
|
||||
);
|
||||
const inactiveWarningDiagnostics = context.warnings
|
||||
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
|
||||
.filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path))
|
||||
.map((warning) => warning.message);
|
||||
const activePaths = new Set(context.assignments.map((assignment) => assignment.path));
|
||||
const localResolutionDiagnostics: string[] = [];
|
||||
for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) {
|
||||
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
|
||||
continue;
|
||||
@@ -244,6 +301,7 @@ async function resolveCommandSecretRefsLocally(params: {
|
||||
resolvedConfig,
|
||||
diagnostics: dedupeDiagnostics([
|
||||
...params.preflightDiagnostics,
|
||||
...inactiveWarningDiagnostics,
|
||||
...filterInactiveSurfaceDiagnostics({
|
||||
diagnostics: analyzed.diagnostics,
|
||||
inactiveRefPaths,
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("command secret target ids", () => {
|
||||
const ids = getAgentRuntimeCommandSecretTargetIds();
|
||||
expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
|
||||
expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true);
|
||||
expect(ids.has("tools.web.fetch.firecrawl.apiKey")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps memory command target set focused on memorySearch remote credentials", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ const COMMAND_SECRET_TARGETS = {
|
||||
"skills.entries.",
|
||||
"messages.tts.",
|
||||
"tools.web.search",
|
||||
"tools.web.fetch.firecrawl.",
|
||||
]),
|
||||
status: idsByPrefix([
|
||||
"channels.",
|
||||
|
||||
@@ -522,6 +522,12 @@ const CHANNELS_AGENTS_TARGET_KEYS = [
|
||||
"channels.telegram",
|
||||
"channels.telegram.botToken",
|
||||
"channels.telegram.capabilities.inlineButtons",
|
||||
"channels.telegram.execApprovals",
|
||||
"channels.telegram.execApprovals.enabled",
|
||||
"channels.telegram.execApprovals.approvers",
|
||||
"channels.telegram.execApprovals.agentFilter",
|
||||
"channels.telegram.execApprovals.sessionFilter",
|
||||
"channels.telegram.execApprovals.target",
|
||||
"channels.whatsapp",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1383,6 +1383,18 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected.",
|
||||
"channels.telegram.capabilities.inlineButtons":
|
||||
"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior.",
|
||||
"channels.telegram.execApprovals":
|
||||
"Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.",
|
||||
"channels.telegram.execApprovals.enabled":
|
||||
"Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.",
|
||||
"channels.telegram.execApprovals.approvers":
|
||||
"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.",
|
||||
"channels.telegram.execApprovals.agentFilter":
|
||||
'Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `["main", "ops-agent"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.',
|
||||
"channels.telegram.execApprovals.sessionFilter":
|
||||
"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.",
|
||||
"channels.telegram.execApprovals.target":
|
||||
'Controls where Telegram approval prompts are sent: "dm" sends to approver DMs (default), "channel" sends to the originating Telegram chat/topic, and "both" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.',
|
||||
"channels.slack.configWrites":
|
||||
"Allow Slack to write config in response to channel events/commands (default: true).",
|
||||
"channels.slack.botToken":
|
||||
|
||||
@@ -719,6 +719,12 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
|
||||
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
|
||||
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
|
||||
"channels.telegram.execApprovals": "Telegram Exec Approvals",
|
||||
"channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled",
|
||||
"channels.telegram.execApprovals.approvers": "Telegram Exec Approval Approvers",
|
||||
"channels.telegram.execApprovals.agentFilter": "Telegram Exec Approval Agent Filter",
|
||||
"channels.telegram.execApprovals.sessionFilter": "Telegram Exec Approval Session Filter",
|
||||
"channels.telegram.execApprovals.target": "Telegram Exec Approval Target",
|
||||
"channels.telegram.threadBindings.enabled": "Telegram Thread Binding Enabled",
|
||||
"channels.telegram.threadBindings.idleHours": "Telegram Thread Binding Idle Timeout (hours)",
|
||||
"channels.telegram.threadBindings.maxAgeHours": "Telegram Thread Binding Max Age (hours)",
|
||||
|
||||
@@ -38,6 +38,20 @@ export type TelegramNetworkConfig = {
|
||||
|
||||
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
|
||||
export type TelegramStreamingMode = "off" | "partial" | "block" | "progress";
|
||||
export type TelegramExecApprovalTarget = "dm" | "channel" | "both";
|
||||
|
||||
export type TelegramExecApprovalConfig = {
|
||||
/** Enable Telegram exec approvals for this account. Default: false. */
|
||||
enabled?: boolean;
|
||||
/** Telegram user IDs allowed to approve exec requests. Required if enabled. */
|
||||
approvers?: Array<string | number>;
|
||||
/** Only forward approvals for these agent IDs. Omit = all agents. */
|
||||
agentFilter?: string[];
|
||||
/** Only forward approvals matching these session key patterns (substring or regex). */
|
||||
sessionFilter?: string[];
|
||||
/** Where to send approval prompts. Default: "dm". */
|
||||
target?: TelegramExecApprovalTarget;
|
||||
};
|
||||
|
||||
export type TelegramCapabilitiesConfig =
|
||||
| string[]
|
||||
@@ -58,6 +72,8 @@ export type TelegramAccountConfig = {
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: TelegramCapabilitiesConfig;
|
||||
/** Telegram-native exec approval delivery + approver authorization. */
|
||||
execApprovals?: TelegramExecApprovalConfig;
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Override native command registration for Telegram (bool or "auto"). */
|
||||
|
||||
@@ -512,7 +512,7 @@ export type ToolsConfig = {
|
||||
/** Enable Firecrawl fallback (default: true when apiKey is set). */
|
||||
enabled?: boolean;
|
||||
/** Firecrawl API key (optional; defaults to FIRECRAWL_API_KEY env var). */
|
||||
apiKey?: string;
|
||||
apiKey?: SecretInput;
|
||||
/** Firecrawl base URL (default: https://api.firecrawl.dev). */
|
||||
baseUrl?: string;
|
||||
/** Whether to keep only main content (default: true). */
|
||||
|
||||
@@ -49,6 +49,7 @@ const DiscordIdSchema = z
|
||||
const DiscordIdListSchema = z.array(DiscordIdSchema);
|
||||
|
||||
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
|
||||
const TelegramIdListSchema = z.array(z.union([z.string(), z.number()]));
|
||||
|
||||
const TelegramCapabilitiesSchema = z.union([
|
||||
z.array(z.string()),
|
||||
@@ -153,6 +154,16 @@ export const TelegramAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: TelegramCapabilitiesSchema.optional(),
|
||||
execApprovals: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
approvers: TelegramIdListSchema.optional(),
|
||||
agentFilter: z.array(z.string()).optional(),
|
||||
sessionFilter: z.array(z.string()).optional(),
|
||||
target: z.enum(["dm", "channel", "both"]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
commands: ProviderCommandsSchema,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
|
||||
import { CronDeliverySchema } from "../gateway/protocol/schema.js";
|
||||
import { CronDeliverySchema, CronJobStateSchema } from "../gateway/protocol/schema.js";
|
||||
|
||||
type SchemaLike = {
|
||||
anyOf?: Array<SchemaLike>;
|
||||
@@ -29,6 +29,16 @@ function extractDeliveryModes(schema: SchemaLike): string[] {
|
||||
return Array.from(new Set(unionModes));
|
||||
}
|
||||
|
||||
function extractConstUnionValues(schema: SchemaLike): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(schema.anyOf ?? [])
|
||||
.map((entry) => entry?.const)
|
||||
.filter((value): value is string => typeof value === "string"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
|
||||
|
||||
const SWIFT_MODEL_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/CronModels.swift`];
|
||||
@@ -88,4 +98,19 @@ describe("cron protocol conformance", () => {
|
||||
expect(swift.includes("struct CronSchedulerStatus")).toBe(true);
|
||||
expect(swift.includes("let jobs:")).toBe(true);
|
||||
});
|
||||
|
||||
it("cron job state schema keeps the full failover reason set", () => {
|
||||
const properties = (CronJobStateSchema as SchemaLike).properties ?? {};
|
||||
const lastErrorReason = properties.lastErrorReason as SchemaLike | undefined;
|
||||
expect(lastErrorReason).toBeDefined();
|
||||
expect(extractConstUnionValues(lastErrorReason ?? {})).toEqual([
|
||||
"auth",
|
||||
"format",
|
||||
"rate_limit",
|
||||
"billing",
|
||||
"timeout",
|
||||
"model_not_found",
|
||||
"unknown",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveFailoverReasonFromError } from "../../agents/failover-error.js";
|
||||
import type { CronConfig, CronRetryOn } from "../../config/types.cron.js";
|
||||
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
|
||||
import { DEFAULT_AGENT_ID } from "../../routing/session-key.js";
|
||||
@@ -322,6 +323,10 @@ export function applyJobResult(
|
||||
job.state.lastStatus = result.status;
|
||||
job.state.lastDurationMs = Math.max(0, result.endedAt - result.startedAt);
|
||||
job.state.lastError = result.error;
|
||||
job.state.lastErrorReason =
|
||||
result.status === "error" && typeof result.error === "string"
|
||||
? (resolveFailoverReasonFromError(result.error) ?? undefined)
|
||||
: undefined;
|
||||
job.state.lastDelivered = result.delivered;
|
||||
const deliveryStatus = resolveDeliveryStatus({ job, delivered: result.delivered });
|
||||
job.state.lastDeliveryStatus = deliveryStatus;
|
||||
@@ -670,7 +675,6 @@ export async function onTimer(state: CronServiceState) {
|
||||
if (completedResults.length > 0) {
|
||||
await locked(state, async () => {
|
||||
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
|
||||
|
||||
for (const result of completedResults) {
|
||||
applyOutcomeToStoredJob(state, result);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FailoverReason } from "../agents/pi-embedded-helpers.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { CronJobBase } from "./types-shared.js";
|
||||
|
||||
@@ -105,7 +106,6 @@ type CronAgentTurnPayload = {
|
||||
type CronAgentTurnPayloadPatch = {
|
||||
kind: "agentTurn";
|
||||
} & Partial<CronAgentTurnPayloadFields>;
|
||||
|
||||
export type CronJobState = {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
@@ -115,6 +115,8 @@ export type CronJobState = {
|
||||
/** Back-compat alias for lastRunStatus. */
|
||||
lastStatus?: "ok" | "error" | "skipped";
|
||||
lastError?: string;
|
||||
/** Classified reason for the last error (when available). */
|
||||
lastErrorReason?: FailoverReason;
|
||||
lastDurationMs?: number;
|
||||
/** Number of consecutive execution errors (reset on success). Used for backoff. */
|
||||
consecutiveErrors?: number;
|
||||
|
||||
23
src/discord/exec-approvals.ts
Normal file
23
src/discord/exec-approvals.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
|
||||
export function isDiscordExecApprovalClientEnabled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
const config = resolveDiscordAccount(params).config.execApprovals;
|
||||
return Boolean(config?.enabled && (config.approvers?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
export function shouldSuppressLocalDiscordExecApprovalPrompt(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
payload: ReplyPayload;
|
||||
}): boolean {
|
||||
return (
|
||||
isDiscordExecApprovalClientEnabled(params) &&
|
||||
getExecApprovalReplyMetadata(params.payload) !== null
|
||||
);
|
||||
}
|
||||
@@ -470,15 +470,15 @@ describe("ExecApprovalButton", () => {
|
||||
|
||||
function createMockInteraction(userId: string) {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const update = vi.fn().mockResolvedValue(undefined);
|
||||
const acknowledge = vi.fn().mockResolvedValue(undefined);
|
||||
const followUp = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = {
|
||||
userId,
|
||||
reply,
|
||||
update,
|
||||
acknowledge,
|
||||
followUp,
|
||||
} as unknown as ButtonInteraction;
|
||||
return { interaction, reply, update, followUp };
|
||||
return { interaction, reply, acknowledge, followUp };
|
||||
}
|
||||
|
||||
it("denies unauthorized users with ephemeral message", async () => {
|
||||
@@ -486,7 +486,7 @@ describe("ExecApprovalButton", () => {
|
||||
const ctx: ExecApprovalButtonContext = { handler };
|
||||
const button = new ExecApprovalButton(ctx);
|
||||
|
||||
const { interaction, reply, update } = createMockInteraction("999");
|
||||
const { interaction, reply, acknowledge } = createMockInteraction("999");
|
||||
const data: ComponentData = { id: "test-approval", action: "allow-once" };
|
||||
|
||||
await button.run(interaction, data);
|
||||
@@ -495,7 +495,7 @@ describe("ExecApprovalButton", () => {
|
||||
content: "⛔ You are not authorized to approve exec requests.",
|
||||
ephemeral: true,
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(acknowledge).not.toHaveBeenCalled();
|
||||
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
|
||||
expect(handler.resolveApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -505,50 +505,45 @@ describe("ExecApprovalButton", () => {
|
||||
const ctx: ExecApprovalButtonContext = { handler };
|
||||
const button = new ExecApprovalButton(ctx);
|
||||
|
||||
const { interaction, reply, update } = createMockInteraction("222");
|
||||
const { interaction, reply, acknowledge } = createMockInteraction("222");
|
||||
const data: ComponentData = { id: "test-approval", action: "allow-once" };
|
||||
|
||||
await button.run(interaction, data);
|
||||
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
content: "Submitting decision: **Allowed (once)**...",
|
||||
components: [],
|
||||
});
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1);
|
||||
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
|
||||
expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-once");
|
||||
});
|
||||
|
||||
it("shows correct label for allow-always", async () => {
|
||||
it("acknowledges allow-always interactions before resolving", async () => {
|
||||
const handler = createMockHandler(["111"]);
|
||||
const ctx: ExecApprovalButtonContext = { handler };
|
||||
const button = new ExecApprovalButton(ctx);
|
||||
|
||||
const { interaction, update } = createMockInteraction("111");
|
||||
const { interaction, acknowledge } = createMockInteraction("111");
|
||||
const data: ComponentData = { id: "test-approval", action: "allow-always" };
|
||||
|
||||
await button.run(interaction, data);
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
content: "Submitting decision: **Allowed (always)**...",
|
||||
components: [],
|
||||
});
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1);
|
||||
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
|
||||
expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-always");
|
||||
});
|
||||
|
||||
it("shows correct label for deny", async () => {
|
||||
it("acknowledges deny interactions before resolving", async () => {
|
||||
const handler = createMockHandler(["111"]);
|
||||
const ctx: ExecApprovalButtonContext = { handler };
|
||||
const button = new ExecApprovalButton(ctx);
|
||||
|
||||
const { interaction, update } = createMockInteraction("111");
|
||||
const { interaction, acknowledge } = createMockInteraction("111");
|
||||
const data: ComponentData = { id: "test-approval", action: "deny" };
|
||||
|
||||
await button.run(interaction, data);
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
content: "Submitting decision: **Denied**...",
|
||||
components: [],
|
||||
});
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1);
|
||||
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
|
||||
expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "deny");
|
||||
});
|
||||
|
||||
it("handles invalid data gracefully", async () => {
|
||||
@@ -556,18 +551,20 @@ describe("ExecApprovalButton", () => {
|
||||
const ctx: ExecApprovalButtonContext = { handler };
|
||||
const button = new ExecApprovalButton(ctx);
|
||||
|
||||
const { interaction, update } = createMockInteraction("111");
|
||||
const { interaction, acknowledge, reply } = createMockInteraction("111");
|
||||
const data: ComponentData = { id: "", action: "invalid" };
|
||||
|
||||
await button.run(interaction, data);
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
expect(reply).toHaveBeenCalledWith({
|
||||
content: "This approval is no longer valid.",
|
||||
components: [],
|
||||
ephemeral: true,
|
||||
});
|
||||
expect(acknowledge).not.toHaveBeenCalled();
|
||||
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
|
||||
expect(handler.resolveApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("follows up with error when resolve fails", async () => {
|
||||
const handler = createMockHandler(["111"]);
|
||||
handler.resolveApproval = vi.fn().mockResolvedValue(false);
|
||||
@@ -581,7 +578,7 @@ describe("ExecApprovalButton", () => {
|
||||
|
||||
expect(followUp).toHaveBeenCalledWith({
|
||||
content:
|
||||
"Failed to submit approval decision. The request may have expired or already been resolved.",
|
||||
"Failed to submit approval decision for **Allowed (once)**. The request may have expired or already been resolved.",
|
||||
ephemeral: true,
|
||||
});
|
||||
});
|
||||
@@ -596,14 +593,14 @@ describe("ExecApprovalButton", () => {
|
||||
const ctx: ExecApprovalButtonContext = { handler };
|
||||
const button = new ExecApprovalButton(ctx);
|
||||
|
||||
const { interaction, update, reply } = createMockInteraction("111");
|
||||
const { interaction, acknowledge, reply } = createMockInteraction("111");
|
||||
const data: ComponentData = { id: "test-approval", action: "allow-once" };
|
||||
|
||||
await button.run(interaction, data);
|
||||
|
||||
// Should match because getApprovers returns [111] and button does String(id) === userId
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
expect(update).toHaveBeenCalled();
|
||||
expect(acknowledge).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -803,6 +800,80 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
|
||||
|
||||
clearPendingTimeouts(handler);
|
||||
});
|
||||
|
||||
it("posts an in-channel note when target is dm and the request came from a non-DM discord conversation", async () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
target: "dm",
|
||||
});
|
||||
const internals = getHandlerInternals(handler);
|
||||
|
||||
mockRestPost.mockImplementation(
|
||||
async (route: string, params?: { body?: { content?: string } }) => {
|
||||
if (route === Routes.channelMessages("999888777")) {
|
||||
expect(params?.body?.content).toContain("I sent the allowed approvers DMs");
|
||||
return { id: "note-1", channel_id: "999888777" };
|
||||
}
|
||||
if (route === Routes.userChannels()) {
|
||||
return { id: "dm-1" };
|
||||
}
|
||||
if (route === Routes.channelMessages("dm-1")) {
|
||||
return { id: "msg-1", channel_id: "dm-1" };
|
||||
}
|
||||
throw new Error(`unexpected route: ${route}`);
|
||||
},
|
||||
);
|
||||
|
||||
await internals.handleApprovalRequested(createRequest());
|
||||
|
||||
expect(mockRestPost).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("999888777"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
content: expect.stringContaining("I sent the allowed approvers DMs"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockRestPost).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("dm-1"),
|
||||
expect.objectContaining({
|
||||
body: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
|
||||
clearPendingTimeouts(handler);
|
||||
});
|
||||
|
||||
it("does not post an in-channel note when the request already came from a discord DM", async () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
target: "dm",
|
||||
});
|
||||
const internals = getHandlerInternals(handler);
|
||||
|
||||
mockRestPost.mockImplementation(async (route: string) => {
|
||||
if (route === Routes.userChannels()) {
|
||||
return { id: "dm-1" };
|
||||
}
|
||||
if (route === Routes.channelMessages("dm-1")) {
|
||||
return { id: "msg-1", channel_id: "dm-1" };
|
||||
}
|
||||
throw new Error(`unexpected route: ${route}`);
|
||||
});
|
||||
|
||||
await internals.handleApprovalRequested(
|
||||
createRequest({ sessionKey: "agent:main:discord:dm:123" }),
|
||||
);
|
||||
|
||||
expect(mockRestPost).not.toHaveBeenCalledWith(
|
||||
Routes.channelMessages("999888777"),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
clearPendingTimeouts(handler);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordExecApprovalHandler gateway auth resolution", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { buildGatewayConnectionDetails } from "../../gateway/call.js";
|
||||
import { GatewayClient } from "../../gateway/client.js";
|
||||
import { resolveGatewayConnectionAuth } from "../../gateway/connection-auth.js";
|
||||
import type { EventFrame } from "../../gateway/protocol/index.js";
|
||||
import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js";
|
||||
import type {
|
||||
ExecApprovalDecision,
|
||||
ExecApprovalRequest,
|
||||
@@ -47,6 +48,12 @@ export function extractDiscordChannelId(sessionKey?: string | null): string | nu
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function buildDiscordApprovalDmRedirectNotice(): { content: string } {
|
||||
return {
|
||||
content: getExecApprovalApproverDmNoticeText(),
|
||||
};
|
||||
}
|
||||
|
||||
type PendingApproval = {
|
||||
discordMessageId: string;
|
||||
discordChannelId: string;
|
||||
@@ -498,6 +505,24 @@ export class DiscordExecApprovalHandler {
|
||||
const sendToDm = target === "dm" || target === "both";
|
||||
const sendToChannel = target === "channel" || target === "both";
|
||||
let fallbackToDm = false;
|
||||
const originatingChannelId =
|
||||
request.request.sessionKey && target === "dm"
|
||||
? extractDiscordChannelId(request.request.sessionKey)
|
||||
: null;
|
||||
|
||||
if (target === "dm" && originatingChannelId) {
|
||||
try {
|
||||
await discordRequest(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(originatingChannelId), {
|
||||
body: buildDiscordApprovalDmRedirectNotice(),
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"send-approval-dm-redirect-notice",
|
||||
);
|
||||
} catch (err) {
|
||||
logError(`discord exec approvals: failed to send DM redirect notice: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send to originating channel if configured
|
||||
if (sendToChannel) {
|
||||
@@ -768,9 +793,9 @@ export class ExecApprovalButton extends Button {
|
||||
const parsed = parseExecApprovalData(data);
|
||||
if (!parsed) {
|
||||
try {
|
||||
await interaction.update({
|
||||
await interaction.reply({
|
||||
content: "This approval is no longer valid.",
|
||||
components: [],
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
@@ -800,12 +825,11 @@ export class ExecApprovalButton extends Button {
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
|
||||
// Update the message immediately to show the decision
|
||||
// Acknowledge immediately so Discord does not fail the interaction while
|
||||
// the gateway resolve roundtrip completes. The resolved event will update
|
||||
// the approval card in-place with the final state.
|
||||
try {
|
||||
await interaction.update({
|
||||
content: `Submitting decision: **${decisionLabel}**...`,
|
||||
components: [], // Remove buttons
|
||||
});
|
||||
await interaction.acknowledge();
|
||||
} catch {
|
||||
// Interaction may have expired, try to continue anyway
|
||||
}
|
||||
@@ -815,8 +839,7 @@ export class ExecApprovalButton extends Button {
|
||||
if (!ok) {
|
||||
try {
|
||||
await interaction.followUp({
|
||||
content:
|
||||
"Failed to submit approval decision. The request may have expired or already been resolved.",
|
||||
content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`,
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -31,6 +31,11 @@ type PendingEntry = {
|
||||
promise: Promise<ExecApprovalDecision | null>;
|
||||
};
|
||||
|
||||
export type ExecApprovalIdLookupResult =
|
||||
| { kind: "exact" | "prefix"; id: string }
|
||||
| { kind: "ambiguous"; ids: string[] }
|
||||
| { kind: "none" };
|
||||
|
||||
export class ExecApprovalManager {
|
||||
private pending = new Map<string, PendingEntry>();
|
||||
|
||||
@@ -170,4 +175,37 @@ export class ExecApprovalManager {
|
||||
const entry = this.pending.get(recordId);
|
||||
return entry?.promise ?? null;
|
||||
}
|
||||
|
||||
lookupPendingId(input: string): ExecApprovalIdLookupResult {
|
||||
const normalized = input.trim();
|
||||
if (!normalized) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
const exact = this.pending.get(normalized);
|
||||
if (exact) {
|
||||
return exact.record.resolvedAtMs === undefined
|
||||
? { kind: "exact", id: normalized }
|
||||
: { kind: "none" };
|
||||
}
|
||||
|
||||
const lowerPrefix = normalized.toLowerCase();
|
||||
const matches: string[] = [];
|
||||
for (const [id, entry] of this.pending.entries()) {
|
||||
if (entry.record.resolvedAtMs !== undefined) {
|
||||
continue;
|
||||
}
|
||||
if (id.toLowerCase().startsWith(lowerPrefix)) {
|
||||
matches.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 1) {
|
||||
return { kind: "prefix", id: matches[0] };
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return { kind: "ambiguous", ids: matches };
|
||||
}
|
||||
return { kind: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type SystemRunParamsLike = {
|
||||
approved?: unknown;
|
||||
approvalDecision?: unknown;
|
||||
runId?: unknown;
|
||||
suppressNotifyOnExit?: unknown;
|
||||
};
|
||||
|
||||
type ApprovalLookup = {
|
||||
@@ -78,6 +79,7 @@ function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unkno
|
||||
"agentId",
|
||||
"sessionKey",
|
||||
"runId",
|
||||
"suppressNotifyOnExit",
|
||||
]) {
|
||||
if (key in raw) {
|
||||
next[key] = raw[key];
|
||||
|
||||
@@ -56,6 +56,15 @@ const CronDeliveryStatusSchema = Type.Union([
|
||||
Type.Literal("unknown"),
|
||||
Type.Literal("not-requested"),
|
||||
]);
|
||||
const CronFailoverReasonSchema = Type.Union([
|
||||
Type.Literal("auth"),
|
||||
Type.Literal("format"),
|
||||
Type.Literal("rate_limit"),
|
||||
Type.Literal("billing"),
|
||||
Type.Literal("timeout"),
|
||||
Type.Literal("model_not_found"),
|
||||
Type.Literal("unknown"),
|
||||
]);
|
||||
const CronCommonOptionalFields = {
|
||||
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
sessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
@@ -219,6 +228,7 @@ export const CronJobStateSchema = Type.Object(
|
||||
lastRunStatus: Type.Optional(CronRunStatusSchema),
|
||||
lastStatus: Type.Optional(CronRunStatusSchema),
|
||||
lastError: Type.Optional(Type.String()),
|
||||
lastErrorReason: Type.Optional(CronFailoverReasonSchema),
|
||||
lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
consecutiveErrors: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lastDelivered: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -19,14 +19,6 @@ export function createExecApprovalHandlers(
|
||||
manager: ExecApprovalManager,
|
||||
opts?: { forwarder?: ExecApprovalForwarder },
|
||||
): GatewayRequestHandlers {
|
||||
const hasApprovalClients = (context: { hasExecApprovalClients?: () => boolean }) => {
|
||||
if (typeof context.hasExecApprovalClients === "function") {
|
||||
return context.hasExecApprovalClients();
|
||||
}
|
||||
// Fail closed when no operator-scope probe is available.
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
"exec.approval.request": async ({ params, respond, context, client }) => {
|
||||
if (!validateExecApprovalRequestParams(params)) {
|
||||
@@ -178,10 +170,11 @@ export function createExecApprovalHandlers(
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
let forwardedToTargets = false;
|
||||
const hasExecApprovalClients = context.hasExecApprovalClients?.() ?? false;
|
||||
let forwarded = false;
|
||||
if (opts?.forwarder) {
|
||||
try {
|
||||
forwardedToTargets = await opts.forwarder.handleRequested({
|
||||
forwarded = await opts.forwarder.handleRequested({
|
||||
id: record.id,
|
||||
request: record.request,
|
||||
createdAtMs: record.createdAtMs,
|
||||
@@ -192,8 +185,19 @@ export function createExecApprovalHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasApprovalClients(context) && !forwardedToTargets) {
|
||||
manager.expire(record.id, "auto-expire:no-approver-clients");
|
||||
if (!hasExecApprovalClients && !forwarded) {
|
||||
manager.expire(record.id, "no-approval-route");
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
id: record.id,
|
||||
decision: null,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only send immediate "accepted" response when twoPhase is requested.
|
||||
@@ -275,21 +279,48 @@ export function createExecApprovalHandlers(
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
|
||||
return;
|
||||
}
|
||||
const snapshot = manager.getSnapshot(p.id);
|
||||
const resolvedId = manager.lookupPendingId(p.id);
|
||||
if (resolvedId.kind === "none") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolvedId.kind === "ambiguous") {
|
||||
const candidates = resolvedId.ids.slice(0, 3).join(", ");
|
||||
const remainder = resolvedId.ids.length > 3 ? ` (+${resolvedId.ids.length - 3} more)` : "";
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`ambiguous approval id prefix; matches: ${candidates}${remainder}. Use the full id.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const approvalId = resolvedId.id;
|
||||
const snapshot = manager.getSnapshot(approvalId);
|
||||
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
|
||||
const ok = manager.resolve(p.id, decision, resolvedBy ?? null);
|
||||
const ok = manager.resolve(approvalId, decision, resolvedBy ?? null);
|
||||
if (!ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id"));
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.broadcast(
|
||||
"exec.approval.resolved",
|
||||
{ id: p.id, decision, resolvedBy, ts: Date.now(), request: snapshot?.request },
|
||||
{ id: approvalId, decision, resolvedBy, ts: Date.now(), request: snapshot?.request },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
void opts?.forwarder
|
||||
?.handleResolved({
|
||||
id: p.id,
|
||||
id: approvalId,
|
||||
decision,
|
||||
resolvedBy,
|
||||
ts: Date.now(),
|
||||
|
||||
@@ -531,6 +531,19 @@ describe("exec approval handlers", () => {
|
||||
expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reuse a resolved exact id as a prefix for another pending approval", () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const resolvedRecord = manager.create({ command: "echo old", host: "gateway" }, 2_000, "abc");
|
||||
void manager.register(resolvedRecord, 2_000);
|
||||
expect(manager.resolve("abc", "allow-once")).toBe(true);
|
||||
|
||||
const pendingRecord = manager.create({ command: "echo new", host: "gateway" }, 2_000, "abcdef");
|
||||
void manager.register(pendingRecord, 2_000);
|
||||
|
||||
expect(manager.lookupPendingId("abc")).toEqual({ kind: "none" });
|
||||
expect(manager.lookupPendingId("abcdef")).toEqual({ kind: "exact", id: "abcdef" });
|
||||
});
|
||||
|
||||
it("stores versioned system.run binding and sorted env keys on approval request", async () => {
|
||||
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
||||
await requestExecApproval({
|
||||
@@ -666,6 +679,134 @@ describe("exec approval handlers", () => {
|
||||
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
});
|
||||
|
||||
it("accepts unique short approval id prefixes", async () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const handlers = createExecApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
const context = {
|
||||
broadcast: (_event: string, _payload: unknown) => {},
|
||||
};
|
||||
|
||||
const record = manager.create({ command: "echo ok" }, 60_000, "approval-12345678-aaaa");
|
||||
void manager.register(record, 60_000);
|
||||
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id: "approval-1234",
|
||||
respond,
|
||||
context,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once");
|
||||
});
|
||||
|
||||
it("rejects ambiguous short approval id prefixes", async () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const handlers = createExecApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
const context = {
|
||||
broadcast: (_event: string, _payload: unknown) => {},
|
||||
};
|
||||
|
||||
void manager.register(
|
||||
manager.create({ command: "echo one" }, 60_000, "approval-abcd-1111"),
|
||||
60_000,
|
||||
);
|
||||
void manager.register(
|
||||
manager.create({ command: "echo two" }, 60_000, "approval-abcd-2222"),
|
||||
60_000,
|
||||
);
|
||||
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id: "approval-abcd",
|
||||
respond,
|
||||
context,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("ambiguous approval id prefix"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns deterministic unknown/expired message for missing approval ids", async () => {
|
||||
const { handlers, respond, context } = createExecApprovalFixture();
|
||||
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id: "missing-approval-id",
|
||||
respond,
|
||||
context,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
message: "unknown or expired approval id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves only the targeted approval id when multiple requests are pending", async () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const handlers = createExecApprovalHandlers(manager);
|
||||
const context = {
|
||||
broadcast: (_event: string, _payload: unknown) => {},
|
||||
hasExecApprovalClients: () => true,
|
||||
};
|
||||
const respondOne = vi.fn();
|
||||
const respondTwo = vi.fn();
|
||||
|
||||
const requestOne = requestExecApproval({
|
||||
handlers,
|
||||
respond: respondOne,
|
||||
context,
|
||||
params: { id: "approval-one", host: "gateway", timeoutMs: 60_000 },
|
||||
});
|
||||
const requestTwo = requestExecApproval({
|
||||
handlers,
|
||||
respond: respondTwo,
|
||||
context,
|
||||
params: { id: "approval-two", host: "gateway", timeoutMs: 60_000 },
|
||||
});
|
||||
|
||||
await drainApprovalRequestTicks();
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id: "approval-one",
|
||||
respond: resolveRespond,
|
||||
context,
|
||||
});
|
||||
|
||||
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
expect(manager.getSnapshot("approval-one")?.decision).toBe("allow-once");
|
||||
expect(manager.getSnapshot("approval-two")?.decision).toBeUndefined();
|
||||
expect(manager.getSnapshot("approval-two")?.resolvedAtMs).toBeUndefined();
|
||||
|
||||
expect(manager.expire("approval-two", "test-expire")).toBe(true);
|
||||
await requestOne;
|
||||
await requestTwo;
|
||||
|
||||
expect(respondOne).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ id: "approval-one", decision: "allow-once" }),
|
||||
undefined,
|
||||
);
|
||||
expect(respondTwo).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ id: "approval-two", decision: null }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards turn-source metadata to exec approval forwarding", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -703,32 +844,59 @@ describe("exec approval handlers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("expires immediately when no approver clients and no forwarding targets", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, handlers, forwarder, respond, context } =
|
||||
createForwardingExecApprovalFixture();
|
||||
const expireSpy = vi.spyOn(manager, "expire");
|
||||
it("fast-fails approvals when no approver clients and no forwarding targets", async () => {
|
||||
const { manager, handlers, forwarder, respond, context } =
|
||||
createForwardingExecApprovalFixture();
|
||||
const expireSpy = vi.spyOn(manager, "expire");
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: { timeoutMs: 60_000 },
|
||||
});
|
||||
await drainApprovalRequestTicks();
|
||||
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
||||
expect(expireSpy).toHaveBeenCalledTimes(1);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await requestPromise;
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ decision: null }),
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: { timeoutMs: 60_000, id: "approval-no-approver", host: "gateway" },
|
||||
});
|
||||
|
||||
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
||||
expect(expireSpy).toHaveBeenCalledWith("approval-no-approver", "no-approval-route");
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ id: "approval-no-approver", decision: null }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps approvals pending when no approver clients but forwarding accepted the request", async () => {
|
||||
const { manager, handlers, forwarder, respond, context } =
|
||||
createForwardingExecApprovalFixture();
|
||||
const expireSpy = vi.spyOn(manager, "expire");
|
||||
const resolveRespond = vi.fn();
|
||||
forwarder.handleRequested.mockResolvedValueOnce(true);
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: { timeoutMs: 60_000, id: "approval-forwarded", host: "gateway" },
|
||||
});
|
||||
await drainApprovalRequestTicks();
|
||||
|
||||
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
||||
expect(expireSpy).not.toHaveBeenCalled();
|
||||
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id: "approval-forwarded",
|
||||
respond: resolveRespond,
|
||||
context,
|
||||
});
|
||||
await requestPromise;
|
||||
|
||||
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ id: "approval-forwarded", decision: "allow-once" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -492,6 +492,23 @@ describe("notifications changed events", () => {
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(2);
|
||||
expect(requestHeartbeatNowMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("suppresses exec notifyOnExit events when payload opts out", async () => {
|
||||
const ctx = buildCtx();
|
||||
await handleNodeEvent(ctx, "node-n7", {
|
||||
event: "exec.finished",
|
||||
payloadJSON: JSON.stringify({
|
||||
sessionKey: "agent:main:main",
|
||||
runId: "approval-1",
|
||||
exitCode: 0,
|
||||
output: "ok",
|
||||
suppressNotifyOnExit: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent request events", () => {
|
||||
|
||||
@@ -538,6 +538,9 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
if (!notifyOnExit) {
|
||||
return;
|
||||
}
|
||||
if (obj.suppressNotifyOnExit === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = typeof obj.runId === "string" ? obj.runId.trim() : "";
|
||||
const command = typeof obj.command === "string" ? obj.command.trim() : "";
|
||||
|
||||
@@ -175,12 +175,14 @@ describe("gateway hot reload", () => {
|
||||
let prevSkipGmail: string | undefined;
|
||||
let prevSkipProviders: string | undefined;
|
||||
let prevOpenAiApiKey: string | undefined;
|
||||
let prevGeminiApiKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
|
||||
prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
prevOpenAiApiKey = process.env.OPENAI_API_KEY;
|
||||
prevGeminiApiKey = process.env.GEMINI_API_KEY;
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "0";
|
||||
delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
delete process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
@@ -207,6 +209,11 @@ describe("gateway hot reload", () => {
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = prevOpenAiApiKey;
|
||||
}
|
||||
if (prevGeminiApiKey === undefined) {
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
} else {
|
||||
process.env.GEMINI_API_KEY = prevGeminiApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
async function writeEnvRefConfig() {
|
||||
@@ -328,6 +335,34 @@ describe("gateway hot reload", () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function writeWebSearchGeminiRefConfig() {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is not set");
|
||||
}
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "gemini",
|
||||
gemini: {
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function removeMainAuthProfileStore() {
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
@@ -540,6 +575,64 @@ describe("gateway hot reload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits one-shot degraded and recovered system events for web search secret reload transitions", async () => {
|
||||
await writeWebSearchGeminiRefConfig();
|
||||
process.env.GEMINI_API_KEY = "gemini-startup-key"; // pragma: allowlist secret
|
||||
|
||||
await withGatewayServer(async () => {
|
||||
const onHotReload = hoisted.getOnHotReload();
|
||||
expect(onHotReload).toBeTypeOf("function");
|
||||
const sessionKey = resolveMainSessionKeyFromConfig();
|
||||
const plan = {
|
||||
changedPaths: ["tools.web.search.gemini.apiKey"],
|
||||
restartGateway: false,
|
||||
restartReasons: [],
|
||||
hotReasons: ["tools.web.search.gemini.apiKey"],
|
||||
reloadHooks: false,
|
||||
restartGmailWatcher: false,
|
||||
restartBrowserControl: false,
|
||||
restartCron: false,
|
||||
restartHeartbeat: false,
|
||||
restartChannels: new Set(),
|
||||
noopPaths: [],
|
||||
};
|
||||
const nextConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "gemini",
|
||||
gemini: {
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow(
|
||||
"[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]",
|
||||
);
|
||||
const degradedEvents = drainSystemEvents(sessionKey);
|
||||
expect(degradedEvents.some((event) => event.includes("[SECRETS_RELOADER_DEGRADED]"))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow(
|
||||
"[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]",
|
||||
);
|
||||
expect(drainSystemEvents(sessionKey)).toEqual([]);
|
||||
|
||||
process.env.GEMINI_API_KEY = "gemini-recovered-key"; // pragma: allowlist secret
|
||||
await expect(onHotReload?.(plan, nextConfig)).resolves.toBeUndefined();
|
||||
const recoveredEvents = drainSystemEvents(sessionKey);
|
||||
expect(recoveredEvents.some((event) => event.includes("[SECRETS_RELOADER_RECOVERED]"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("serves secrets.reload immediately after startup without race failures", async () => {
|
||||
await writeEnvRefConfig();
|
||||
process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { telegramOutbound } from "../channels/plugins/outbound/telegram.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
|
||||
|
||||
const baseRequest = {
|
||||
@@ -18,8 +21,18 @@ const baseRequest = {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const emptyRegistry = createTestRegistry([]);
|
||||
const defaultRegistry = createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
|
||||
function getFirstDeliveryText(deliver: ReturnType<typeof vi.fn>): string {
|
||||
const firstCall = deliver.mock.calls[0]?.[0] as
|
||||
| { payloads?: Array<{ text?: string }> }
|
||||
@@ -32,7 +45,7 @@ const TARGETS_CFG = {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
targets: [{ channel: "slack", to: "U123" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
@@ -128,6 +141,14 @@ async function expectSessionFilterRequestResult(params: {
|
||||
}
|
||||
|
||||
describe("exec approval forwarder", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(defaultRegistry);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
});
|
||||
|
||||
it("forwards to session target and resolves", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = {
|
||||
@@ -159,19 +180,118 @@ describe("exec approval forwarder", () => {
|
||||
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
|
||||
|
||||
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
|
||||
await Promise.resolve();
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(deliver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips telegram forwarding when telegram exec approvals handler is enabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = {
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "session",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
target: "channel",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { deliver, forwarder } = createForwarder({
|
||||
cfg,
|
||||
resolveSessionTarget: () => ({ channel: "telegram", to: "-100999", threadId: 77 }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
forwarder.handleRequested({
|
||||
...baseRequest,
|
||||
request: {
|
||||
...baseRequest.request,
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "-100999",
|
||||
turnSourceThreadId: "77",
|
||||
turnSourceAccountId: "default",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches explicit telegram buttons in forwarded telegram fallback payloads", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = {
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { deliver, forwarder } = createForwarder({ cfg });
|
||||
|
||||
await expect(
|
||||
forwarder.handleRequested({
|
||||
...baseRequest,
|
||||
request: {
|
||||
...baseRequest.request,
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceTo: "channel:123",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
expect(deliver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
payloads: [
|
||||
expect.objectContaining({
|
||||
channelData: {
|
||||
execApproval: expect.objectContaining({
|
||||
approvalId: "req-1",
|
||||
}),
|
||||
telegram: {
|
||||
buttons: [
|
||||
[
|
||||
{ text: "Allow Once", callback_data: "/approve req-1 allow-once" },
|
||||
{ text: "Allow Always", callback_data: "/approve req-1 allow-always" },
|
||||
],
|
||||
[{ text: "Deny", callback_data: "/approve req-1 deny" }],
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats single-line commands as inline code", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
|
||||
|
||||
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getFirstDeliveryText(deliver)).toContain("Command: `echo hello`");
|
||||
const text = getFirstDeliveryText(deliver);
|
||||
expect(text).toContain("🔒 Exec approval required");
|
||||
expect(text).toContain("Command: `echo hello`");
|
||||
expect(text).toContain("Expires in: 5s");
|
||||
expect(text).toContain("Reply with: /approve <id> allow-once|allow-always|deny");
|
||||
});
|
||||
|
||||
it("formats complex commands as fenced code blocks", async () => {
|
||||
@@ -187,8 +307,9 @@ describe("exec approval forwarder", () => {
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```");
|
||||
expect(getFirstDeliveryText(deliver)).toContain("```\necho `uname`\necho done\n```");
|
||||
});
|
||||
|
||||
it("returns false when forwarding is disabled", async () => {
|
||||
@@ -334,7 +455,8 @@ describe("exec approval forwarder", () => {
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getFirstDeliveryText(deliver)).toContain("Command:\n````\necho ```danger```\n````");
|
||||
expect(getFirstDeliveryText(deliver)).toContain("````\necho ```danger```\n````");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
@@ -8,11 +9,14 @@ import type {
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js";
|
||||
import { buildTelegramExecApprovalButtons } from "../telegram/approval-buttons.js";
|
||||
import { sendTypingTelegram } from "../telegram/send.js";
|
||||
import {
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
type DeliverableMessageChannel,
|
||||
} from "../utils/message-channel.js";
|
||||
import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js";
|
||||
import type {
|
||||
ExecApprovalDecision,
|
||||
ExecApprovalRequest,
|
||||
@@ -65,7 +69,11 @@ function matchSessionFilter(sessionKey: string, patterns: string[]): boolean {
|
||||
}
|
||||
|
||||
function shouldForward(params: {
|
||||
config?: ExecApprovalForwardingConfig;
|
||||
config?: {
|
||||
enabled?: boolean;
|
||||
agentFilter?: string[];
|
||||
sessionFilter?: string[];
|
||||
};
|
||||
request: ExecApprovalRequest;
|
||||
}): boolean {
|
||||
const config = params.config;
|
||||
@@ -147,6 +155,48 @@ function shouldSkipDiscordForwarding(
|
||||
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
function shouldSkipTelegramForwarding(params: {
|
||||
target: ExecApprovalForwardTarget;
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
}): boolean {
|
||||
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
|
||||
if (channel !== "telegram") {
|
||||
return false;
|
||||
}
|
||||
const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? "");
|
||||
if (requestChannel !== "telegram") {
|
||||
return false;
|
||||
}
|
||||
const telegram = params.cfg.channels?.telegram;
|
||||
if (!telegram) {
|
||||
return false;
|
||||
}
|
||||
const telegramConfig = telegram as
|
||||
| {
|
||||
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
|
||||
accounts?: Record<
|
||||
string,
|
||||
{ execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
|
||||
>;
|
||||
}
|
||||
| undefined;
|
||||
if (!telegramConfig) {
|
||||
return false;
|
||||
}
|
||||
const accountId =
|
||||
params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim();
|
||||
const account = accountId
|
||||
? (resolveChannelAccountConfig<{
|
||||
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
|
||||
}>(telegramConfig.accounts, accountId) as
|
||||
| { execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
|
||||
| undefined)
|
||||
: undefined;
|
||||
const execApprovals = account?.execApprovals ?? telegramConfig.execApprovals;
|
||||
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
function formatApprovalCommand(command: string): { inline: boolean; text: string } {
|
||||
if (!command.includes("\n") && !command.includes("`")) {
|
||||
return { inline: true, text: `\`${command}\`` };
|
||||
@@ -191,6 +241,10 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
|
||||
}
|
||||
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
|
||||
lines.push(`Expires in: ${expiresIn}s`);
|
||||
lines.push("Mode: foreground (interactive approvals available in this chat).");
|
||||
lines.push(
|
||||
"Background mode note: non-interactive runs cannot wait for chat approvals; use pre-approved policy (allow-always or ask=off).",
|
||||
);
|
||||
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -261,7 +315,7 @@ function defaultResolveSessionTarget(params: {
|
||||
async function deliverToTargets(params: {
|
||||
cfg: OpenClawConfig;
|
||||
targets: ForwardTarget[];
|
||||
text: string;
|
||||
buildPayload: (target: ForwardTarget) => ReplyPayload;
|
||||
deliver: typeof deliverOutboundPayloads;
|
||||
shouldSend?: () => boolean;
|
||||
}) {
|
||||
@@ -274,13 +328,33 @@ async function deliverToTargets(params: {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = params.buildPayload(target);
|
||||
if (
|
||||
channel === "telegram" &&
|
||||
payload.channelData &&
|
||||
typeof payload.channelData === "object" &&
|
||||
!Array.isArray(payload.channelData) &&
|
||||
payload.channelData.execApproval
|
||||
) {
|
||||
const threadId =
|
||||
typeof target.threadId === "number"
|
||||
? target.threadId
|
||||
: typeof target.threadId === "string"
|
||||
? Number.parseInt(target.threadId, 10)
|
||||
: undefined;
|
||||
await sendTypingTelegram(target.to, {
|
||||
cfg: params.cfg,
|
||||
accountId: target.accountId,
|
||||
...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
await params.deliver({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
to: target.to,
|
||||
accountId: target.accountId,
|
||||
threadId: target.threadId,
|
||||
payloads: [{ text: params.text }],
|
||||
payloads: [payload],
|
||||
});
|
||||
} catch (err) {
|
||||
log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`);
|
||||
@@ -289,6 +363,42 @@ async function deliverToTargets(params: {
|
||||
await Promise.allSettled(deliveries);
|
||||
}
|
||||
|
||||
function buildRequestPayloadForTarget(
|
||||
_cfg: OpenClawConfig,
|
||||
request: ExecApprovalRequest,
|
||||
nowMsValue: number,
|
||||
target: ForwardTarget,
|
||||
): ReplyPayload {
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
if (channel === "telegram") {
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: request.id,
|
||||
approvalSlug: request.id.slice(0, 8),
|
||||
approvalCommandId: request.id,
|
||||
command: request.request.command,
|
||||
cwd: request.request.cwd ?? undefined,
|
||||
host: request.request.host === "node" ? "node" : "gateway",
|
||||
nodeId: request.request.nodeId ?? undefined,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
nowMs: nowMsValue,
|
||||
});
|
||||
const buttons = buildTelegramExecApprovalButtons(request.id);
|
||||
if (!buttons) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
channelData: {
|
||||
...payload.channelData,
|
||||
telegram: {
|
||||
buttons,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { text: buildRequestMessage(request, nowMsValue) };
|
||||
}
|
||||
|
||||
function resolveForwardTargets(params: {
|
||||
cfg: OpenClawConfig;
|
||||
config?: ExecApprovalForwardingConfig;
|
||||
@@ -343,15 +453,20 @@ export function createExecApprovalForwarder(
|
||||
const handleRequested = async (request: ExecApprovalRequest): Promise<boolean> => {
|
||||
const cfg = getConfig();
|
||||
const config = cfg.approvals?.exec;
|
||||
if (!shouldForward({ config, request })) {
|
||||
return false;
|
||||
}
|
||||
const filteredTargets = resolveForwardTargets({
|
||||
cfg,
|
||||
config,
|
||||
request,
|
||||
resolveSessionTarget,
|
||||
}).filter((target) => !shouldSkipDiscordForwarding(target, cfg));
|
||||
const filteredTargets = [
|
||||
...(shouldForward({ config, request })
|
||||
? resolveForwardTargets({
|
||||
cfg,
|
||||
config,
|
||||
request,
|
||||
resolveSessionTarget,
|
||||
})
|
||||
: []),
|
||||
].filter(
|
||||
(target) =>
|
||||
!shouldSkipDiscordForwarding(target, cfg) &&
|
||||
!shouldSkipTelegramForwarding({ target, cfg, request }),
|
||||
);
|
||||
|
||||
if (filteredTargets.length === 0) {
|
||||
return false;
|
||||
@@ -366,7 +481,12 @@ export function createExecApprovalForwarder(
|
||||
}
|
||||
pending.delete(request.id);
|
||||
const expiredText = buildExpiredMessage(request);
|
||||
await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver });
|
||||
await deliverToTargets({
|
||||
cfg,
|
||||
targets: entry.targets,
|
||||
buildPayload: () => ({ text: expiredText }),
|
||||
deliver,
|
||||
});
|
||||
})();
|
||||
}, expiresInMs);
|
||||
timeoutId.unref?.();
|
||||
@@ -377,12 +497,10 @@ export function createExecApprovalForwarder(
|
||||
if (pending.get(request.id) !== pendingEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = buildRequestMessage(request, nowMs());
|
||||
void deliverToTargets({
|
||||
cfg,
|
||||
targets: filteredTargets,
|
||||
text,
|
||||
buildPayload: (target) => buildRequestPayloadForTarget(cfg, request, nowMs(), target),
|
||||
deliver,
|
||||
shouldSend: () => pending.get(request.id) === pendingEntry,
|
||||
}).catch((err) => {
|
||||
@@ -410,20 +528,26 @@ export function createExecApprovalForwarder(
|
||||
expiresAtMs: resolved.ts,
|
||||
};
|
||||
const config = cfg.approvals?.exec;
|
||||
if (shouldForward({ config, request })) {
|
||||
targets = resolveForwardTargets({
|
||||
cfg,
|
||||
config,
|
||||
request,
|
||||
resolveSessionTarget,
|
||||
}).filter((target) => !shouldSkipDiscordForwarding(target, cfg));
|
||||
}
|
||||
targets = [
|
||||
...(shouldForward({ config, request })
|
||||
? resolveForwardTargets({
|
||||
cfg,
|
||||
config,
|
||||
request,
|
||||
resolveSessionTarget,
|
||||
})
|
||||
: []),
|
||||
].filter(
|
||||
(target) =>
|
||||
!shouldSkipDiscordForwarding(target, cfg) &&
|
||||
!shouldSkipTelegramForwarding({ target, cfg, request }),
|
||||
);
|
||||
}
|
||||
if (!targets || targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const text = buildResolvedMessage(resolved);
|
||||
await deliverToTargets({ cfg, targets, text, deliver });
|
||||
await deliverToTargets({ cfg, targets, buildPayload: () => ({ text }), deliver });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user