mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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>"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) } : {}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+3
-3
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"] },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)`,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user