refactor(config): config-surface reduction tranche 1 — retire dead keys, dedupe channel schemas, add growth ratchet (#111142)

* refactor(config): retire dead and aliased config keys via doctor migrations

* refactor(config): dedupe bundled channel config schemas into shared builders

* feat(config): add config-surface count ratchet to doc-baseline check

* test(config): drop stale fixtures for retired config keys

* fix(doctor): migrate only positive finite MCP timeout aliases

* fix(migrate-hermes): emit canonical MCP timeouts only

* fix(config): satisfy lint and contract gates
This commit is contained in:
Peter Steinberger
2026-07-19 00:52:37 -07:00
committed by GitHub
parent 33b50089cd
commit 58452de711
212 changed files with 2782 additions and 3166 deletions
+2 -1
View File
@@ -266,8 +266,9 @@ ui/src/ui/__screenshots__
ui/src/ui/views/__screenshots__ ui/src/ui/views/__screenshots__
ui/.vitest-attachments ui/.vitest-attachments
# Generated docs baseline artifacts (locally generated, only hashes tracked) # Generated docs baseline artifacts (locally generated, only drift detectors tracked)
docs/.generated/*.json docs/.generated/*.json
!docs/.generated/config-baseline.counts.json
docs/.generated/*.jsonl docs/.generated/*.jsonl
# Deprecated changelog fragment workflow # Deprecated changelog fragment workflow
+1
View File
@@ -6,6 +6,7 @@ remain local inspection artifacts.
**Tracked (committed to git):** **Tracked (committed to git):**
- `config-baseline.sha256` — hashes of config baseline JSON artifacts. - `config-baseline.sha256` — hashes of config baseline JSON artifacts.
- `config-baseline.counts.json` — maximum entry counts for each config baseline kind.
- `plugin-sdk-api-baseline.sha256` — one hash per Plugin SDK entrypoint. - `plugin-sdk-api-baseline.sha256` — one hash per Plugin SDK entrypoint.
- `sqlite-session-transcript-schema-baseline.sha256` — hash of the sessions/transcripts SQLite schema baseline. - `sqlite-session-transcript-schema-baseline.sha256` — hash of the sessions/transcripts SQLite schema baseline.
@@ -0,0 +1,5 @@
{
"core": 3007,
"channel": 3732,
"plugin": 3537
}
+4 -4
View File
@@ -1,4 +1,4 @@
53e7eea9a0193d94f5afe8e774456ae71cd3eddf99e5224602fcf13bb845051b config-baseline.json 2f4ba51e354995c0d28052d7c61210ace881145af0db7f5409f8eb7dff167a4f config-baseline.json
97c9ec219366ad2fab4d83d5b05eb3ae3df71db68747e7b31cc697a76676c620 config-baseline.core.json 0e9d04a669b853f8b58397d2dfc8913b5a3dd70af20ebd43e25b08be4cd3d292 config-baseline.core.json
f75efa4f3fa4ac7c2e7dc6580a1dd1efaa8e2e13ec7df9c21838c8ebc44a3dca config-baseline.channel.json c624c55cb46a67db4c836dcc04d295db966579885a00778d1a32241632de3475 config-baseline.channel.json
e4f096d6d3c39fefdd1649cab8336b8dfba1f2194eb804fa6126eb25fe2d2b93 config-baseline.plugin.json 0e0592b95e4958477e539abd5ccb336c63735c7d69133199953c48ff3c0845a8 config-baseline.plugin.json
+1 -1
View File
@@ -199,7 +199,7 @@ Notes:
- Group keys must be stable space ids (`spaces/<spaceId>`). Display-name keys are deprecated and logged as such. - Group keys must be stable space ids (`spaces/<spaceId>`). Display-name keys are deprecated and logged as such.
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode); doctor warns about email entries. - `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode); doctor warns about email entries.
- Google Chat reaction actions are not exposed. The plugin uses service-account authentication, while Google Chat reaction endpoints require user authentication. Existing `actions.reactions` config is accepted for compatibility but has no effect. - Google Chat reaction actions are not exposed. The plugin uses service-account authentication, while Google Chat reaction endpoints require user authentication. Existing `actions.reactions` config is accepted for compatibility but has no effect.
- Native approval cards use Google Chat `cardsV2` button clicks, not reaction events. Approvers come from `dm.allowFrom` or `defaultTo` and must be stable numeric `users/<id>` values. - Native approval cards use Google Chat `cardsV2` button clicks, not reaction events. Approvers come from `allowFrom` or `defaultTo` and must be stable numeric `users/<id>` values.
- Message actions expose text `send` only. Google Chat attachment upload requires user authentication, while this plugin uses service-account authentication, so outbound file upload is not exposed. - Message actions expose text `send` only. Google Chat attachment upload requires user authentication, while this plugin uses service-account authentication, so outbound file upload is not exposed.
- `typingIndicator`: `message` (default) posts a `_<Bot> is typing..._` placeholder and edits it into the first reply; `none` disables it; `reaction` requires user OAuth and currently falls back to `message` with a logged error under service-account auth. - `typingIndicator`: `message` (default) posts a `_<Bot> is typing..._` placeholder and edits it into the first reply; `none` disables it; `reaction` requires user OAuth and currently falls back to `message` with a logged error under service-account auth.
- Inbound attachments (first attachment per message) are downloaded through the Chat API into the media pipeline, capped by `mediaMaxMb` (default 20). - Inbound attachments (first attachment per message) are downloaded through the Chat API into the media pipeline, capped by `mediaMaxMb` (default 20).
+1 -1
View File
@@ -218,7 +218,7 @@ Retention behavior:
## Migrating older jobs ## Migrating older jobs
<Note> <Note>
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates `notify: true` webhook fallback jobs from `cron.webhook` to explicit webhook delivery. Jobs that already announce to a chat keep that delivery and get a completion webhook destination. When `cron.webhook` is unset, the inert top-level `notify` marker is removed for jobs with no migration target (the existing delivery is preserved unchanged), so `doctor --fix` no longer keeps re-warning about them. If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates `notify: true` webhook fallback jobs from the retired raw `cron.webhook` value to explicit webhook delivery before removing that config key. Jobs that already announce to a chat keep that delivery and get a completion webhook destination. Without a legacy webhook, the inert top-level `notify` marker is removed for jobs with no migration target (the existing delivery is preserved unchanged), so `doctor --fix` no longer keeps re-warning about them.
</Note> </Note>
## Common edits ## Common edits
+27 -29
View File
@@ -368,7 +368,7 @@ Those saved definitions are for runtimes that OpenClaw launches or configures la
- `add` builds a definition from flags and probes before saving unless `--no-probe` is set or OAuth authorization is needed first - `add` builds a definition from flags and probes before saving unless `--no-probe` is set or OAuth authorization is needed first
- runtime adapters decide which transport shapes they actually support at execution time - runtime adapters decide which transport shapes they actually support at execution time
- `enabled: false` keeps a server saved but excludes it from embedded runtime discovery - `enabled: false` keeps a server saved but excludes it from embedded runtime discovery
- `timeout` and `connectTimeout` set per-server request and connection timeouts in seconds - `requestTimeoutMs` and `connectionTimeoutMs` set per-server request and connection timeouts in milliseconds
- `supportsParallelToolCalls: true` marks servers that adapters can call concurrently - `supportsParallelToolCalls: true` marks servers that adapters can call concurrently
- HTTP servers can use static headers, OAuth login, TLS verification control, and mTLS certificate/key paths - HTTP servers can use static headers, OAuth login, TLS verification control, and mTLS certificate/key paths
- embedded OpenClaw exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly - embedded OpenClaw exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly
@@ -620,8 +620,8 @@ Example config shape:
"docs": { "docs": {
"url": "https://mcp.example.com", "url": "https://mcp.example.com",
"transport": "streamable-http", "transport": "streamable-http",
"timeout": 20, "requestTimeoutMs": 20000,
"connectTimeout": 5, "connectionTimeoutMs": 5000,
"supportsParallelToolCalls": true, "supportsParallelToolCalls": true,
"auth": "oauth", "auth": "oauth",
"oauth": { "oauth": {
@@ -663,17 +663,16 @@ If your MCP server genuinely needs one of the blocked variables, set it on the g
Connects to a remote MCP server over HTTP Server-Sent Events. Connects to a remote MCP server over HTTP Server-Sent Events.
| Field | Description | | Field | Description |
| ------------------------------ | ---------------------------------------------------------------- | | --------------------------- | ---------------------------------------------------------------- |
| `url` | HTTP or HTTPS URL of the remote server (required) | | `url` | HTTP or HTTPS URL of the remote server (required) |
| `headers` | Optional key-value map of HTTP headers (for example auth tokens) | | `headers` | Optional key-value map of HTTP headers (for example auth tokens) |
| `connectionTimeoutMs` | Per-server connection timeout in ms (optional) | | `connectionTimeoutMs` | Per-server connection timeout in ms (optional) |
| `connectTimeout` | Per-server connection timeout in seconds (optional) | | `requestTimeoutMs` | Per-server MCP request timeout in milliseconds |
| `timeout` / `requestTimeoutMs` | Per-server MCP request timeout in seconds or ms | | `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` |
| `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` | | `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints |
| `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints | | `clientCert` / `clientKey` | mTLS client certificate and key paths |
| `clientCert` / `clientKey` | mTLS client certificate and key paths | | `supportsParallelToolCalls` | Hint that concurrent calls are safe for this server |
| `supportsParallelToolCalls` | Hint that concurrent calls are safe for this server |
Example: Example:
@@ -684,7 +683,7 @@ Example:
"remote-tools": { "remote-tools": {
"url": "https://mcp.example.com", "url": "https://mcp.example.com",
"auth": "oauth", "auth": "oauth",
"timeout": 20, "requestTimeoutMs": 20000,
"headers": { "headers": {
"Authorization": "Bearer <token>" "Authorization": "Bearer <token>"
} }
@@ -768,18 +767,17 @@ If the provider rotates tokens or the authorization state gets stuck, run `openc
`streamable-http` is an additional transport option alongside `sse` and `stdio`. It uses HTTP streaming for bidirectional communication with remote MCP servers. `streamable-http` is an additional transport option alongside `sse` and `stdio`. It uses HTTP streaming for bidirectional communication with remote MCP servers.
| Field | Description | | Field | Description |
| ------------------------------ | -------------------------------------------------------------------------------------- | | --------------------------- | -------------------------------------------------------------------------------------- |
| `url` | HTTP or HTTPS URL of the remote server (required) | | `url` | HTTP or HTTPS URL of the remote server (required) |
| `transport` | Set to `"streamable-http"` to select this transport; when omitted, OpenClaw uses `sse` | | `transport` | Set to `"streamable-http"` to select this transport; when omitted, OpenClaw uses `sse` |
| `headers` | Optional key-value map of HTTP headers (for example auth tokens) | | `headers` | Optional key-value map of HTTP headers (for example auth tokens) |
| `connectionTimeoutMs` | Per-server connection timeout in ms (optional) | | `connectionTimeoutMs` | Per-server connection timeout in ms (optional) |
| `connectTimeout` | Per-server connection timeout in seconds (optional) | | `requestTimeoutMs` | Per-server MCP request timeout in milliseconds |
| `timeout` / `requestTimeoutMs` | Per-server MCP request timeout in seconds or ms | | `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` |
| `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` | | `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints |
| `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints | | `clientCert` / `clientKey` | mTLS client certificate and key paths |
| `clientCert` / `clientKey` | mTLS client certificate and key paths | | `supportsParallelToolCalls` | Hint that concurrent calls are safe for this server |
| `supportsParallelToolCalls` | Hint that concurrent calls are safe for this server |
OpenClaw config uses `transport: "streamable-http"` as the canonical spelling. CLI-native MCP `type: "http"` values are accepted when saved through `openclaw mcp set` and repaired by `openclaw doctor --fix` in existing config, but `transport` is what embedded OpenClaw consumes directly. OpenClaw config uses `transport: "streamable-http"` as the canonical spelling. CLI-native MCP `type: "http"` values are accepted when saved through `openclaw mcp set` and repaired by `openclaw doctor --fix` in existing config, but `transport` is what embedded OpenClaw consumes directly.
@@ -792,8 +790,8 @@ Example:
"streaming-tools": { "streaming-tools": {
"url": "https://mcp.example.com/stream", "url": "https://mcp.example.com/stream",
"transport": "streamable-http", "transport": "streamable-http",
"connectTimeout": 10, "connectionTimeoutMs": 10000,
"timeout": 30, "requestTimeoutMs": 30000,
"headers": { "headers": {
"Authorization": "Bearer <token>" "Authorization": "Bearer <token>"
} }
-3
View File
@@ -45,9 +45,6 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with
- Launched from inside a configured agent workspace directory, TUI auto-selects - Launched from inside a configured agent workspace directory, TUI auto-selects
that agent for the session key default (unless `--session` is explicitly that agent for the session key default (unless `--session` is explicitly
`agent:<id>:...`). `agent:<id>:...`).
- To show the Gateway hostname in the footer for non-local URL-backed
connections, run `openclaw config set tui.footer.showRemoteHost true`. Off by
default; never shown for loopback or embedded local connections.
- Local mode uses the embedded agent runtime directly. Most local tools work, - Local mode uses the embedded agent runtime directly. Most local tools work,
but Gateway-only features are unavailable. but Gateway-only features are unavailable.
- Local mode adds `/auth [provider]` to the TUI command surface. - Local mode adds `/auth [provider]` to the TUI command surface.
+1 -1
View File
@@ -1433,7 +1433,7 @@ Batches rapid text-only messages from the same sender into a single agent turn.
### Other message keys ### Other message keys
- `messages.messagePrefix`: prefix text prepended to inbound user messages before they reach the agent runtime. Use sparingly for channel context markers. - `channels.whatsapp.messagePrefix`: WhatsApp-only prefix prepended to inbound user messages before they reach the agent runtime.
- `messages.visibleReplies`: controls visible source replies across direct, group, and channel conversations (`"message_tool"` requires `message(action=send)` for visible output; `"automatic"` posts normal replies as before). - `messages.visibleReplies`: controls visible source replies across direct, group, and channel conversations (`"message_tool"` requires `message(action=send)` for visible output; `"automatic"` posts normal replies as before).
- `messages.usageTemplate` / `messages.responseUsage`: custom `/usage` footer template and default per-reply usage mode (`off | tokens | full`, plus legacy `on` alias for `tokens`). - `messages.usageTemplate` / `messages.responseUsage`: custom `/usage` footer template and default per-reply usage mode (`off | tokens | full`, plus legacy `on` alias for `tokens`).
- `messages.groupChat.mentionPatterns` / `historyLimit`: group-message mention triggers and history window sizing. - `messages.groupChat.mentionPatterns` / `historyLimit`: group-message mention triggers and history window sizing.
-7
View File
@@ -318,9 +318,6 @@ Configures inbound media understanding (image/audio/video):
tools: { tools: {
media: { media: {
concurrency: 2, concurrency: 2,
asyncCompletion: {
directSend: false, // deprecated: completions stay agent-mediated
},
audio: { audio: {
enabled: true, enabled: true,
maxBytes: 20971520, maxBytes: 20971520,
@@ -372,10 +369,6 @@ Configures inbound media understanding (image/audio/video):
Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`. Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`.
**Async completion fields:**
- `asyncCompletion.directSend`: deprecated compatibility flag. Completed async media tasks stay requester-session mediated so the agent receives the result, decides how to tell the user, and uses the message tool when source delivery requires it.
</Accordion> </Accordion>
</AccordionGroup> </AccordionGroup>
+4 -6
View File
@@ -95,8 +95,8 @@ target server during config edits.
remote: { remote: {
url: "https://example.com/mcp", url: "https://example.com/mcp",
transport: "streamable-http", // streamable-http | sse transport: "streamable-http", // streamable-http | sse
timeout: 20, requestTimeoutMs: 20000,
connectTimeout: 5, connectionTimeoutMs: 5000,
supportsParallelToolCalls: true, supportsParallelToolCalls: true,
headers: { headers: {
Authorization: "Bearer ${MCP_REMOTE_TOKEN}", Authorization: "Bearer ${MCP_REMOTE_TOKEN}",
@@ -130,10 +130,8 @@ target server during config edits.
`openclaw doctor --fix` normalize into the canonical `transport` field. `openclaw doctor --fix` normalize into the canonical `transport` field.
- `mcp.servers.<name>.enabled`: set `false` to keep a saved server definition - `mcp.servers.<name>.enabled`: set `false` to keep a saved server definition
while excluding it from embedded OpenClaw MCP discovery and tool projection. while excluding it from embedded OpenClaw MCP discovery and tool projection.
- `mcp.servers.<name>.timeout` / `requestTimeoutMs`: per-server MCP request - `mcp.servers.<name>.requestTimeoutMs`: per-server MCP request timeout in milliseconds.
timeout in seconds or milliseconds. - `mcp.servers.<name>.connectionTimeoutMs`: per-server connection timeout in milliseconds.
- `mcp.servers.<name>.connectTimeout` / `connectionTimeoutMs`: per-server
connection timeout in seconds or milliseconds.
- `mcp.servers.<name>.supportsParallelToolCalls`: optional concurrency hint for - `mcp.servers.<name>.supportsParallelToolCalls`: optional concurrency hint for
adapters that can choose whether to issue parallel MCP tool calls. adapters that can choose whether to issue parallel MCP tool calls.
- `mcp.servers.<name>.auth`: set `"oauth"` for HTTP MCP servers that require - `mcp.servers.<name>.auth`: set `"oauth"` for HTTP MCP servers that require
+7 -2
View File
@@ -283,6 +283,11 @@ That stages grounded durable candidates into the short-term dreaming store while
| `browser.profiles.*.driver: "extension"` | `"existing-session"` | | `browser.profiles.*.driver: "extension"` | `"existing-session"` |
| `browser.relayBindHost` | removed (legacy Chrome extension relay setting) | | `browser.relayBindHost` | removed (legacy Chrome extension relay setting) |
| `mcp.servers.*.type` (CLI-native aliases) | `mcp.servers.*.transport` | | `mcp.servers.*.type` (CLI-native aliases) | `mcp.servers.*.transport` |
| MCP timeout aliases `connectTimeout`/`connect_timeout`/`timeout` | `connectionTimeoutMs`/`requestTimeoutMs` |
| top-level `defaultModel` | `agents.defaults.model` |
| `messages.messagePrefix` | `channels.whatsapp.messagePrefix` |
| `session.maintenance.pruneDays`, `session.resetByType.dm` | `session.maintenance.pruneAfter`, `session.resetByType.direct` |
| top-level `tui` | removed (the TUI footer uses the compact default) |
| `plugins.entries.codex.config.codexDynamicToolsProfile` | removed (Codex app-server always keeps Codex-native workspace tools native) | | `plugins.entries.codex.config.codexDynamicToolsProfile` | removed (Codex app-server always keeps Codex-native workspace tools native) |
| `commands.modelsWrite` | removed (`/models add` is deprecated) | | `commands.modelsWrite` | removed (`/models add` is deprecated) |
| `agents.defaults/list[].silentReplyRewrite`, `surfaces.*.silentReplyRewrite` | removed (exact `NO_REPLY` is no longer rewritten to visible fallback text) | | `agents.defaults/list[].silentReplyRewrite`, `surfaces.*.silentReplyRewrite` | removed (exact `NO_REPLY` is no longer rewritten to visible fallback text) |
@@ -383,11 +388,11 @@ That stages grounded durable candidates into the short-term dreaming store while
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload` - top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery` - top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
- payload `provider` delivery aliases → explicit `delivery.channel` - payload `provider` delivery aliases → explicit `delivery.channel`
- legacy `notify: true` webhook fallback jobs → explicit webhook delivery from `cron.webhook` when set; announce jobs keep their chat delivery and get `delivery.completionDestination`. When `cron.webhook` is unset, the inert top-level `notify` marker is removed for no-target jobs (existing delivery, including announce, is preserved) since runtime delivery never reads it. - legacy `notify: true` webhook fallback jobs → explicit webhook delivery from the retired raw `cron.webhook` value when valid; announce jobs keep their chat delivery and get `delivery.completionDestination`. Doctor then removes the old config key. Without a usable legacy webhook, the inert top-level `notify` marker is removed for no-target jobs (existing delivery, including announce, is preserved) since runtime delivery never reads it.
The Gateway also sanitizes malformed cron rows at load time so valid jobs keep running. Raw malformed rows are copied to `jobs-quarantine.json` next to the active store before removal from `jobs.json`; doctor reports quarantined rows so you can review or repair them manually. The Gateway also sanitizes malformed cron rows at load time so valid jobs keep running. Raw malformed rows are copied to `jobs-quarantine.json` next to the active store before removal from `jobs.json`; doctor reports quarantined rows so you can review or repair them manually.
Gateway startup normalizes the runtime projection and ignores the top-level `notify` marker, but leaves the persisted cron config for doctor repair. When `cron.webhook` is unset, doctor removes the inert marker for jobs with no migration target (`delivery.mode` none/absent, an unusable webhook target, or existing announce/chat delivery), leaving existing delivery untouched, so repeated `doctor --fix` runs no longer re-warn about the same job. If `cron.webhook` is set but not a valid HTTP(S) URL, doctor still warns and leaves the marker so you can fix the URL. Gateway startup normalizes the runtime projection and ignores the top-level `notify` marker, but leaves persisted cron state for doctor repair. Doctor removes inert markers for jobs with no migration target (`delivery.mode` none/absent, an unusable legacy webhook target, or existing announce/chat delivery), leaving existing delivery untouched, so repeated `doctor --fix` runs no longer re-warn about the same job.
On Linux, doctor also warns when the user's crontab still invokes legacy `~/.openclaw/bin/ensure-whatsapp.sh`. That host-local script is not maintained by current OpenClaw and can write false `Gateway inactive` messages to `~/.openclaw/logs/whatsapp-health.log` when cron cannot reach the systemd user bus. Remove the stale crontab entry with `crontab -e`; use `openclaw channels status --probe`, `openclaw doctor`, and `openclaw gateway status` for current health checks. On Linux, doctor also warns when the user's crontab still invokes legacy `~/.openclaw/bin/ensure-whatsapp.sh`. That host-local script is not maintained by current OpenClaw and can write false `Gateway inactive` messages to `~/.openclaw/logs/whatsapp-health.log` when cron cannot reach the systemd user bus. Remove the stale crontab entry with `crontab -e`; use `openclaw channels status --probe`, `openclaw doctor`, and `openclaw gateway status` for current health checks.
+2 -3
View File
@@ -1138,9 +1138,8 @@ sessionId})`; create, branch, continue, list, and fork flows live in their
the imported sources. Plugin target writebacks update matching `cron_jobs` the imported sources. Plugin target writebacks update matching `cron_jobs`
rows instead of loading and replacing the whole cron store. rows instead of loading and replacing the whole cron store.
- Gateway startup ignores legacy `notify: true` markers in the runtime - Gateway startup ignores legacy `notify: true` markers in the runtime
projection. Doctor translates them into explicit SQLite delivery when projection. Doctor reads the retired raw `cron.webhook` only while translating
`cron.webhook` is valid, removes inert markers when it is unset, and preserves those markers into explicit SQLite delivery, then removes the config key.
them with a warning when the configured webhook is invalid.
- Outbound and session delivery queues now store queue status, entry kind, - Outbound and session delivery queues now store queue status, entry kind,
session key, channel, target, account id, retry count, last attempt/error, session key, channel, target, account id, retry count, last attempt/error,
recovery state, and platform-send markers as typed columns in the shared recovery state, and platform-send markers as typed columns in the shared
+1 -1
View File
@@ -285,7 +285,7 @@ select it to open the owning Approvals page.
- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, agent model/thinking overrides, and best-effort delivery toggles. - Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, agent model/thinking overrides, and best-effort delivery toggles.
- Form validation is inline with field-level errors; invalid values disable the save button until fixed. - Form validation is inline with field-level errors; invalid values disable the save button until fixed.
- Set `cron.webhookToken` to send a dedicated bearer token; if omitted, the webhook is sent without an auth header. - Set `cron.webhookToken` to send a dedicated bearer token; if omitted, the webhook is sent without an auth header.
- `cron.webhook` is a deprecated legacy fallback: run `openclaw doctor --fix` to migrate stored jobs that still use `notify: true` to explicit per-job webhook or completion delivery. - `cron.webhook` is a retired legacy fallback rejected by current config validation. Run `openclaw doctor --fix` to migrate stored jobs that still use `notify: true` to explicit per-job webhook or completion delivery and remove the old key.
</Accordion> </Accordion>
</AccordionGroup> </AccordionGroup>
+1 -9
View File
@@ -52,7 +52,7 @@ openclaw tui --local
- Header: connection URL, current agent, current session. - Header: connection URL, current agent, current session.
- Chat log: user messages, assistant replies, system notices, tool cards. - Chat log: user messages, assistant replies, system notices, tool cards.
- Status line: connection/run state (connecting, running, streaming, idle, error). - Status line: connection/run state (connecting, running, streaming, idle, error).
- Footer: agent + session + model + goal state + think/fast/verbose/trace/reasoning + token counts + deliver. When `tui.footer.showRemoteHost` is enabled, remote Gateway connections also show the connection host. - Footer: agent + session + model + goal state + think/fast/verbose/trace/reasoning + token counts + deliver.
- Input: text editor with autocomplete. - Input: text editor with autocomplete.
## Mental model: agents + sessions ## Mental model: agents + sessions
@@ -66,14 +66,6 @@ openclaw tui --local
- `per-sender` (default): each agent has many sessions. - `per-sender` (default): each agent has many sessions.
- `global`: the TUI always uses the `global` session (the picker may be empty). - `global`: the TUI always uses the `global` session (the picker may be empty).
- The current agent + session are always visible in the footer. - The current agent + session are always visible in the footer.
- To show the Gateway host for non-local URL-backed connections, opt in with:
```bash
openclaw config set tui.footer.showRemoteHost true
```
Default is `false`. Loopback and embedded local connections never show a host label.
- If the session has a [goal](/tools/goal), the footer shows its compact state: - If the session has a [goal](/tools/goal), the footer shows its compact state:
`Pursuing goal`, `Goal paused (/goal resume)`, `Goal blocked (/goal resume)`, or `Goal achieved`. `Pursuing goal`, `Goal paused (/goal resume)`, `Goal blocked (/goal resume)`, or `Goal achieved`.
- When started without `--session`, gateway-mode TUI resumes the last selected session for the same gateway, agent, and session scope if that session still exists. Passing `--session`, `/session`, `/new`, or `/reset` remains explicit. - When started without `--session`, gateway-mode TUI resumes the last selected session for the same gateway, agent, and session scope if that session still exists. Passing `--session`, `/session`, `/new`, or `/reset` remains explicit.
+2 -13
View File
@@ -8,8 +8,6 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { import {
mapAllowFromEntries, mapAllowFromEntries,
normalizeChannelDmPolicy, normalizeChannelDmPolicy,
resolveChannelDmAllowFrom,
resolveChannelDmPolicy,
type ChannelDmPolicy, type ChannelDmPolicy,
} from "openclaw/plugin-sdk/channel-config-helpers"; } from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
@@ -68,11 +66,7 @@ export function resolveDiscordAccountAllowFrom(params: {
); );
const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId); const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined; const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined;
const allowFrom = accountConfig?.allowFrom ?? rootConfig?.allowFrom;
const allowFrom = resolveChannelDmAllowFrom({
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
});
return allowFrom ? mapAllowFromEntries(allowFrom) : undefined; return allowFrom ? mapAllowFromEntries(allowFrom) : undefined;
} }
@@ -85,12 +79,7 @@ export function resolveDiscordAccountDmPolicy(params: {
); );
const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId); const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined; const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined;
const policy = resolveChannelDmPolicy({ return normalizeChannelDmPolicy(accountConfig?.dmPolicy ?? rootConfig?.dmPolicy ?? "pairing");
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
defaultPolicy: "pairing",
});
return normalizeChannelDmPolicy(policy);
} }
export function createDiscordActionGate(params: { export function createDiscordActionGate(params: {
@@ -308,8 +308,7 @@ export function createDiscordMessagingActionContext(params: {
const directOperator = params.options?.conversationReadOrigin === "direct-operator"; const directOperator = params.options?.conversationReadOrigin === "direct-operator";
const currentReadContext = params.options?.readContext; const currentReadContext = params.options?.readContext;
const directDmEnabled = const directDmEnabled =
accountConfig.dm?.enabled !== false && accountConfig.dm?.enabled !== false && (accountConfig.dmPolicy ?? "pairing") !== "disabled";
(accountConfig.dmPolicy ?? accountConfig.dm?.policy ?? "pairing") !== "disabled";
const withOpts = (extra?: Record<string, unknown>) => const withOpts = (extra?: Record<string, unknown>) =>
createDiscordActionOptions({ cfg: params.cfg, accountId, extra }); createDiscordActionOptions({ cfg: params.cfg, accountId, extra });
const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(params.cfg); const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(params.cfg);
+11 -7
View File
@@ -911,7 +911,8 @@ describe("handleDiscordMessagingAction", () => {
qa: { qa: {
token: "token", token: "token",
groupPolicy: "open", groupPolicy: "open",
dm: { enabled: false, policy: "disabled" }, dm: { enabled: false },
dmPolicy: "disabled",
guilds: { guilds: {
"111": { "111": {
channels: { channels: {
@@ -936,7 +937,8 @@ describe("handleDiscordMessagingAction", () => {
discord: { discord: {
token: "token", token: "token",
groupPolicy: "open", groupPolicy: "open",
dm: { enabled: true, policy: "pairing", groupEnabled: false }, dm: { enabled: true, groupEnabled: false },
dmPolicy: "pairing",
}, },
}, },
} as OpenClawConfig, } as OpenClawConfig,
@@ -953,9 +955,9 @@ describe("handleDiscordMessagingAction", () => {
discord: { discord: {
token: "token", token: "token",
groupPolicy: "open", groupPolicy: "open",
dmPolicy: "pairing",
dm: { dm: {
enabled: true, enabled: true,
policy: "pairing",
groupEnabled: true, groupEnabled: true,
groupChannels: ["allowed-group"], groupChannels: ["allowed-group"],
}, },
@@ -995,9 +997,9 @@ describe("handleDiscordMessagingAction", () => {
discord: { discord: {
token: "token", token: "token",
groupPolicy: "disabled", groupPolicy: "disabled",
dmPolicy: "disabled",
dm: { dm: {
enabled: false, enabled: false,
policy: "disabled",
groupEnabled: true, groupEnabled: true,
groupChannels: ["allowed-group"], groupChannels: ["allowed-group"],
}, },
@@ -1027,7 +1029,8 @@ describe("handleDiscordMessagingAction", () => {
discord: { discord: {
token: "token", token: "token",
groupPolicy: "disabled", groupPolicy: "disabled",
dm: { enabled: true, policy: "pairing" }, dm: { enabled: true },
dmPolicy: "pairing",
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
@@ -1051,9 +1054,9 @@ describe("handleDiscordMessagingAction", () => {
discord: { discord: {
token: "token", token: "token",
groupPolicy: "open", groupPolicy: "open",
dmPolicy: "pairing",
dm: { dm: {
enabled: true, enabled: true,
policy: "pairing",
groupEnabled: false, groupEnabled: false,
}, },
}, },
@@ -1079,7 +1082,8 @@ describe("handleDiscordMessagingAction", () => {
discord: { discord: {
token: "token", token: "token",
groupPolicy: "open", groupPolicy: "open",
dm: { enabled: true, policy: "pairing" }, dm: { enabled: true },
dmPolicy: "pairing",
guilds: { guilds: {
"111": { "111": {
channels: { channels: {
@@ -68,7 +68,8 @@ describe("discordPlugin security", () => {
channels: { channels: {
discord: { discord: {
token: "discord-token", token: "discord-token",
dm: { policy: "allowlist", allowFrom: [" discord:<@!123456789> "] }, dmPolicy: "allowlist",
allowFrom: [" discord:<@!123456789> "],
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
+4 -12
View File
@@ -39,19 +39,12 @@ describe("discord config schema", () => {
expect(issues[0]?.path.join(".")).toBe("allowFrom"); expect(issues[0]?.path.join(".")).toBe("allowFrom");
}); });
it('rejects legacy dm.policy="open" with empty dm.allowFrom', () => { it("rejects legacy nested DM access keys", () => {
const issues = expectInvalidDiscordConfig({ const issues = expectInvalidDiscordConfig({
dm: { policy: "open", allowFrom: [] }, dm: { policy: "open", allowFrom: [] },
}); });
expect(issues[0]?.path.join(".")).toBe("dm.allowFrom"); expect(issues[0]?.path.join(".")).toBe("dm");
});
it('accepts legacy dm.policy="open" with top-level allowFrom alias', () => {
expectValidDiscordConfig({
dm: { policy: "open", allowFrom: ["123"] },
allowFrom: ["*"],
});
}); });
it("accepts textChunkLimit without reviving legacy message limits", () => { it("accepts textChunkLimit without reviving legacy message limits", () => {
@@ -130,9 +123,9 @@ describe("discord config schema", () => {
it("loads guild map and dm group settings", () => { it("loads guild map and dm group settings", () => {
const cfg = expectValidDiscordConfig({ const cfg = expectValidDiscordConfig({
enabled: true, enabled: true,
allowFrom: ["steipete"],
dm: { dm: {
enabled: true, enabled: true,
allowFrom: ["steipete"],
groupEnabled: true, groupEnabled: true,
groupChannels: ["openclaw-dm"], groupChannels: ["openclaw-dm"],
}, },
@@ -309,7 +302,7 @@ describe("discord config schema", () => {
it("coerces safe-integer numeric allowlist entries to strings", () => { it("coerces safe-integer numeric allowlist entries to strings", () => {
const cfg = expectValidDiscordConfig({ const cfg = expectValidDiscordConfig({
allowFrom: [123], allowFrom: [123],
dm: { allowFrom: [456], groupChannels: [789] }, dm: { groupChannels: [789] },
guilds: { guilds: {
"123": { "123": {
users: [111], users: [111],
@@ -323,7 +316,6 @@ describe("discord config schema", () => {
}); });
expect(cfg.allowFrom).toEqual(["123"]); expect(cfg.allowFrom).toEqual(["123"]);
expect(cfg.dm?.allowFrom).toEqual(["456"]);
expect(cfg.dm?.groupChannels).toEqual(["789"]); expect(cfg.dm?.groupChannels).toEqual(["789"]);
expect(cfg.guilds?.["123"]?.users).toEqual(["111"]); expect(cfg.guilds?.["123"]?.users).toEqual(["111"]);
expect(cfg.guilds?.["123"]?.roles).toEqual(["222"]); expect(cfg.guilds?.["123"]?.roles).toEqual(["222"]);
+1 -9
View File
@@ -9,11 +9,7 @@ export const discordChannelConfigUiHints = {
}, },
...createChannelConfigUiHints({ ...createChannelConfigUiHints({
channelLabel: "Discord", channelLabel: "Discord",
dmPolicy: { dmPolicy: { channelKey: "discord" },
channelKey: "discord",
includeLegacyNestedPolicy: true,
legacyNestedPolicyOrder: "after",
},
configWrites: true, configWrites: true,
mentionPatterns: { mentionPatterns: {
targetDescription: "Discord channel IDs", targetDescription: "Discord channel IDs",
@@ -195,10 +191,6 @@ export const discordChannelConfigUiHints = {
label: "Discord Realtime Speaker Voice ID", label: "Discord Realtime Speaker Voice ID",
help: "Provider realtime output voice id.", help: "Provider realtime output voice id.",
}, },
"voice.realtime.voice": {
label: "Discord Realtime Voice",
help: "Deprecated provider realtime output voice. Use voice.realtime.speakerVoice.",
},
"voice.realtime.toolPolicy": { "voice.realtime.toolPolicy": {
label: "Discord Realtime Tool Policy", label: "Discord Realtime Tool Policy",
help: "Tool policy for the OpenClaw agent consult tool in realtime voice modes: safe-read-only, owner, or none. Default is owner for agent-proxy and safe-read-only for bidi.", help: "Tool policy for the OpenClaw agent consult tool in realtime voice modes: safe-read-only, owner, or none. Default is owner for agent-proxy and safe-read-only for bidi.",
@@ -21,7 +21,7 @@ describe("Discord directory contract", () => {
channels: { channels: {
discord: { discord: {
token: "discord-test", token: "discord-test",
dm: { allowFrom: ["<@111>", "<@!333>", "nope"] }, allowFrom: ["<@111>", "<@!333>", "nope"],
dms: { "222": {} }, dms: { "222": {} },
guilds: { guilds: {
"123": { "123": {
@@ -62,7 +62,7 @@ describe("Discord directory contract", () => {
channels: { channels: {
discord: { discord: {
token: envSecret, token: envSecret,
dm: { allowFrom: ["<@111>"] }, allowFrom: ["<@111>"],
guilds: { guilds: {
"123": { "123": {
channels: { channels: {
@@ -78,14 +78,14 @@ describe("Discord directory contract", () => {
await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]); await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]);
}); });
it("uses account legacy dm.allowFrom before inherited root allowFrom", async () => { it("uses account allowFrom before inherited root allowFrom", async () => {
const cfg = { const cfg = {
channels: { channels: {
discord: { discord: {
allowFrom: ["<@111>"], allowFrom: ["<@111>"],
accounts: { accounts: {
work: { work: {
dm: { allowFrom: ["<@222>"] }, allowFrom: ["<@222>"],
}, },
}, },
}, },
+3 -1
View File
@@ -545,7 +545,9 @@ describe("discord doctor", () => {
const result = maybeRepairDiscordNumericIds(cfg, "openclaw doctor --fix"); const result = maybeRepairDiscordNumericIds(cfg, "openclaw doctor --fix");
expect(result.config.channels?.discord?.allowFrom).toEqual(["123"]); expect(result.config.channels?.discord?.allowFrom).toEqual(["123"]);
expect(result.config.channels?.discord?.dm?.allowFrom).toEqual(["99"]); expect(
(result.config.channels?.discord?.dm as { allowFrom?: string[] } | undefined)?.allowFrom,
).toEqual(["99"]);
expect(result.config.channels?.discord?.guilds?.main?.users).toEqual(["111"]); expect(result.config.channels?.discord?.guilds?.main?.users).toEqual(["111"]);
expect(result.config.channels?.discord?.guilds?.main?.roles).toEqual(["222"]); expect(result.config.channels?.discord?.guilds?.main?.roles).toEqual(["222"]);
expect(result.changes).not.toHaveLength(0); expect(result.changes).not.toHaveLength(0);
@@ -67,8 +67,7 @@ describe("discord exec approvals", () => {
const cfg = buildConfig( const cfg = buildConfig(
{ enabled: true }, { enabled: true },
{ {
allowFrom: ["123"], allowFrom: ["123", "456"],
dm: { allowFrom: ["456"] },
defaultTo: "user:789", defaultTo: "user:789",
}, },
); );
@@ -105,7 +105,7 @@ export function createDiscordPreflightArgs(params: {
replyToMode: "all", replyToMode: "all",
dmEnabled: true, dmEnabled: true,
groupDmEnabled: true, groupDmEnabled: true,
dmPolicy: params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing", dmPolicy: params.discordConfig?.dmPolicy ?? "pairing",
ackReactionScope: "direct", ackReactionScope: "direct",
groupPolicy: "open", groupPolicy: "open",
threadBindings: createNoopThreadBindingManager("default"), threadBindings: createNoopThreadBindingManager("default"),
@@ -71,9 +71,9 @@ function createModelPickerContext(): ModelPickerContext {
}, },
channels: { channels: {
discord: { discord: {
dmPolicy: "open",
dm: { dm: {
enabled: true, enabled: true,
policy: "open",
}, },
}, },
}, },
@@ -266,7 +266,9 @@ describe("createDiscordNativeCommand option wiring", () => {
const cfg = { const cfg = {
channels: { channels: {
discord: { discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
@@ -502,7 +504,8 @@ describe("createDiscordNativeCommand option wiring", () => {
session: { dmScope: "main" }, session: { dmScope: "main" },
channels: { channels: {
discord: { discord: {
dm: { enabled: true, policy: "disabled" }, dm: { enabled: true },
dmPolicy: "disabled",
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
@@ -510,7 +513,9 @@ describe("createDiscordNativeCommand option wiring", () => {
session: { dmScope: "per-channel-peer" }, session: { dmScope: "per-channel-peer" },
channels: { channels: {
discord: { discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
@@ -607,9 +612,9 @@ describe("createDiscordNativeCommand option wiring", () => {
it("returns no autocomplete choices for group DMs outside dm.groupChannels", async () => { it("returns no autocomplete choices for group DMs outside dm.groupChannels", async () => {
const discordConfig = { const discordConfig = {
dmPolicy: "open",
dm: { dm: {
enabled: true, enabled: true,
policy: "open",
groupEnabled: true, groupEnabled: true,
groupChannels: ["allowed-group"], groupChannels: ["allowed-group"],
}, },
@@ -42,7 +42,9 @@ function createConfig(): OpenClawConfig {
return { return {
channels: { channels: {
discord: { discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
@@ -87,7 +89,9 @@ function createConfiguredAcpCase(params: {
? { ? {
channels: { channels: {
discord: { discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
}, },
}, },
} }
@@ -622,7 +626,9 @@ describe("Discord native plugin command dispatch", () => {
const cfg = { const cfg = {
channels: { channels: {
discord: { discord: {
dm: { enabled: true, policy: "open", allowFrom: ["user:owner"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["user:owner"],
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
@@ -647,7 +653,9 @@ describe("Discord native plugin command dispatch", () => {
}, },
channels: { channels: {
discord: { discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
@@ -872,9 +880,9 @@ describe("Discord native plugin command dispatch", () => {
}, },
channels: { channels: {
discord: { discord: {
dmPolicy: "open",
dm: { dm: {
enabled: true, enabled: true,
policy: "open",
groupEnabled: true, groupEnabled: true,
groupChannels: ["allowed-group"], groupChannels: ["allowed-group"],
}, },
@@ -41,7 +41,9 @@ function createConfig(params?: { requireMention?: boolean }): OpenClawConfig {
}, },
channels: { channels: {
discord: { discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
guilds: { guilds: {
guild1: { guild1: {
requireMention: true, requireMention: true,
@@ -100,7 +100,7 @@ describe("Discord security audit findings", () => {
}, },
}, },
}, },
} satisfies OpenClawConfig, } as OpenClawConfig,
expectFinding: true, expectFinding: true,
}, },
{ {
@@ -122,13 +122,17 @@ describe("Discord security audit findings", () => {
}, },
}, },
}, },
} satisfies OpenClawConfig, } as unknown as OpenClawConfig,
expectFinding: false, expectFinding: false,
}, },
])("$name", async (testCase) => { ])("$name", async (testCase) => {
const discordConfig = testCase.cfg.channels?.discord;
if (!discordConfig) {
throw new Error("discord config required");
}
const findings = await collectFindings({ const findings = await collectFindings({
cfg: testCase.cfg, cfg: testCase.cfg,
config: testCase.cfg.channels.discord, config: discordConfig,
}); });
expect( expect(
+6 -7
View File
@@ -87,14 +87,13 @@ describe("createDiscordPluginBase", () => {
}); });
describe("discordConfigAdapter", () => { describe("discordConfigAdapter", () => {
it("resolves top-level allowFrom before legacy dm.allowFrom", () => { it("resolves canonical allowFrom", () => {
const cfg = { const cfg = {
channels: { channels: {
discord: { discord: {
accounts: { accounts: {
default: { default: {
allowFrom: ["123"], allowFrom: ["123"],
dm: { allowFrom: ["456"] },
}, },
}, },
}, },
@@ -104,7 +103,7 @@ describe("discordConfigAdapter", () => {
expect(discordConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["123"]); expect(discordConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["123"]);
}); });
it("falls back to legacy dm.allowFrom", () => { it("ignores retired nested dm.allowFrom", () => {
const cfg = { const cfg = {
channels: { channels: {
discord: { discord: {
@@ -117,17 +116,17 @@ describe("discordConfigAdapter", () => {
}, },
} as OpenClawConfig; } as OpenClawConfig;
expect(discordConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["456"]); expect(discordConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([]);
}); });
it("prefers account legacy dm.allowFrom over inherited root allowFrom", () => { it("prefers account allowFrom over inherited root allowFrom", () => {
const cfg = { const cfg = {
channels: { channels: {
discord: { discord: {
allowFrom: ["root"], allowFrom: ["root"],
accounts: { accounts: {
work: { work: {
dm: { allowFrom: ["account-legacy"] }, allowFrom: ["account"],
}, },
}, },
}, },
@@ -135,7 +134,7 @@ describe("discordConfigAdapter", () => {
} as OpenClawConfig; } as OpenClawConfig;
expect(discordConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "work" })).toEqual([ expect(discordConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "work" })).toEqual([
"account-legacy", "account",
]); ]);
}); });
+5 -6
View File
@@ -159,13 +159,13 @@ describe("resolveDiscordTarget", () => {
expect(listPeers).not.toHaveBeenCalled(); expect(listPeers).not.toHaveBeenCalled();
}); });
it("uses legacy dm.allowFrom when disambiguating bare numeric ids", async () => { it("uses account allowFrom when disambiguating bare numeric ids", async () => {
const cfgEntry = { const cfgEntry = {
channels: { channels: {
discord: { discord: {
accounts: { accounts: {
default: { default: {
dm: { allowFrom: ["456"] }, allowFrom: ["456"],
}, },
}, },
}, },
@@ -182,14 +182,13 @@ describe("resolveDiscordTarget", () => {
); );
}); });
it("prefers top-level allowFrom over legacy dm.allowFrom for bare numeric ids", async () => { it("uses only canonical allowFrom for bare numeric ids", async () => {
const cfgResult = { const cfgResult = {
channels: { channels: {
discord: { discord: {
accounts: { accounts: {
default: { default: {
allowFrom: ["123"], allowFrom: ["123"],
dm: { allowFrom: ["456"] },
}, },
}, },
}, },
@@ -206,14 +205,14 @@ describe("resolveDiscordTarget", () => {
); );
}); });
it("uses account legacy dm.allowFrom before inherited root allowFrom for bare numeric ids", async () => { it("uses account allowFrom before inherited root allowFrom for bare numeric ids", async () => {
const cfgValue = { const cfgValue = {
channels: { channels: {
discord: { discord: {
allowFrom: ["123"], allowFrom: ["123"],
accounts: { accounts: {
work: { work: {
dm: { allowFrom: ["456"] }, allowFrom: ["456"],
}, },
}, },
}, },
+1 -3
View File
@@ -103,9 +103,7 @@ export async function authorizeDiscordVoiceIngress(params: {
}); });
const admissionAllowList = normalizeDiscordAllowList( const admissionAllowList = normalizeDiscordAllowList(
params.admissionAllowFrom ?? params.admissionAllowFrom ?? params.discordConfig.allowFrom ?? params.discordConfig.allowFrom,
params.discordConfig.allowFrom ??
params.discordConfig.dm?.allowFrom,
["discord:", "user:", "pk:"], ["discord:", "user:", "pk:"],
); );
const admissionAllowed = admissionAllowList const admissionAllowed = admissionAllowList
@@ -5537,7 +5537,7 @@ describe("DiscordVoiceManager", () => {
realtime: { realtime: {
provider: "openai", provider: "openai",
model: "gpt-realtime-2", model: "gpt-realtime-2",
voice: "cedar", speakerVoice: "cedar",
toolPolicy: "safe-read-only", toolPolicy: "safe-read-only",
consultPolicy: "always", consultPolicy: "always",
requireWakeName: true, requireWakeName: true,
+1 -1
View File
@@ -669,7 +669,7 @@ export class DiscordVoiceManager {
} }
const { route, voiceRoute, agentSessionMode, agentSessionTarget } = routeInfo; const { route, voiceRoute, agentSessionMode, agentSessionTarget } = routeInfo;
logger.info( logger.info(
`discord voice: joining guild=${guildId} channel=${channelId} mode=${voiceMode} agent=${route.agentId} voiceSession=${voiceRoute.sessionKey} supervisorSession=${route.sessionKey} agentSessionMode=${agentSessionMode}${agentSessionTarget ? ` agentSessionTarget=${agentSessionTarget}` : ""} voiceModel=${voiceConfig?.model ?? "route-default"} realtimeProvider=${voiceConfig?.realtime?.provider ?? "auto"} realtimeModel=${voiceConfig?.realtime?.model ?? "provider-default"} realtimeVoice=${voiceConfig?.realtime?.voice ?? "provider-default"}`, `discord voice: joining guild=${guildId} channel=${channelId} mode=${voiceMode} agent=${route.agentId} voiceSession=${voiceRoute.sessionKey} supervisorSession=${route.sessionKey} agentSessionMode=${agentSessionMode}${agentSessionTarget ? ` agentSessionTarget=${agentSessionTarget}` : ""} voiceModel=${voiceConfig?.model ?? "route-default"} realtimeProvider=${voiceConfig?.realtime?.provider ?? "auto"} realtimeModel=${voiceConfig?.realtime?.model ?? "provider-default"} realtimeVoice=${voiceConfig?.realtime?.speakerVoice ?? voiceConfig?.realtime?.speakerVoiceId ?? "provider-default"}`,
); );
const player = voiceSdk.createAudioPlayer(); const player = voiceSdk.createAudioPlayer();
+1 -1
View File
@@ -24,7 +24,7 @@ export function resolveDiscordVoiceAccess(params: {
const admissionAllowFrom = const admissionAllowFrom =
resolveDiscordAccountAllowFrom({ cfg: params.cfg, accountId: params.accountId }) ?? resolveDiscordAccountAllowFrom({ cfg: params.cfg, accountId: params.accountId }) ??
params.discordConfig.allowFrom ?? params.discordConfig.allowFrom ??
params.discordConfig.dm?.allowFrom ?? params.discordConfig.allowFrom ??
[]; [];
return { return {
admissionAllowFrom, admissionAllowFrom,
+2 -4
View File
@@ -828,7 +828,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
this.harness.outputActivity.markPlaybackStarted(); this.harness.outputActivity.markPlaybackStarted();
const realtimeConfig = this.realtimeConfig; const realtimeConfig = this.realtimeConfig;
logger.info( logger.info(
`discord voice: realtime audio playback started guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} mode=${this.params.mode} model=${realtimeConfig?.model ?? "provider-default"} voice=${realtimeConfig?.voice ?? "provider-default"}`, `discord voice: realtime audio playback started guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} mode=${this.params.mode} model=${realtimeConfig?.model ?? "provider-default"} voice=${realtimeConfig?.speakerVoice ?? realtimeConfig?.speakerVoiceId ?? "provider-default"}`,
); );
} }
@@ -1738,9 +1738,7 @@ function buildProviderConfigOverrides(
? { voice: realtimeConfig.speakerVoice } ? { voice: realtimeConfig.speakerVoice }
: realtimeConfig?.speakerVoiceId : realtimeConfig?.speakerVoiceId
? { voice: realtimeConfig.speakerVoiceId } ? { voice: realtimeConfig.speakerVoiceId }
: realtimeConfig?.voice : {}),
? { voice: realtimeConfig.voice }
: {}),
...(typeof realtimeConfig?.minBargeInAudioEndMs === "number" ...(typeof realtimeConfig?.minBargeInAudioEndMs === "number"
? { minBargeInAudioEndMs: realtimeConfig.minBargeInAudioEndMs } ? { minBargeInAudioEndMs: realtimeConfig.minBargeInAudioEndMs }
: {}), : {}),
+10 -10
View File
@@ -49,7 +49,7 @@ describe("googlechat message actions", () => {
...overrides, ...overrides,
config: { config: {
groupPolicy: "open", groupPolicy: "open",
dm: { policy: "open" }, dmPolicy: "open",
...overrideConfig, ...overrideConfig,
}, },
}; };
@@ -75,7 +75,7 @@ describe("googlechat message actions", () => {
{ {
enabled: true, enabled: true,
credentialSource: "service-account", credentialSource: "service-account",
config: { actions: { reactions: true } }, config: {},
}, },
]); ]);
@@ -99,14 +99,14 @@ describe("googlechat message actions", () => {
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull(); expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull();
}); });
it("keeps the legacy reaction gate from changing account-scoped discovery", () => { it("keeps account-scoped discovery send-only", () => {
resolveGoogleChatAccount.mockImplementation(({ accountId }: { accountId?: string | null }) => ({ resolveGoogleChatAccount.mockImplementation(
enabled: true, ({ accountId: _accountId }: { accountId?: string | null }) => ({
credentialSource: "service-account", enabled: true,
config: { credentialSource: "service-account",
actions: { reactions: accountId === "work" }, config: {},
}, }),
})); );
for (const accountId of ["default", "work"]) { for (const accountId of ["default", "work"]) {
expect( expect(
@@ -6,7 +6,7 @@ describe("googleChatApprovalAuth", () => {
it("authorizes stable users/* ids and ignores email-style approvers", () => { it("authorizes stable users/* ids and ignores email-style approvers", () => {
expect( expect(
googleChatApprovalAuth.authorizeActorAction({ googleChatApprovalAuth.authorizeActorAction({
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } }, cfg: { channels: { googlechat: { allowFrom: ["users/123"] } } },
senderId: "users/123", senderId: "users/123",
action: "approve", action: "approve",
approvalKind: "exec", approvalKind: "exec",
@@ -15,7 +15,7 @@ describe("googleChatApprovalAuth", () => {
expect( expect(
googleChatApprovalAuth.authorizeActorAction({ googleChatApprovalAuth.authorizeActorAction({
cfg: { channels: { googlechat: { dm: { allowFrom: ["owner@example.com"] } } } }, cfg: { channels: { googlechat: { allowFrom: ["owner@example.com"] } } },
senderId: "users/attacker", senderId: "users/attacker",
action: "approve", action: "approve",
approvalKind: "exec", approvalKind: "exec",
+1 -1
View File
@@ -20,7 +20,7 @@ const googleChatApproval = createChannelApprovalAuth({
channelLabel: "Google Chat", channelLabel: "Google Chat",
resolveInputs: ({ cfg, accountId }) => { resolveInputs: ({ cfg, accountId }) => {
const account = resolveGoogleChatAccount({ cfg, accountId }).config; const account = resolveGoogleChatAccount({ cfg, accountId }).config;
return { allowFrom: account.dm?.allowFrom, defaultTo: account.defaultTo }; return { allowFrom: account.allowFrom, defaultTo: account.defaultTo };
}, },
normalizeApprover: normalizeGoogleChatApproverId, normalizeApprover: normalizeGoogleChatApproverId,
}); });
@@ -67,13 +67,13 @@ function createTarget(): WebhookTarget {
enabled: true, enabled: true,
credentialSource: "inline", credentialSource: "inline",
config: { config: {
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
config: { config: {
channels: { channels: {
googlechat: { googlechat: {
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
}, },
@@ -52,7 +52,7 @@ const cfg: OpenClawConfig = {
audienceType: "app-url", audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat", audience: "https://chat-app.example.test/googlechat",
appPrincipal: "123456789012345678901", appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
}; };
@@ -431,7 +431,7 @@ describe("googleChatApprovalNativeRuntime", () => {
}, },
audienceType: "app-url", audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat", audience: "https://chat-app.example.test/googlechat",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
}, },
@@ -18,7 +18,7 @@ const GOOGLE_CHAT_APPROVAL_ACCOUNT = {
audienceType: "app-url" as const, audienceType: "app-url" as const,
audience: "https://chat-app.example.test/googlechat", audience: "https://chat-app.example.test/googlechat",
appPrincipal: "123456789012345678901", appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}; };
const execApprovalPayload: ReplyPayload = { const execApprovalPayload: ReplyPayload = {
@@ -59,7 +59,7 @@ describe("googleChatApprovalCapability", () => {
audienceType: "app-url", audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat", audience: "https://chat-app.example.test/googlechat",
appPrincipal: "123456789012345678901", appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
}, },
@@ -81,7 +81,7 @@ describe("googleChatApprovalCapability", () => {
private_key: "test-key", private_key: "test-key",
token_uri: "https://oauth2.googleapis.com/token", token_uri: "https://oauth2.googleapis.com/token",
}, },
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
}, },
@@ -100,7 +100,7 @@ describe("googleChatApprovalCapability", () => {
token_uri: "https://oauth2.googleapis.com/token", token_uri: "https://oauth2.googleapis.com/token",
}, },
audienceType: "project-number", audienceType: "project-number",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
}, },
@@ -119,7 +119,7 @@ describe("googleChatApprovalCapability", () => {
}, },
audienceType: "app-url" as const, audienceType: "app-url" as const,
audience: "https://chat-app.example.test/googlechat", audience: "https://chat-app.example.test/googlechat",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}; };
expect( expect(
@@ -169,7 +169,7 @@ describe("googleChatApprovalCapability", () => {
}, },
audienceType: "app-url", audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat", audience: "https://chat-app.example.test/googlechat",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
}, },
@@ -189,7 +189,7 @@ describe("googleChatApprovalCapability", () => {
}, },
audienceType: "project-number", audienceType: "project-number",
audience: "1234567890", audience: "1234567890",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
}, },
@@ -200,7 +200,7 @@ describe("googleChatApprovalCapability", () => {
it("preserves Google Chat approval actor authorization", () => { it("preserves Google Chat approval actor authorization", () => {
expect( expect(
googleChatApprovalCapability.authorizeActorAction?.({ googleChatApprovalCapability.authorizeActorAction?.({
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } }, cfg: { channels: { googlechat: { allowFrom: ["users/123"] } } },
senderId: "users/123", senderId: "users/123",
action: "approve", action: "approve",
approvalKind: "plugin", approvalKind: "plugin",
@@ -209,7 +209,7 @@ describe("googleChatApprovalCapability", () => {
expect( expect(
googleChatApprovalCapability.authorizeActorAction?.({ googleChatApprovalCapability.authorizeActorAction?.({
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } }, cfg: { channels: { googlechat: { allowFrom: ["users/123"] } } },
senderId: "users/999", senderId: "users/999",
action: "approve", action: "approve",
approvalKind: "plugin", approvalKind: "plugin",
@@ -237,7 +237,7 @@ describe("googleChatApprovalCapability", () => {
audienceType: "app-url", audienceType: "app-url",
audience: "https://alpha.example.com/googlechat", audience: "https://alpha.example.com/googlechat",
appPrincipal: "123456789012345678901", appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
beta: { beta: {
enabled: true, enabled: true,
@@ -250,7 +250,7 @@ describe("googleChatApprovalCapability", () => {
audienceType: "app-url", audienceType: "app-url",
audience: "https://beta.example.com/googlechat", audience: "https://beta.example.com/googlechat",
appPrincipal: "987654321098765432109", appPrincipal: "987654321098765432109",
dm: { allowFrom: ["users/456"] }, allowFrom: ["users/456"],
}, },
}, },
}, },
@@ -298,7 +298,7 @@ describe("googleChatApprovalCapability", () => {
audienceType: "app-url", audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat", audience: "https://chat-app.example.test/googlechat",
appPrincipal: "123456789012345678901", appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
}; };
+1 -1
View File
@@ -202,7 +202,7 @@ export const googleChatApprovalCapability: ChannelApprovalCapability =
accountId && accountId !== "default" accountId && accountId !== "default"
? `channels.googlechat.accounts.${accountId}` ? `channels.googlechat.accounts.${accountId}`
: "channels.googlechat"; : "channels.googlechat";
return `Approve it from the Web UI or terminal UI for now. Google Chat supports native approvals for this account when the webhook and service account are configured. Configure \`${prefix}.dm.allowFrom\` or \`${prefix}.defaultTo\` with numeric \`users/{id}\` approvers.`; return `Approve it from the Web UI or terminal UI for now. Google Chat supports native approvals for this account when the webhook and service account are configured. Configure \`${prefix}.allowFrom\` or \`${prefix}.defaultTo\` with numeric \`users/{id}\` approvers.`;
}, },
listAccountIds: listGoogleChatAccountIds, listAccountIds: listGoogleChatAccountIds,
hasApprovers: ({ cfg, accountId }) => hasApprovers: ({ cfg, accountId }) =>
+1 -1
View File
@@ -62,7 +62,7 @@ const googleChatConfigAdapter = createScopedChannelConfigAdapter<
"botUser", "botUser",
"name", "name",
], ],
resolveAllowFrom: (account) => account.config.dm?.allowFrom, resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({ formatNormalizedAllowFromEntries({
allowFrom, allowFrom,
@@ -30,7 +30,6 @@ describe("googlechatPlugin config adapter", () => {
channels: { channels: {
googlechat: { googlechat: {
serviceAccount: { client_email: "bot@example.com" }, serviceAccount: { client_email: "bot@example.com" },
actions: { reactions: true },
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
@@ -64,9 +63,7 @@ describe("googlechatPlugin config adapter", () => {
provider: "google_chat_service_account", provider: "google_chat_service_account",
id: "value", id: "value",
}, },
dm: { allowFrom: ["users/123"],
allowFrom: ["users/123"],
},
defaultTo: "spaces/AAA", defaultTo: "spaces/AAA",
}, },
}, },
@@ -93,7 +90,7 @@ describe("googlechatPlugin config adapter", () => {
}, },
audienceType: "app-url", audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat", audience: "https://chat-app.example.test/googlechat",
dm: { allowFrom: ["users/123"] }, allowFrom: ["users/123"],
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
@@ -86,8 +86,8 @@ const collectGoogleChatSecurityWarnings = composeAccountWarningCollectors<
>( >(
collectGoogleChatGroupPolicyWarnings, collectGoogleChatGroupPolicyWarnings,
(account) => (account) =>
account.config.dm?.policy === "open" && account.config.dmPolicy === "open" &&
'- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".', '- Google Chat DMs are open to anyone. Set channels.googlechat.dmPolicy="pairing" or "allowlist".',
); );
export const googlechatGroupsAdapter = { export const googlechatGroupsAdapter = {
@@ -99,7 +99,7 @@ export const googlechatDirectoryAdapter = createChannelDirectoryAdapter({
listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedGoogleChatAccount>({ listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedGoogleChatAccount>({
...params, ...params,
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount), resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
resolveAllowFrom: (account) => account.config.dm?.allowFrom, resolveAllowFrom: (account) => account.config.allowFrom,
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry, normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
}), }),
listGroups: async (params) => listGroups: async (params) =>
@@ -113,9 +113,9 @@ export const googlechatDirectoryAdapter = createChannelDirectoryAdapter({
export const googlechatSecurityAdapter = { export const googlechatSecurityAdapter = {
dm: { dm: {
channelKey: "googlechat", channelKey: "googlechat",
resolvePolicy: (account: ResolvedGoogleChatAccount) => account.config.dm?.policy, resolvePolicy: (account: ResolvedGoogleChatAccount) => account.config.dmPolicy,
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom, resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.allowFrom,
allowFromPathSuffix: "dm.", allowFromPathSuffix: "",
normalizeEntry: (raw: string) => formatGoogleChatAllowFromEntry(raw), normalizeEntry: (raw: string) => formatGoogleChatAllowFromEntry(raw),
}, },
collectWarnings: collectGoogleChatSecurityWarnings, collectWarnings: collectGoogleChatSecurityWarnings,
+4 -6
View File
@@ -480,7 +480,7 @@ describe("googlechat directory", () => {
channels: { channels: {
googlechat: { googlechat: {
serviceAccount: { client_email: "bot@example.com" }, serviceAccount: { client_email: "bot@example.com" },
dm: { allowFrom: ["users/alice", "googlechat:bob"] }, allowFrom: ["users/alice", "googlechat:bob"],
groups: { groups: {
"spaces/AAA": {}, "spaces/AAA": {},
"spaces/BBB": {}, "spaces/BBB": {},
@@ -521,7 +521,7 @@ describe("googlechat directory", () => {
channels: { channels: {
googlechat: { googlechat: {
serviceAccount: { client_email: "bot@example.com" }, serviceAccount: { client_email: "bot@example.com" },
dm: { allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "] }, allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "],
}, },
}, },
} as unknown as OpenClawConfig; } as unknown as OpenClawConfig;
@@ -548,10 +548,8 @@ describe("googlechatPlugin security", () => {
channels: { channels: {
googlechat: { googlechat: {
serviceAccount: { client_email: "bot@example.com" }, serviceAccount: { client_email: "bot@example.com" },
dm: { dmPolicy: "allowlist",
policy: "allowlist", allowFrom: [" googlechat:user:Bob@Example.com "],
allowFrom: [" googlechat:user:Bob@Example.com "],
},
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
+2 -2
View File
@@ -131,7 +131,7 @@ export const googlechatPlugin = createChatChannelPlugin({
}, },
actions: googlechatActions, actions: googlechatActions,
doctor: { doctor: {
dmAllowFromMode: "nestedOnly", dmAllowFromMode: "topOnly",
groupModel: "route", groupModel: "route",
groupAllowFromFallbackToAllowFrom: false, groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false, warnOnEmptyGroupSenderAllowlist: false,
@@ -192,7 +192,7 @@ export const googlechatPlugin = createChatChannelPlugin({
audience: account.config.audience, audience: account.config.audience,
webhookPath: account.config.webhookPath, webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl, webhookUrl: account.config.webhookUrl,
dmPolicy: account.config.dm?.policy ?? "pairing", dmPolicy: account.config.dmPolicy ?? "pairing",
}, },
}), }),
}), }),
@@ -29,4 +29,22 @@ describe("googlechat config schema", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it("accepts canonical DM access and shared runtime messaging knobs", () => {
const result = GoogleChatConfigSchema.safeParse({
dmPolicy: "allowlist",
allowFrom: ["users/1234567890"],
markdown: { tables: "bullets" },
heartbeat: { showOk: false },
contextVisibility: "allowlist_quote",
});
expect(result.success).toBe(true);
});
it("rejects legacy nested DM access keys", () => {
const result = GoogleChatConfigSchema.safeParse({ dm: { policy: "pairing" } });
expect(result.success).toBe(false);
});
}); });
@@ -4,6 +4,23 @@ import { resolveGoogleChatAccount } from "./accounts.js";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js"; import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
describe("googlechat doctor contract", () => { describe("googlechat doctor contract", () => {
it("removes retired reaction flags", () => {
const result = normalizeCompatibilityConfig({
cfg: {
channels: {
googlechat: {
actions: { reactions: true },
accounts: { work: { actions: { reactions: false } } },
},
},
} as never,
});
expect(result.config.channels?.googlechat).toEqual({ accounts: { work: {} } });
expect(result.changes).toEqual([
"Removed channels.googlechat.actions.reactions (Google Chat does not support reactions).",
"Removed channels.googlechat.accounts.work.actions.reactions (Google Chat does not support reactions).",
]);
});
it("removes legacy streamMode keys", () => { it("removes legacy streamMode keys", () => {
const result = normalizeCompatibilityConfig({ const result = normalizeCompatibilityConfig({
cfg: { cfg: {
@@ -83,6 +100,30 @@ describe("googlechat doctor contract", () => {
expect(rootRule?.match?.({ streaming: { block: { enabled: true } } }, {})).toBe(false); expect(rootRule?.match?.({ streaming: { block: { enabled: true } } }, {})).toBe(false);
}); });
it("detects and promotes legacy nested DM access at root and account scope", () => {
const dmRules = legacyConfigRules.filter((rule) => rule.message.includes("dm.policy"));
expect(dmRules[0]?.match?.({ dm: { policy: "allowlist" } }, {})).toBe(true);
expect(dmRules[1]?.match?.({ work: { dm: { allowFrom: ["users/work"] } } }, {})).toBe(true);
const result = normalizeCompatibilityConfig({
cfg: {
channels: {
googlechat: {
dm: { enabled: false, policy: "allowlist", allowFrom: ["users/root"] },
accounts: { work: { dm: { policy: "open", allowFrom: ["*"] } } },
},
},
} as never,
});
expect(result.config.channels?.googlechat).toEqual({
dm: { enabled: false },
dmPolicy: "allowlist",
allowFrom: ["users/root"],
accounts: { work: { dmPolicy: "open", allowFrom: ["*"] } },
});
});
it("moves flat delivery aliases at root and account level with root seeding", () => { it("moves flat delivery aliases at root and account level with root seeding", () => {
const result = normalizeCompatibilityConfig({ const result = normalizeCompatibilityConfig({
cfg: { cfg: {
@@ -17,12 +17,17 @@ type GoogleChatChannelsConfig = NonNullable<OpenClawConfig["channels"]>;
const streamingAliasMigration = defineChannelAliasMigration({ const streamingAliasMigration = defineChannelAliasMigration({
channelId: "googlechat", channelId: "googlechat",
streaming: { defaultMode: "partial", deliveryOnly: true }, streaming: { defaultMode: "partial", deliveryOnly: true },
dm: { root: true, accounts: true },
}); });
function hasLegacyGoogleChatStreamMode(value: unknown): boolean { function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
return asObjectRecord(value)?.streamMode !== undefined; return asObjectRecord(value)?.streamMode !== undefined;
} }
function hasRetiredReactions(value: unknown): boolean {
return Object.hasOwn(asObjectRecord(asObjectRecord(value)?.actions) ?? {}, "reactions");
}
function hasLegacyGoogleChatGroupAllowAlias(value: unknown): boolean { function hasLegacyGoogleChatGroupAllowAlias(value: unknown): boolean {
const groups = asObjectRecord(asObjectRecord(value)?.groups); const groups = asObjectRecord(asObjectRecord(value)?.groups);
if (!groups) { if (!groups) {
@@ -84,6 +89,21 @@ function normalizeGoogleChatEntry(params: {
changed = true; changed = true;
} }
if (hasRetiredReactions(updated)) {
const actions = { ...asObjectRecord(updated.actions) };
delete actions.reactions;
updated = { ...updated };
if (Object.keys(actions).length > 0) {
updated.actions = actions;
} else {
delete updated.actions;
}
params.changes.push(
`Removed ${params.pathPrefix}.actions.reactions (Google Chat does not support reactions).`,
);
changed = true;
}
const groups = asObjectRecord(updated.groups); const groups = asObjectRecord(updated.groups);
if (groups) { if (groups) {
const normalized = normalizeGoogleChatGroups({ const normalized = normalizeGoogleChatGroups({
@@ -101,6 +121,18 @@ function normalizeGoogleChatEntry(params: {
} }
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "googlechat"],
message:
'channels.googlechat.actions.reactions is retired and ignored. Run "openclaw doctor --fix".',
match: hasRetiredReactions,
},
{
path: ["channels", "googlechat", "accounts"],
message:
'channels.googlechat.accounts.<id>.actions.reactions is retired and ignored. Run "openclaw doctor --fix".',
match: (value) => hasLegacyAccountAliases(value, hasRetiredReactions),
},
{ {
path: ["channels", "googlechat"], path: ["channels", "googlechat"],
message: "channels.googlechat.streamMode is legacy and no longer used; it is removed on load.", message: "channels.googlechat.streamMode is legacy and no longer used; it is removed on load.",
@@ -140,10 +140,8 @@ describe("googlechat inbound access policy", () => {
account: { account: {
accountId: "default", accountId: "default",
config: { config: {
dm: { dmPolicy: "allowlist",
policy: "allowlist", allowFrom,
allowFrom,
},
}, },
} as never, } as never,
senderId, senderId,
@@ -170,7 +168,7 @@ describe("googlechat inbound access policy", () => {
const account = { const account = {
accountId: "default", accountId: "default",
config: { config: {
dm: { policy: "pairing" }, dmPolicy: "pairing",
}, },
}; };
@@ -305,10 +303,8 @@ describe("googlechat inbound access policy", () => {
account: { account: {
accountId: "default", accountId: "default",
config: { config: {
dm: { dmPolicy: "allowlist",
policy: "allowlist", allowFrom: ["accessGroup:operators"],
allowFrom: ["accessGroup:operators"],
},
}, },
} as never, } as never,
}); });
@@ -327,10 +323,8 @@ describe("googlechat inbound access policy", () => {
account: { account: {
accountId: "default", accountId: "default",
config: { config: {
dm: { dmPolicy: "allowlist",
policy: "allowlist", allowFrom: ["users/alice"],
allowFrom: ["users/alice"],
},
groups: { groups: {
"spaces/AAA": { "spaces/AAA": {
enabled: true, enabled: true,
+2 -2
View File
@@ -264,8 +264,8 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
const groupEntry = groupConfigResolved.entry; const groupEntry = groupConfigResolved.entry;
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? []; const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
let effectiveWasMentioned: boolean | undefined; let effectiveWasMentioned: boolean | undefined;
const dmPolicy = account.config.dm?.policy ?? "pairing"; const dmPolicy = account.config.dmPolicy ?? "pairing";
const rawConfigAllowFrom = normalizeStringEntries(account.config.dm?.allowFrom); const rawConfigAllowFrom = normalizeStringEntries(account.config.allowFrom);
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const groupActivation = (() => { const groupActivation = (() => {
if (!isGroup) { if (!isGroup) {
+13 -21
View File
@@ -38,57 +38,49 @@ const promptAllowFrom = createPromptParsedAllowFromForAccount({
entries: mergeAllowFromEntries(undefined, splitSetupEntries(raw)), entries: mergeAllowFromEntries(undefined, splitSetupEntries(raw)),
}), }),
getExistingAllowFrom: ({ cfg, accountId }) => getExistingAllowFrom: ({ cfg, accountId }) =>
resolveGoogleChatAccount({ cfg, accountId }).config.dm?.allowFrom ?? [], resolveGoogleChatAccount({ cfg, accountId }).config.allowFrom ?? [],
applyAllowFrom: ({ cfg, accountId, allowFrom }) => applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
applySetupAccountConfigPatch({ applySetupAccountConfigPatch({
cfg, cfg,
channelKey: channel, channelKey: channel,
accountId, accountId,
patch: { patch: { allowFrom },
dm: {
...resolveGoogleChatAccount({ cfg, accountId }).config.dm,
allowFrom,
},
},
}), }),
}); });
const googlechatDmPolicy: ChannelSetupDmPolicy = { const googlechatDmPolicy: ChannelSetupDmPolicy = {
label: "Google Chat", label: "Google Chat",
channel, channel,
policyKey: "channels.googlechat.dm.policy", policyKey: "channels.googlechat.dmPolicy",
allowFromKey: "channels.googlechat.dm.allowFrom", allowFromKey: "channels.googlechat.allowFrom",
resolveConfigKeys: (cfg, accountId) => resolveConfigKeys: (cfg, accountId) =>
(accountId ?? resolveDefaultGoogleChatAccountId(cfg)) !== DEFAULT_ACCOUNT_ID (accountId ?? resolveDefaultGoogleChatAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
? { ? {
policyKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dm.policy`, policyKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dmPolicy`,
allowFromKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dm.allowFrom`, allowFromKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.allowFrom`,
} }
: { : {
policyKey: "channels.googlechat.dm.policy", policyKey: "channels.googlechat.dmPolicy",
allowFromKey: "channels.googlechat.dm.allowFrom", allowFromKey: "channels.googlechat.allowFrom",
}, },
getCurrent: (cfg, accountId) => getCurrent: (cfg, accountId) =>
resolveGoogleChatAccount({ resolveGoogleChatAccount({
cfg, cfg,
accountId: accountId ?? resolveDefaultGoogleChatAccountId(cfg), accountId: accountId ?? resolveDefaultGoogleChatAccountId(cfg),
}).config.dm?.policy ?? "pairing", }).config.dmPolicy ?? "pairing",
setPolicy: (cfg, policy, accountId) => { setPolicy: (cfg, policy, accountId) => {
const resolvedAccountId = accountId ?? resolveDefaultGoogleChatAccountId(cfg); const resolvedAccountId = accountId ?? resolveDefaultGoogleChatAccountId(cfg);
const currentDm = resolveGoogleChatAccount({ const current = resolveGoogleChatAccount({
cfg, cfg,
accountId: resolvedAccountId, accountId: resolvedAccountId,
}).config.dm; }).config;
return applySetupAccountConfigPatch({ return applySetupAccountConfigPatch({
cfg, cfg,
channelKey: channel, channelKey: channel,
accountId: resolvedAccountId, accountId: resolvedAccountId,
patch: { patch: {
dm: { dmPolicy: policy,
...currentDm, ...(policy === "open" ? { allowFrom: addWildcardAllowFrom(current.allowFrom) } : {}),
policy,
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(currentDm?.allowFrom) } : {}),
},
}, },
}); });
}, },
+18 -34
View File
@@ -258,15 +258,11 @@ describe("googlechat setup", () => {
{ {
channels: { channels: {
googlechat: { googlechat: {
dm: { dmPolicy: "disabled",
policy: "disabled",
},
accounts: { accounts: {
alerts: { alerts: {
serviceAccount: { client_email: "bot@example.com" }, serviceAccount: { client_email: "bot@example.com" },
dm: { dmPolicy: "allowlist",
policy: "allowlist",
},
}, },
}, },
}, },
@@ -324,8 +320,8 @@ describe("googlechat setup", () => {
it("reports account-scoped config keys for named accounts", () => { it("reports account-scoped config keys for named accounts", () => {
expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({ expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({
policyKey: "channels.googlechat.accounts.alerts.dm.policy", policyKey: "channels.googlechat.accounts.alerts.dmPolicy",
allowFromKey: "channels.googlechat.accounts.alerts.dm.allowFrom", allowFromKey: "channels.googlechat.accounts.alerts.allowFrom",
}); });
}); });
@@ -334,15 +330,11 @@ describe("googlechat setup", () => {
channels: { channels: {
googlechat: { googlechat: {
defaultAccount: "alerts", defaultAccount: "alerts",
dm: { dmPolicy: "disabled",
policy: "disabled",
},
accounts: { accounts: {
alerts: { alerts: {
serviceAccount: { client_email: "bot@example.com" }, serviceAccount: { client_email: "bot@example.com" },
dm: { dmPolicy: "allowlist",
policy: "allowlist",
},
}, },
}, },
}, },
@@ -351,13 +343,13 @@ describe("googlechat setup", () => {
expect(googlechatSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist"); expect(googlechatSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({ expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
policyKey: "channels.googlechat.accounts.alerts.dm.policy", policyKey: "channels.googlechat.accounts.alerts.dmPolicy",
allowFromKey: "channels.googlechat.accounts.alerts.dm.allowFrom", allowFromKey: "channels.googlechat.accounts.alerts.allowFrom",
}); });
const next = googlechatSetupWizard.dmPolicy?.setPolicy(cfg, "open"); const next = googlechatSetupWizard.dmPolicy?.setPolicy(cfg, "open");
expect(next?.channels?.googlechat?.dm?.policy).toBe("disabled"); expect(next?.channels?.googlechat?.dmPolicy).toBe("disabled");
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open"); expect(next?.channels?.googlechat?.accounts?.alerts?.dmPolicy).toBe("open");
}); });
it("uses configured defaultAccount for omitted allowFrom prompt context", async () => { it("uses configured defaultAccount for omitted allowFrom prompt context", async () => {
@@ -371,15 +363,11 @@ describe("googlechat setup", () => {
channels: { channels: {
googlechat: { googlechat: {
defaultAccount: "alerts", defaultAccount: "alerts",
dm: { allowFrom: ["users/root"],
allowFrom: ["users/root"],
},
accounts: { accounts: {
alerts: { alerts: {
serviceAccount: { client_email: "bot@example.com" }, serviceAccount: { client_email: "bot@example.com" },
dm: { allowFrom: ["users/alerts"],
allowFrom: ["users/alerts"],
},
}, },
}, },
}, },
@@ -388,10 +376,8 @@ describe("googlechat setup", () => {
prompter, prompter,
}); });
expect(next?.channels?.googlechat?.dm?.allowFrom).toEqual(["users/root"]); expect(next?.channels?.googlechat?.allowFrom).toEqual(["users/root"]);
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual([ expect(next?.channels?.googlechat?.accounts?.alerts?.allowFrom).toEqual(["users/123456789"]);
"users/123456789",
]);
}); });
it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => { it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
@@ -399,9 +385,7 @@ describe("googlechat setup", () => {
{ {
channels: { channels: {
googlechat: { googlechat: {
dm: { allowFrom: ["users/123"],
allowFrom: ["users/123"],
},
accounts: { accounts: {
alerts: { alerts: {
serviceAccount: { client_email: "bot@example.com" }, serviceAccount: { client_email: "bot@example.com" },
@@ -414,9 +398,9 @@ describe("googlechat setup", () => {
"alerts", "alerts",
); );
expect(next?.channels?.googlechat?.dm?.policy).toBeUndefined(); expect(next?.channels?.googlechat?.dmPolicy).toBeUndefined();
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open"); expect(next?.channels?.googlechat?.accounts?.alerts?.dmPolicy).toBe("open");
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual(["users/123", "*"]); expect(next?.channels?.googlechat?.accounts?.alerts?.allowFrom).toEqual(["users/123", "*"]);
}); });
it("keeps startAccount pending until abort, then unregisters", async () => { it("keeps startAccount pending until abort, then unregisters", async () => {
-2
View File
@@ -17,8 +17,6 @@ const ThreadBindingsSchema = z
maxAgeHours: z.number().optional(), maxAgeHours: z.number().optional(),
spawnSessions: z.boolean().optional(), spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(), defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
}) })
.strict(); .strict();
-4
View File
@@ -15,10 +15,6 @@ interface LineThreadBindingsConfig {
maxAgeHours?: number; maxAgeHours?: number;
spawnSessions?: boolean; spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork"; defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
} }
interface LineAccountBaseConfig { interface LineAccountBaseConfig {
-2
View File
@@ -33,8 +33,6 @@ const matrixThreadBindingsSchema = z
maxAgeHours: z.number().nonnegative().optional(), maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(), spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(), defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
}) })
.optional(); .optional();
-4
View File
@@ -72,10 +72,6 @@ type MatrixThreadBindingsConfig = {
maxAgeHours?: number; maxAgeHours?: number;
spawnSessions?: boolean; spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork"; defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
}; };
type MatrixExecApprovalTarget = "dm" | "channel" | "both"; type MatrixExecApprovalTarget = "dm" | "channel" | "both";
+20 -2
View File
@@ -113,7 +113,6 @@ const MCP_CONNECTION_FIELDS = [
"url", "url",
"connectionTimeoutMs", "connectionTimeoutMs",
"requestTimeoutMs", "requestTimeoutMs",
"timeout",
] as const; ] as const;
export function importsMcpSensitiveValues( export function importsMcpSensitiveValues(
@@ -173,7 +172,26 @@ export function mapMcpServer(
} else if (!transport && readString(next.url)) { } else if (!transport && readString(next.url)) {
next.transport = "streamable-http"; next.transport = "streamable-http";
} }
next.connectTimeout = value.connectTimeout ?? value.connect_timeout; // Canonical timeout fields are finite().positive(); drop non-positive or
// overflowing source values instead of importing config that fails validation.
const connectionTimeoutSeconds = value.connectTimeout ?? value.connect_timeout;
if (
next.connectionTimeoutMs === undefined &&
typeof connectionTimeoutSeconds === "number" &&
connectionTimeoutSeconds > 0 &&
Number.isFinite(connectionTimeoutSeconds * 1_000)
) {
next.connectionTimeoutMs = connectionTimeoutSeconds * 1_000;
}
const requestTimeoutSeconds = value.timeout;
if (
next.requestTimeoutMs === undefined &&
typeof requestTimeoutSeconds === "number" &&
requestTimeoutSeconds > 0 &&
Number.isFinite(requestTimeoutSeconds * 1_000)
) {
next.requestTimeoutMs = requestTimeoutSeconds * 1_000;
}
next.supportsParallelToolCalls = readBoolean( next.supportsParallelToolCalls = readBoolean(
value.supportsParallelToolCalls ?? value.supports_parallel_tool_calls, value.supportsParallelToolCalls ?? value.supports_parallel_tool_calls,
); );
+2 -2
View File
@@ -115,8 +115,8 @@ describe("Hermes migration config mapping", () => {
enabled: false, enabled: false,
command: "npx", command: "npx",
args: ["-y", "mcp-server-time"], args: ["-y", "mcp-server-time"],
timeout: 45, connectionTimeoutMs: 10_000,
connectTimeout: 10, requestTimeoutMs: 45_000,
supportsParallelToolCalls: true, supportsParallelToolCalls: true,
}, },
}); });
@@ -436,7 +436,7 @@ export async function createMatrixQaOpenClawCliRuntime(params: {
FORCE_COLOR: "0", FORCE_COLOR: "0",
NO_COLOR: "1", NO_COLOR: "1",
OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_AUTO_UPDATE: "1", OPENCLAW_NO_AUTO_UPDATE: "1",
OPENCLAW_STATE_DIR: stateDir, OPENCLAW_STATE_DIR: stateDir,
}; };
return { return {
@@ -82,7 +82,7 @@ export async function createMatrixQaCliSelfVerificationRuntime(params: {
FORCE_COLOR: "0", FORCE_COLOR: "0",
NO_COLOR: "1", NO_COLOR: "1",
OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_AUTO_UPDATE: "1", OPENCLAW_NO_AUTO_UPDATE: "1",
OPENCLAW_STATE_DIR: stateDir, OPENCLAW_STATE_DIR: stateDir,
}; };
const run = async (args: string[], timeoutMs = params.context.timeoutMs, stdin?: string) => const run = async (args: string[], timeoutMs = params.context.timeoutMs, stdin?: string) =>
@@ -145,7 +145,7 @@ export async function createMatrixQaCliE2eeSetupRuntime(params: {
FORCE_COLOR: "0", FORCE_COLOR: "0",
NO_COLOR: "1", NO_COLOR: "1",
OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_AUTO_UPDATE: "1", OPENCLAW_NO_AUTO_UPDATE: "1",
OPENCLAW_STATE_DIR: stateDir, OPENCLAW_STATE_DIR: stateDir,
}; };
const run = async (args: string[], timeoutMs = params.context.timeoutMs, stdin?: string) => const run = async (args: string[], timeoutMs = params.context.timeoutMs, stdin?: string) =>
@@ -190,7 +190,7 @@ export async function createMatrixQaCliGatewayRuntime(params: {
...requireMatrixQaCliRuntimeEnv(params.context), ...requireMatrixQaCliRuntimeEnv(params.context),
FORCE_COLOR: "0", FORCE_COLOR: "0",
NO_COLOR: "1", NO_COLOR: "1",
OPENCLAW_DISABLE_AUTO_UPDATE: "1", OPENCLAW_NO_AUTO_UPDATE: "1",
}; };
const run = async (args: string[], timeoutMs = params.context.timeoutMs) => const run = async (args: string[], timeoutMs = params.context.timeoutMs) =>
await runMatrixQaOpenClawCli({ await runMatrixQaOpenClawCli({
@@ -58,10 +58,6 @@ type MatrixQaThreadBindingsConfigOverrides = {
maxAgeHours?: number; maxAgeHours?: number;
spawnSessions?: boolean; spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork"; defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
}; };
type MatrixQaExecApprovalsConfigOverrides = { type MatrixQaExecApprovalsConfigOverrides = {
agentFilter?: string[]; agentFilter?: string[];
@@ -6,7 +6,6 @@ type SlackReplyToMode = "off" | "first" | "all" | "batched";
type SlackReplyToModeAccount = { type SlackReplyToModeAccount = {
replyToMode?: SlackReplyToMode; replyToMode?: SlackReplyToMode;
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
dm?: { replyToMode?: SlackReplyToMode };
}; };
function normalizeSlackChatType(raw?: string): "direct" | "group" | "channel" | undefined { function normalizeSlackChatType(raw?: string): "direct" | "group" | "channel" | undefined {
@@ -31,8 +30,5 @@ export function resolveSlackReplyToMode(
if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) { if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) {
return account.replyToModeByChatType[normalized] ?? "off"; return account.replyToModeByChatType[normalized] ?? "off";
} }
if (normalized === "direct" && account.dm?.replyToMode !== undefined) {
return account.dm.replyToMode;
}
return account.replyToMode ?? "off"; return account.replyToMode ?? "off";
} }
+8 -9
View File
@@ -363,7 +363,7 @@ describe("resolveSlackAccount allowFrom precedence", () => {
expect(resolved.config.allowFrom).toBeUndefined(); expect(resolved.config.allowFrom).toBeUndefined();
}); });
it("falls back to top-level dm.allowFrom when allowFrom alias is unset", () => { it("does not treat retired nested dm.allowFrom as canonical", () => {
const resolved = resolveSlackAccount({ const resolved = resolveSlackAccount({
cfg: { cfg: {
channels: { channels: {
@@ -374,15 +374,14 @@ describe("resolveSlackAccount allowFrom precedence", () => {
}, },
}, },
}, },
}, } as never,
accountId: "work", accountId: "work",
}); });
expect(resolved.config.allowFrom).toBeUndefined(); expect(resolved.config.allowFrom).toBeUndefined();
expect(resolved.config.dm?.allowFrom).toEqual(["U123"]);
}); });
it("resolves account legacy dm.allowFrom before inherited root allowFrom", () => { it("resolves account allowFrom before inherited root allowFrom", () => {
const cfg = { const cfg = {
channels: { channels: {
slack: { slack: {
@@ -391,14 +390,14 @@ describe("resolveSlackAccount allowFrom precedence", () => {
work: { work: {
botToken: "xoxb-work", botToken: "xoxb-work",
appToken: "xapp-work", appToken: "xapp-work",
dm: { allowFrom: ["account-legacy"] }, allowFrom: ["account"],
}, },
}, },
}, },
}, },
} satisfies OpenClawConfig; } satisfies OpenClawConfig;
expect(resolveSlackAccountAllowFrom({ cfg, accountId: "work" })).toEqual(["account-legacy"]); expect(resolveSlackAccountAllowFrom({ cfg, accountId: "work" })).toEqual(["account"]);
}); });
it("coerces numeric allowFrom entries at the config boundary", () => { it("coerces numeric allowFrom entries at the config boundary", () => {
@@ -419,7 +418,7 @@ describe("resolveSlackAccount allowFrom precedence", () => {
expect(resolveSlackAccountAllowFrom({ cfg, accountId: "work" })).toEqual(["12345"]); expect(resolveSlackAccountAllowFrom({ cfg, accountId: "work" })).toEqual(["12345"]);
}); });
it("resolves account legacy dm policy before inherited root policy", () => { it("resolves account DM policy before inherited root policy", () => {
const cfg = { const cfg = {
channels: { channels: {
slack: { slack: {
@@ -428,7 +427,7 @@ describe("resolveSlackAccount allowFrom precedence", () => {
work: { work: {
botToken: "xoxb-work", botToken: "xoxb-work",
appToken: "xapp-work", appToken: "xapp-work",
dm: { policy: "allowlist" }, dmPolicy: "allowlist",
}, },
}, },
}, },
@@ -448,7 +447,7 @@ describe("resolveSlackAccount allowFrom precedence", () => {
Work: { Work: {
botToken: "xoxb-work", botToken: "xoxb-work",
appToken: "xapp-work", appToken: "xapp-work",
dm: { policy: "allowlist" }, dmPolicy: "allowlist",
allowFrom: ["U123"], allowFrom: ["U123"],
}, },
}, },
+2 -12
View File
@@ -10,8 +10,6 @@ import {
import { import {
mapAllowFromEntries, mapAllowFromEntries,
normalizeChannelDmPolicy, normalizeChannelDmPolicy,
resolveChannelDmAllowFrom,
resolveChannelDmPolicy,
type ChannelDmPolicy, type ChannelDmPolicy,
} from "openclaw/plugin-sdk/channel-config-helpers"; } from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
@@ -202,10 +200,7 @@ export function resolveSlackAccountAllowFrom(params: {
); );
const accountConfig = resolveSlackAccountConfig(params.cfg, accountId); const accountConfig = resolveSlackAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined; const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined;
const allowFrom = resolveChannelDmAllowFrom({ const allowFrom = accountConfig?.allowFrom ?? rootConfig?.allowFrom;
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
});
return allowFrom ? mapAllowFromEntries(allowFrom) : undefined; return allowFrom ? mapAllowFromEntries(allowFrom) : undefined;
} }
@@ -232,12 +227,7 @@ export function resolveSlackAccountDmPolicy(params: {
); );
const accountConfig = resolveSlackAccountConfig(params.cfg, accountId); const accountConfig = resolveSlackAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined; const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined;
const policy = resolveChannelDmPolicy({ return normalizeChannelDmPolicy(accountConfig?.dmPolicy ?? rootConfig?.dmPolicy ?? "pairing");
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
defaultPolicy: "pairing",
});
return normalizeChannelDmPolicy(policy);
} }
export function resolveSlackAccount(params: { export function resolveSlackAccount(params: {
+1 -1
View File
@@ -193,7 +193,7 @@ function hasPotentialSlackNamedPolicy(params: {
} }
function resolveSlackDmReadAllowed(account: ResolvedSlackAccount): boolean { function resolveSlackDmReadAllowed(account: ResolvedSlackAccount): boolean {
const dmPolicy = account.config.dmPolicy ?? account.config.dm?.policy ?? "pairing"; const dmPolicy = account.config.dmPolicy ?? "pairing";
return account.config.dm?.enabled !== false && dmPolicy !== "disabled"; return account.config.dm?.enabled !== false && dmPolicy !== "disabled";
} }
+1 -2
View File
@@ -7,8 +7,7 @@ describe("isSlackApprovalAuthorizedSender", () => {
const cfg = { const cfg = {
channels: { channels: {
slack: { slack: {
allowFrom: ["slack:U123OWNER"], allowFrom: ["slack:U123OWNER", "<@U234DM>"],
dm: { allowFrom: ["<@U234DM>"] },
defaultTo: "user:U345DEFAULT", defaultTo: "user:U345DEFAULT",
execApprovals: { enabled: true, approvers: ["user:U999EXEC"] }, execApprovals: { enabled: true, approvers: ["user:U999EXEC"] },
}, },
+4 -2
View File
@@ -801,7 +801,8 @@ describe("slackPlugin security", () => {
cfg: { cfg: {
channels: { channels: {
slack: { slack: {
dm: { policy: "allowlist", allowFrom: [" slack:U123 "] }, dmPolicy: "allowlist",
allowFrom: [" slack:U123 "],
}, },
}, },
} as OpenClawConfig, } as OpenClawConfig,
@@ -811,7 +812,8 @@ describe("slackPlugin security", () => {
slack: { slack: {
botToken: "xoxb-test", botToken: "xoxb-test",
appToken: "xapp-test", appToken: "xapp-test",
dm: { policy: "allowlist", allowFrom: [" slack:U123 "] }, dmPolicy: "allowlist",
allowFrom: [" slack:U123 "],
}, },
}, },
} as OpenClawConfig, } as OpenClawConfig,
+2 -5
View File
@@ -211,11 +211,8 @@ describe("slack config schema", () => {
); );
}); });
it('accepts legacy dm.policy="open" with top-level allowFrom alias', () => { it("rejects legacy nested DM access keys", () => {
expectSlackConfigValid({ expectSlackConfigIssue({ dm: { policy: "open", allowFrom: ["U123"] } }, "dm");
dm: { policy: "open", allowFrom: ["U123"] },
allowFrom: ["*"],
});
}); });
it("accepts user token config fields", () => { it("accepts user token config fields", () => {
+1 -1
View File
@@ -17,7 +17,7 @@ export const slackChannelConfigUiHints = {
}, },
...createChannelConfigUiHints({ ...createChannelConfigUiHints({
channelLabel: "Slack", channelLabel: "Slack",
dmPolicy: { channelKey: "slack", includeLegacyNestedPolicy: true }, dmPolicy: { channelKey: "slack" },
configWrites: true, configWrites: true,
mentionPatterns: { mentionPatterns: {
targetDescription: "Slack channel IDs", targetDescription: "Slack channel IDs",
@@ -56,7 +56,7 @@ describe("Slack directory contract", () => {
slack: { slack: {
botToken: "xoxb-test", botToken: "xoxb-test",
appToken: "xapp-test", appToken: "xapp-test",
dm: { allowFrom: ["U123", "user:U999"] }, allowFrom: ["U123", "user:U999"],
dms: { U234: {} }, dms: { U234: {} },
channels: { C111: { users: ["U777"] } }, channels: { C111: { users: ["U777"] } },
}, },
@@ -83,7 +83,7 @@ describe("Slack directory contract", () => {
slack: { slack: {
botToken: envSecret, botToken: envSecret,
appToken: envSecret, appToken: envSecret,
dm: { allowFrom: ["U123"] }, allowFrom: ["U123"],
channels: { C111: {} }, channels: { C111: {} },
}, },
}, },
@@ -99,7 +99,7 @@ describe("Slack directory contract", () => {
slack: { slack: {
botToken: "xoxb-test", botToken: "xoxb-test",
appToken: "xapp-test", appToken: "xapp-test",
dm: { allowFrom: ["U100", "U200"] }, allowFrom: ["U100", "U200"],
dms: { U300: {} }, dms: { U300: {} },
}, },
}, },
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { normalizeCompatibilityConfig } from "./doctor-contract.js";
describe("slack doctor contract", () => {
it("moves direct DM reply mode to the chat-type map", () => {
const result = normalizeCompatibilityConfig({
cfg: {
channels: {
slack: {
dm: { replyToMode: "all" },
accounts: { work: { dm: { replyToMode: "first" } } },
},
},
} as never,
});
expect(result.config.channels?.slack).toEqual({
dm: {},
replyToModeByChatType: { direct: "all" },
accounts: {
work: { dm: {}, replyToModeByChatType: { direct: "first" } },
},
});
});
});
+43
View File
@@ -34,6 +34,31 @@ function hasLegacySlackThreadMentionPolicy(value: unknown): boolean {
return Boolean(thread && Object.hasOwn(thread, "requireExplicitMention")); return Boolean(thread && Object.hasOwn(thread, "requireExplicitMention"));
} }
function hasLegacyDmReplyMode(value: unknown): boolean {
return Object.hasOwn(asObjectRecord(asObjectRecord(value)?.dm) ?? {}, "replyToMode");
}
function migrateDmReplyMode(
entry: Record<string, unknown>,
path: string,
changes: string[],
): boolean {
const dm = asObjectRecord(entry.dm);
if (!dm || !Object.hasOwn(dm, "replyToMode")) {
return false;
}
const byType = asObjectRecord(entry.replyToModeByChatType) ?? {};
if (byType.direct === undefined) {
byType.direct = dm.replyToMode;
entry.replyToModeByChatType = byType;
changes.push(`Moved ${path}.dm.replyToMode → ${path}.replyToModeByChatType.direct.`);
} else {
changes.push(`Removed ${path}.dm.replyToMode (replyToModeByChatType.direct already set).`);
}
delete dm.replyToMode;
return true;
}
function normalizeSlackThreadMentionPolicy(params: { function normalizeSlackThreadMentionPolicy(params: {
value: Record<string, unknown>; value: Record<string, unknown>;
pathPrefix: string; pathPrefix: string;
@@ -110,6 +135,19 @@ function normalizeSlackChannelAllowAliases(params: {
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
...streamingAliasMigration.legacyConfigRules, ...streamingAliasMigration.legacyConfigRules,
{
path: ["channels", "slack"],
message:
'channels.slack.dm.replyToMode moved to replyToModeByChatType.direct. Run "openclaw doctor --fix".',
match: hasLegacyDmReplyMode,
},
{
path: ["channels", "slack", "accounts"],
message:
'channels.slack.accounts.<id>.dm.replyToMode moved to replyToModeByChatType.direct. Run "openclaw doctor --fix".',
match: (value) =>
Object.values(asObjectRecord(value) ?? {}).some((account) => hasLegacyDmReplyMode(account)),
},
{ {
path: ["channels", "slack"], path: ["channels", "slack"],
message: message:
@@ -163,6 +201,7 @@ export function normalizeCompatibilityConfig({
} }
let updated = rawEntry; let updated = rawEntry;
let changed = aliases.config !== cfg; let changed = aliases.config !== cfg;
changed = migrateDmReplyMode(updated, "channels.slack", changes) || changed;
const normalizedThreadPolicy = normalizeSlackThreadMentionPolicy({ const normalizedThreadPolicy = normalizeSlackThreadMentionPolicy({
value: updated, value: updated,
@@ -196,6 +235,10 @@ export function normalizeCompatibilityConfig({
if (!account) { if (!account) {
continue; continue;
} }
if (migrateDmReplyMode(account, `channels.slack.accounts.${accountId}`, changes)) {
nextAccounts[accountId] = account;
accountsChanged = true;
}
const normalizedAccountThreadPolicy = normalizeSlackThreadMentionPolicy({ const normalizedAccountThreadPolicy = normalizeSlackThreadMentionPolicy({
value: account, value: account,
pathPrefix: `channels.slack.accounts.${accountId}`, pathPrefix: `channels.slack.accounts.${accountId}`,
+1 -2
View File
@@ -91,8 +91,7 @@ describe("slack exec approvals", () => {
const cfg = buildConfig( const cfg = buildConfig(
{ enabled: true }, { enabled: true },
{ {
allowFrom: ["slack:U123"], allowFrom: ["slack:U123", "<@U456>"],
dm: { allowFrom: ["<@U456>"] },
defaultTo: "user:U789", defaultTo: "user:U789",
}, },
); );
+3 -1
View File
@@ -293,7 +293,9 @@ export const defaultSlackTestConfig = () => ({
}, },
channels: { channels: {
slack: { slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open", groupPolicy: "open",
}, },
}, },
@@ -62,7 +62,9 @@ describe("monitorSlackProvider tool results", () => {
}, },
channels: { channels: {
slack: { slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
replyToMode, replyToMode,
}, },
}, },
@@ -109,7 +111,9 @@ describe("monitorSlackProvider tool results", () => {
: {}), : {}),
channels: { channels: {
slack: { slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
channels: { C1: { allow: true, requireMention: true } }, channels: { C1: { allow: true, requireMention: true } },
}, },
}, },
@@ -152,7 +156,9 @@ describe("monitorSlackProvider tool results", () => {
channels: { channels: {
slack: { slack: {
historyLimit: 5, historyLimit: 5,
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
channels, channels,
}, },
}, },
@@ -187,7 +193,9 @@ describe("monitorSlackProvider tool results", () => {
...currentConfig.channels, ...currentConfig.channels,
slack: { slack: {
...currentConfig.channels?.slack, ...currentConfig.channels?.slack,
dm: { enabled: true, policy: "pairing", allowFrom: [] }, dm: { enabled: true },
dmPolicy: "pairing",
allowFrom: [],
}, },
}, },
}; };
@@ -202,7 +210,9 @@ describe("monitorSlackProvider tool results", () => {
visibleReplies?: "automatic" | "message_tool"; visibleReplies?: "automatic" | "message_tool";
}) { }) {
const slackChannelConfig: Record<string, unknown> = { const slackChannelConfig: Record<string, unknown> = {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
channels: { C1: { allow: true, requireMention: false } }, channels: { C1: { allow: true, requireMention: false } },
...(params?.groupPolicy ? { groupPolicy: params.groupPolicy } : {}), ...(params?.groupPolicy ? { groupPolicy: params.groupPolicy } : {}),
...(params?.replyToMode ? { replyToMode: params.replyToMode } : {}), ...(params?.replyToMode ? { replyToMode: params.replyToMode } : {}),
@@ -262,7 +272,9 @@ describe("monitorSlackProvider tool results", () => {
}, },
channels: { channels: {
slack: { slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open", groupPolicy: "open",
}, },
}, },
@@ -504,7 +516,9 @@ describe("monitorSlackProvider tool results", () => {
}, },
channels: { channels: {
slack: { slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open", groupPolicy: "open",
requireMention: false, requireMention: false,
}, },
@@ -536,7 +550,9 @@ describe("monitorSlackProvider tool results", () => {
}, },
channels: { channels: {
slack: { slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open", groupPolicy: "open",
requireMention: false, requireMention: false,
}, },
@@ -628,7 +644,9 @@ describe("monitorSlackProvider tool results", () => {
}, },
channels: { channels: {
slack: { slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open", groupPolicy: "open",
}, },
}, },
@@ -714,7 +732,9 @@ describe("monitorSlackProvider tool results", () => {
}, },
channels: { channels: {
slack: { slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open", groupPolicy: "open",
}, },
}, },
@@ -217,7 +217,6 @@ describe("assertEnterpriseSlackPolicyConfig", () => {
["prefixed channels key", { channels: { "channel:general": {} } }], ["prefixed channels key", { channels: { "channel:general": {} } }],
["allowFrom", { allowFrom: ["ursula"] }], ["allowFrom", { allowFrom: ["ursula"] }],
["prefixed allowFrom", { allowFrom: ["slack:ursula"] }], ["prefixed allowFrom", { allowFrom: ["slack:ursula"] }],
["legacy DM allowFrom", { dm: { allowFrom: ["ursula"] } }],
["group DM channel", { dm: { groupChannels: ["general"] } }], ["group DM channel", { dm: { groupChannels: ["general"] } }],
["reaction allowlist", { reactionNotifications: "allowlist", reactionAllowlist: ["ursula"] }], ["reaction allowlist", { reactionNotifications: "allowlist", reactionAllowlist: ["ursula"] }],
["channel users", { channels: { C01234567: { users: ["ursula"] } } }], ["channel users", { channels: { C01234567: { users: ["ursula"] } } }],
@@ -118,8 +118,8 @@ export function assertEnterpriseSlackPolicyConfig(params: {
predicate: isStableSlackAllowlistUserEntry, predicate: isStableSlackAllowlistUserEntry,
}); });
assertStableEntries({ assertStableEntries({
values: config.dm?.allowFrom, values: config.allowFrom,
path: `channels.slack.accounts.${accountId}.dm.allowFrom`, path: `channels.slack.accounts.${accountId}.allowFrom`,
predicate: isStableSlackAllowlistUserEntry, predicate: isStableSlackAllowlistUserEntry,
}); });
assertStableEntries({ assertStableEntries({
@@ -277,8 +277,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
enabled: true, enabled: true,
accounts: { accounts: {
soltea: { soltea: {
dm: { enabled: true },
dmPolicy: "open", dmPolicy: "open",
dm: { enabled: true, policy: "open" },
}, },
}, },
}, },
@@ -293,8 +293,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
const prepared = await prepareSlackMessage({ const prepared = await prepareSlackMessage({
ctx, ctx,
account: createSlackAccount({ account: createSlackAccount({
dm: { enabled: true },
dmPolicy: "open", dmPolicy: "open",
dm: { enabled: true, policy: "open" },
}), }),
message: createSlackMessage({ channel: "D999", user: "U123", text: "hello" }), message: createSlackMessage({ channel: "D999", user: "U123", text: "hello" }),
opts: { source: "message" }, opts: { source: "message" },
@@ -2149,18 +2149,6 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
expect(prepared.ctxPayload.MessageThreadId).toBeUndefined(); expect(prepared.ctxPayload.MessageThreadId).toBeUndefined();
}); });
it("respects dm.replyToMode legacy override for DMs", async () => {
const prepared = await prepareMessageWith(
createReplyToAllSlackCtx(),
createSlackAccount({ replyToMode: "all", dm: { replyToMode: "off" } }),
createSlackMessage({}), // DM
);
assertPrepared(prepared);
expect(prepared.replyToMode).toBe("off");
expect(prepared.ctxPayload.MessageThreadId).toBeUndefined();
});
it("marks first thread turn and injects thread history for a new thread session", async () => { it("marks first thread turn and injects thread history for a new thread session", async () => {
const { storePath } = storeFixture.makeTmpStorePath(); const { storePath } = storeFixture.makeTmpStorePath();
const replies = vi const replies = vi
@@ -4471,7 +4459,7 @@ describe("prepareSlackMessage sender prefix", () => {
it("detects /new as control command when prefixed with Slack mention", async () => { it("detects /new as control command when prefixed with Slack mention", async () => {
const ctx = createSenderPrefixCtx({ const ctx = createSenderPrefixCtx({
channels: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } }, channels: { dm: { enabled: true }, dmPolicy: "open", allowFrom: ["*"] },
allowFrom: ["U1"], allowFrom: ["U1"],
useAccessGroups: true, useAccessGroups: true,
slashCommand: { slashCommand: {
@@ -344,7 +344,9 @@ describe("user identity provider transport", () => {
identity: "user", identity: "user",
userToken: "test-user-token", userToken: "test-user-token",
appToken: "test-app-token", appToken: "test-app-token",
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open", groupPolicy: "open",
}, },
}, },
@@ -485,7 +487,9 @@ describe("user identity provider transport", () => {
mode: "http", mode: "http",
userToken: "test-user-token", userToken: "test-user-token",
signingSecret: "test-signing-secret", signingSecret: "test-signing-secret",
dm: { enabled: true, policy: "open", allowFrom: ["*"] }, dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open", groupPolicy: "open",
}, },
}, },
@@ -77,18 +77,6 @@ describe("buildSlackThreadingToolContext", () => {
expect(result.replyToMode).toBe("first"); expect(result.replyToMode).toBe("first");
}); });
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
expect(
resolveReplyToModeWithConfig({
slackConfig: {
replyToMode: "off",
dm: { replyToMode: "all" },
},
context: { ChatType: "direct" },
}),
).toBe("all");
});
it("uses all mode when MessageThreadId is present", () => { it("uses all mode when MessageThreadId is present", () => {
expect( expect(
resolveReplyToModeWithConfig({ resolveReplyToModeWithConfig({
+12 -2
View File
@@ -198,7 +198,12 @@ describe("telegram doctor", () => {
} as never, } as never,
}); });
const telegram = result.config.channels?.telegram; const telegram = result.config.channels?.telegram as
| (NonNullable<typeof result.config.channels>["telegram"] & {
dm?: unknown;
accounts?: Record<string, { dm?: unknown; direct?: Record<string, unknown> }>;
})
| undefined;
expect(telegram?.dm).toBeUndefined(); expect(telegram?.dm).toBeUndefined();
expect(telegram?.direct?.["123"]).toEqual({ requireTopic: true }); expect(telegram?.direct?.["123"]).toEqual({ requireTopic: true });
expect(telegram?.accounts?.work?.dm).toBeUndefined(); expect(telegram?.accounts?.work?.dm).toBeUndefined();
@@ -232,7 +237,12 @@ describe("telegram doctor", () => {
} as never, } as never,
}); });
const telegram = result.config.channels?.telegram; const telegram = result.config.channels?.telegram as
| (NonNullable<typeof result.config.channels>["telegram"] & {
dm?: unknown;
accounts?: Record<string, { dm?: unknown }>;
})
| undefined;
expect(telegram?.dm).toBeUndefined(); expect(telegram?.dm).toBeUndefined();
expect(telegram?.accounts?.work?.dm).toBeUndefined(); expect(telegram?.accounts?.work?.dm).toBeUndefined();
expect(result.changes).toEqual([ expect(result.changes).toEqual([
-3
View File
@@ -71,9 +71,6 @@ describe("resolveWhatsAppAuthDir", () => {
it("merges top-level and account-specific config through shared helpers", () => { it("merges top-level and account-specific config through shared helpers", () => {
const resolved = resolveWhatsAppAccount({ const resolved = resolveWhatsAppAccount({
cfg: { cfg: {
messages: {
messagePrefix: "[global]",
},
channels: { channels: {
whatsapp: { whatsapp: {
sendReadReceipts: false, sendReadReceipts: false,
+1 -1
View File
@@ -133,7 +133,7 @@ export function resolveWhatsAppAccount(params: {
name: normalizeOptionalString(merged.name), name: normalizeOptionalString(merged.name),
enabled, enabled,
sendReadReceipts: merged.sendReadReceipts ?? true, sendReadReceipts: merged.sendReadReceipts ?? true,
messagePrefix: merged.messagePrefix ?? params.cfg.messages?.messagePrefix, messagePrefix: merged.messagePrefix,
defaultTo: merged.defaultTo, defaultTo: merged.defaultTo,
authDir, authDir,
isLegacyAuthDir: isLegacy, isLegacyAuthDir: isLegacy,
@@ -139,8 +139,8 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
)?.identity, )?.identity,
resolveIdentityNamePrefix: (cfg: { messages?: { responsePrefix?: string } }, _agentId: string) => resolveIdentityNamePrefix: (cfg: { messages?: { responsePrefix?: string } }, _agentId: string) =>
cfg.messages?.responsePrefix, cfg.messages?.responsePrefix,
resolveMessagePrefix: (cfg: { messages?: { messagePrefix?: string } }) => resolveMessagePrefix: (_cfg: unknown, _agentId: string, opts?: { configured?: string }) =>
cfg.messages?.messagePrefix, opts?.configured,
runEmbeddedAgent: vi.fn(), runEmbeddedAgent: vi.fn(),
})); }));
@@ -28,7 +28,7 @@ export function resolveMessagePrefix(
agentId: string, agentId: string,
opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string }, opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string },
): string { ): string {
const configured = opts?.configured ?? cfg.messages?.messagePrefix; const configured = opts?.configured;
if (configured !== undefined) { if (configured !== undefined) {
return configured; return configured;
} }
@@ -36,7 +36,7 @@ export function buildInboundLine(params: {
visibleReplyTo?: WhatsAppReplyContext | null; visibleReplyTo?: WhatsAppReplyContext | null;
}) { }) {
const { cfg, msg, agentId, previousTimestamp, envelope } = params; const { cfg, msg, agentId, previousTimestamp, envelope } = params;
// WhatsApp inbound prefix: channels.whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults // WhatsApp inbound prefix: channels.whatsapp.messagePrefix > identity/defaults.
const messagePrefix = resolveMessagePrefix(cfg, agentId, { const messagePrefix = resolveMessagePrefix(cfg, agentId, {
configured: cfg.channels?.whatsapp?.messagePrefix, configured: cfg.channels?.whatsapp?.messagePrefix,
hasAllowFrom: (cfg.channels?.whatsapp?.allowFrom?.length ?? 0) > 0, hasAllowFrom: (cfg.channels?.whatsapp?.allowFrom?.length ?? 0) > 0,
@@ -16,6 +16,17 @@ describe("whatsapp streaming legacy config rules", () => {
}); });
}); });
it("removes retired exposeErrorText at root and account level", () => {
const result = normalizeCompatibilityConfig({
cfg: whatsappConfig({ exposeErrorText: true, accounts: { work: { exposeErrorText: false } } }),
});
expect(result.config.channels?.whatsapp).toEqual({ accounts: { work: {} } });
expect(result.changes).toContain("Removed retired channels.whatsapp.exposeErrorText.");
expect(result.changes).toContain(
"Removed retired channels.whatsapp.accounts.work.exposeErrorText.",
);
});
describe("whatsapp normalizeCompatibilityConfig streaming aliases", () => { describe("whatsapp normalizeCompatibilityConfig streaming aliases", () => {
it("moves flat delivery aliases at root and account level with root seeding", () => { it("moves flat delivery aliases at root and account level with root seeding", () => {
const result = normalizeCompatibilityConfig({ const result = normalizeCompatibilityConfig({
+52 -4
View File
@@ -20,8 +20,55 @@ const streamingAliasMigration = defineChannelAliasMigration({
streaming: { defaultMode: "partial", deliveryOnly: true }, streaming: { defaultMode: "partial", deliveryOnly: true },
}); });
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = const hasExposeErrorText = (value: unknown): boolean =>
streamingAliasMigration.legacyConfigRules; Object.hasOwn(asObjectRecord(value) ?? {}, "exposeErrorText");
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
...streamingAliasMigration.legacyConfigRules,
{
path: ["channels", "whatsapp", "exposeErrorText"],
message:
'channels.whatsapp.exposeErrorText is retired and ignored. Run "openclaw doctor --fix".',
},
{
path: ["channels", "whatsapp", "accounts"],
message:
'channels.whatsapp.accounts.<id>.exposeErrorText is retired and ignored. Run "openclaw doctor --fix".',
match: (value) => Object.values(asObjectRecord(value) ?? {}).some(hasExposeErrorText),
},
];
function removeExposeErrorText(cfg: OpenClawConfig, changes: string[]): OpenClawConfig {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = asObjectRecord(channels?.whatsapp);
if (!entry) {
return cfg;
}
let changed = false;
const next = { ...entry };
if (Object.hasOwn(next, "exposeErrorText")) {
delete next.exposeErrorText;
changes.push("Removed retired channels.whatsapp.exposeErrorText.");
changed = true;
}
const accounts = asObjectRecord(next.accounts);
if (accounts) {
const nextAccounts = { ...accounts };
for (const [id, value] of Object.entries(accounts)) {
const account = asObjectRecord(value);
if (!account || !Object.hasOwn(account, "exposeErrorText")) {
continue;
}
const cleaned = { ...account };
delete cleaned.exposeErrorText;
nextAccounts[id] = cleaned;
changes.push(`Removed retired channels.whatsapp.accounts.${id}.exposeErrorText.`);
changed = true;
}
next.accounts = nextAccounts;
}
return changed ? ({ ...cfg, channels: { ...channels, whatsapp: next } } as OpenClawConfig) : cfg;
}
// The runtime merge replaces `streaming` wholesale per layer (named account > // The runtime merge replaces `streaming` wholesale per layer (named account >
// accounts.default > root), while the retired flat keys resolved per key // accounts.default > root), while the retired flat keys resolved per key
@@ -103,12 +150,13 @@ export function normalizeCompatibilityConfig({
cfg: OpenClawConfig; cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation { }): ChannelDoctorConfigMutation {
const ackReaction = normalizeAckReactionConfig({ cfg }); const ackReaction = normalizeAckReactionConfig({ cfg });
const retiredConfig = removeExposeErrorText(ackReaction.config, ackReaction.changes);
const accountsBefore = asObjectRecord( const accountsBefore = asObjectRecord(
asObjectRecord((ackReaction.config.channels as Record<string, unknown> | undefined)?.whatsapp) asObjectRecord((retiredConfig.channels as Record<string, unknown> | undefined)?.whatsapp)
?.accounts, ?.accounts,
); );
const aliases = streamingAliasMigration.normalizeChannelConfig({ const aliases = streamingAliasMigration.normalizeChannelConfig({
cfg: ackReaction.config, cfg: retiredConfig,
changes: ackReaction.changes, changes: ackReaction.changes,
}); });
return { return {
@@ -288,7 +288,7 @@ describe("web inbound media saves with extension", () => {
monitorWebInbox({ monitorWebInbox({
cfg: { cfg: {
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined }, messages: { responsePrefix: undefined },
web: { whatsapp: { connectTimeoutMs: 12_345 } }, web: { whatsapp: { connectTimeoutMs: 12_345 } },
} as never, } as never,
verbose: false, verbose: false,
@@ -309,7 +309,7 @@ describe("web inbound media saves with extension", () => {
const listener = await monitorWebInbox({ const listener = await monitorWebInbox({
cfg: { cfg: {
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined }, messages: { responsePrefix: undefined },
} as never, } as never,
verbose: false, verbose: false,
onMessage, onMessage,
@@ -362,7 +362,7 @@ describe("web inbound media saves with extension", () => {
const listener = await monitorWebInbox({ const listener = await monitorWebInbox({
cfg: { cfg: {
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined }, messages: { responsePrefix: undefined },
} as never, } as never,
verbose: false, verbose: false,
onMessage, onMessage,
@@ -410,7 +410,7 @@ describe("web inbound media saves with extension", () => {
const listener = await monitorWebInbox({ const listener = await monitorWebInbox({
cfg: { cfg: {
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined }, messages: { responsePrefix: undefined },
} as never, } as never,
verbose: false, verbose: false,
onMessage, onMessage,
@@ -447,7 +447,7 @@ describe("web inbound media saves with extension", () => {
const listener = await monitorWebInbox({ const listener = await monitorWebInbox({
cfg: { cfg: {
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined }, messages: { responsePrefix: undefined },
} as never, } as never,
verbose: false, verbose: false,
onMessage, onMessage,
+1 -2
View File
@@ -632,12 +632,11 @@ vi.mock("./auto-reply/monitor/message-line.runtime.js", () => ({
resolveMessagePrefix: ( resolveMessagePrefix: (
cfg: { cfg: {
channels?: { whatsapp?: { messagePrefix?: string; allowFrom?: string[] } }; channels?: { whatsapp?: { messagePrefix?: string; allowFrom?: string[] } };
messages?: { messagePrefix?: string };
}, },
_agentId: string, _agentId: string,
params?: { configured?: string; hasAllowFrom?: boolean }, params?: { configured?: string; hasAllowFrom?: boolean },
) => { ) => {
const configured = params?.configured ?? cfg.messages?.messagePrefix; const configured = params?.configured ?? cfg.channels?.whatsapp?.messagePrefix;
if (configured !== undefined) { if (configured !== undefined) {
return configured; return configured;
} }
+1
View File
@@ -45,6 +45,7 @@ export const RELEASE_METADATA_PATHS = new Set([
"apps/android/version.json", "apps/android/version.json",
"apps/ios/CHANGELOG.md", "apps/ios/CHANGELOG.md",
"apps/macos/Sources/OpenClaw/Resources/Info.plist", "apps/macos/Sources/OpenClaw/Resources/Info.plist",
"docs/.generated/config-baseline.counts.json",
"docs/.generated/config-baseline.sha256", "docs/.generated/config-baseline.sha256",
"docs/install/updating.md", "docs/install/updating.md",
"package.json", "package.json",
+22 -9
View File
@@ -20,23 +20,36 @@ const result = await writeConfigDocBaselineArtifacts({
if (checkOnly) { if (checkOnly) {
if (!result.changed) { if (!result.changed) {
console.log(`OK ${path.relative(repoRoot, result.hashPath)}`); console.log(
`OK ${path.relative(repoRoot, result.hashPath)} and ${path.relative(repoRoot, result.countsPath)}`,
);
process.exit(0); process.exit(0);
} }
console.error( if (result.countBudgetError) {
[ console.error(
"Config baseline drift detected.", `Config baseline count budget invalid: ${result.countBudgetError}. Run \`pnpm config:docs:gen\` to recreate ${path.relative(repoRoot, result.countsPath)}.`,
`Hash mismatch: ${path.relative(repoRoot, result.hashPath)}`, );
"If this config-surface change is intentional, run `pnpm config:docs:gen` and commit the updated hash file.", }
"If not intentional, treat this as docs drift or a possible breaking config change and fix the schema/help changes first.", for (const violation of result.countViolations) {
].join("\n"), console.error(violation.message);
); }
if (result.hashChanged) {
console.error(
[
"Config baseline drift detected.",
`Hash mismatch: ${path.relative(repoRoot, result.hashPath)}`,
"If this config-surface change is intentional, run `pnpm config:docs:gen` and commit the updated hash file.",
"If not intentional, treat this as docs drift or a possible breaking config change and fix the schema/help changes first.",
].join("\n"),
);
}
process.exit(1); process.exit(1);
} }
console.log( console.log(
[ [
`Wrote ${path.relative(repoRoot, result.hashPath)}`, `Wrote ${path.relative(repoRoot, result.hashPath)}`,
`Wrote ${path.relative(repoRoot, result.countsPath)}`,
`Wrote ${path.relative(repoRoot, result.jsonPaths.combined)} (gitignored, local only)`, `Wrote ${path.relative(repoRoot, result.jsonPaths.combined)} (gitignored, local only)`,
`Wrote ${path.relative(repoRoot, result.jsonPaths.core)} (gitignored, local only)`, `Wrote ${path.relative(repoRoot, result.jsonPaths.core)} (gitignored, local only)`,
`Wrote ${path.relative(repoRoot, result.jsonPaths.channel)} (gitignored, local only)`, `Wrote ${path.relative(repoRoot, result.jsonPaths.channel)} (gitignored, local only)`,
+2 -1
View File
@@ -284,7 +284,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: SwarmConfig exposes the tools.swarm contract through config-types. // +1: SwarmConfig exposes the tools.swarm contract through config-types.
// +3: harness sessionFork capability params, result, and failure-code contracts. // +3: harness sessionFork capability params, result, and failure-code contracts.
// +2: upstream-link registry write/delete for harness-owned session forks. // +2: upstream-link registry write/delete for harness-owned session forks.
8183, // Harvest: mention-pattern schemas and helper exports -3.
8179,
env, env,
), ),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv( publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
+1 -1
View File
@@ -71,7 +71,7 @@ function resolveMessagePrefix(
agentId: string, agentId: string,
opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string }, opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string },
): string { ): string {
const configured = opts?.configured ?? cfg.messages?.messagePrefix; const configured = opts?.configured;
if (configured !== undefined) { if (configured !== undefined) {
return configured; return configured;
} }
+6 -17
View File
@@ -32,11 +32,11 @@ describe("resolveMcpTransportConfig", () => {
}); });
}); });
it("resolves operator timeout aliases and parallel capability", () => { it("resolves canonical timeouts and parallel capability", () => {
const resolved = resolveMcpTransportConfig("probe", { const resolved = resolveMcpTransportConfig("probe", {
command: "node", command: "node",
timeout: 7, requestTimeoutMs: 7_000,
connectTimeout: 2, connectionTimeoutMs: 2_000,
supportsParallelToolCalls: true, supportsParallelToolCalls: true,
}); });
@@ -49,25 +49,14 @@ describe("resolveMcpTransportConfig", () => {
); );
}); });
it("clamps oversized MCP request timeout aliases to the Node timer maximum", () => { it("clamps oversized canonical MCP timeouts to the Node timer maximum", () => {
const seconds = resolveMcpTransportConfig("probe", { const resolved = resolveMcpTransportConfig("probe", {
command: "node",
connectTimeout: 1e306,
timeout: 1e306,
});
const milliseconds = resolveMcpTransportConfig("probe", {
command: "node", command: "node",
connectionTimeoutMs: 1e306, connectionTimeoutMs: 1e306,
requestTimeoutMs: 1e306, requestTimeoutMs: 1e306,
}); });
expect(seconds).toEqual( expect(resolved).toEqual(
expect.objectContaining({
connectionTimeoutMs: MAX_TIMER_TIMEOUT_MS,
requestTimeoutMs: MAX_TIMER_TIMEOUT_MS,
}),
);
expect(milliseconds).toEqual(
expect.objectContaining({ expect.objectContaining({
connectionTimeoutMs: MAX_TIMER_TIMEOUT_MS, connectionTimeoutMs: MAX_TIMER_TIMEOUT_MS,
requestTimeoutMs: MAX_TIMER_TIMEOUT_MS, requestTimeoutMs: MAX_TIMER_TIMEOUT_MS,
-9
View File
@@ -3,7 +3,6 @@
*/ */
import { import {
clampPositiveTimerTimeoutMs, clampPositiveTimerTimeoutMs,
finiteSecondsToTimerSafeMilliseconds,
resolvePositiveTimerTimeoutMs, resolvePositiveTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion"; } from "@openclaw/normalization-core/number-coercion";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
@@ -76,10 +75,6 @@ function getConnectionTimeoutMs(rawServer: unknown): number {
if (milliseconds) { if (milliseconds) {
return clampPositiveTimerTimeoutMs(milliseconds) ?? DEFAULT_CONNECTION_TIMEOUT_MS; return clampPositiveTimerTimeoutMs(milliseconds) ?? DEFAULT_CONNECTION_TIMEOUT_MS;
} }
const seconds = getPositiveNumber(rawServer, ["connectTimeout", "connect_timeout"]);
if (seconds) {
return finiteSecondsToTimerSafeMilliseconds(seconds) ?? 1;
}
return DEFAULT_CONNECTION_TIMEOUT_MS; return DEFAULT_CONNECTION_TIMEOUT_MS;
} }
@@ -91,10 +86,6 @@ export function resolveMcpRequestTimeoutMs(
if (milliseconds) { if (milliseconds) {
return clampPositiveTimerTimeoutMs(milliseconds) ?? DEFAULT_REQUEST_TIMEOUT_MS; return clampPositiveTimerTimeoutMs(milliseconds) ?? DEFAULT_REQUEST_TIMEOUT_MS;
} }
const seconds = getPositiveNumber(rawServer, ["timeout"]);
if (seconds) {
return finiteSecondsToTimerSafeMilliseconds(seconds) ?? 1;
}
return resolvePositiveTimerTimeoutMs(fallbackMs, DEFAULT_REQUEST_TIMEOUT_MS); return resolvePositiveTimerTimeoutMs(fallbackMs, DEFAULT_REQUEST_TIMEOUT_MS);
} }

Some files were not shown because too many files have changed in this diff Show More