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

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

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

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

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

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

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

* fix(config): satisfy lint and contract gates
This commit is contained in:
Peter Steinberger
2026-07-19 00:52:37 -07:00
committed by GitHub
parent 33b50089cd
commit 58452de711
212 changed files with 2782 additions and 3166 deletions
+2 -1
View File
@@ -266,8 +266,9 @@ ui/src/ui/__screenshots__
ui/src/ui/views/__screenshots__
ui/.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/config-baseline.counts.json
docs/.generated/*.jsonl
# Deprecated changelog fragment workflow
+1
View File
@@ -6,6 +6,7 @@ remain local inspection artifacts.
**Tracked (committed to git):**
- `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.
- `sqlite-session-transcript-schema-baseline.sha256` — hash of the sessions/transcripts SQLite schema baseline.
@@ -0,0 +1,5 @@
{
"core": 3007,
"channel": 3732,
"plugin": 3537
}
+4 -4
View File
@@ -1,4 +1,4 @@
53e7eea9a0193d94f5afe8e774456ae71cd3eddf99e5224602fcf13bb845051b config-baseline.json
97c9ec219366ad2fab4d83d5b05eb3ae3df71db68747e7b31cc697a76676c620 config-baseline.core.json
f75efa4f3fa4ac7c2e7dc6580a1dd1efaa8e2e13ec7df9c21838c8ebc44a3dca config-baseline.channel.json
e4f096d6d3c39fefdd1649cab8336b8dfba1f2194eb804fa6126eb25fe2d2b93 config-baseline.plugin.json
2f4ba51e354995c0d28052d7c61210ace881145af0db7f5409f8eb7dff167a4f config-baseline.json
0e9d04a669b853f8b58397d2dfc8913b5a3dd70af20ebd43e25b08be4cd3d292 config-baseline.core.json
c624c55cb46a67db4c836dcc04d295db966579885a00778d1a32241632de3475 config-baseline.channel.json
0e0592b95e4958477e539abd5ccb336c63735c7d69133199953c48ff3c0845a8 config-baseline.plugin.json
+1 -1
View File
@@ -199,7 +199,7 @@ Notes:
- Group keys must be stable space ids (`spaces/<spaceId>`). Display-name keys are deprecated and logged as such.
- `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.
- 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.
- `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).
+1 -1
View File
@@ -218,7 +218,7 @@ Retention behavior:
## Migrating older jobs
<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>
## Common edits
+27 -29
View File
@@ -368,7 +368,7 @@ Those saved definitions are for runtimes that OpenClaw launches or configures la
- `add` builds a definition from flags and probes before saving unless `--no-probe` is set or OAuth authorization is needed first
- 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
- `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
- 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
@@ -620,8 +620,8 @@ Example config shape:
"docs": {
"url": "https://mcp.example.com",
"transport": "streamable-http",
"timeout": 20,
"connectTimeout": 5,
"requestTimeoutMs": 20000,
"connectionTimeoutMs": 5000,
"supportsParallelToolCalls": true,
"auth": "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.
| Field | Description |
| ------------------------------ | ---------------------------------------------------------------- |
| `url` | HTTP or HTTPS URL of the remote server (required) |
| `headers` | Optional key-value map of HTTP headers (for example auth tokens) |
| `connectionTimeoutMs` | Per-server connection timeout in ms (optional) |
| `connectTimeout` | Per-server connection timeout in seconds (optional) |
| `timeout` / `requestTimeoutMs` | Per-server MCP request timeout in seconds or ms |
| `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` |
| `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints |
| `clientCert` / `clientKey` | mTLS client certificate and key paths |
| `supportsParallelToolCalls` | Hint that concurrent calls are safe for this server |
| Field | Description |
| --------------------------- | ---------------------------------------------------------------- |
| `url` | HTTP or HTTPS URL of the remote server (required) |
| `headers` | Optional key-value map of HTTP headers (for example auth tokens) |
| `connectionTimeoutMs` | Per-server connection timeout in ms (optional) |
| `requestTimeoutMs` | Per-server MCP request timeout in milliseconds |
| `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` |
| `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints |
| `clientCert` / `clientKey` | mTLS client certificate and key paths |
| `supportsParallelToolCalls` | Hint that concurrent calls are safe for this server |
Example:
@@ -684,7 +683,7 @@ Example:
"remote-tools": {
"url": "https://mcp.example.com",
"auth": "oauth",
"timeout": 20,
"requestTimeoutMs": 20000,
"headers": {
"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.
| Field | Description |
| ------------------------------ | -------------------------------------------------------------------------------------- |
| `url` | HTTP or HTTPS URL of the remote server (required) |
| `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) |
| `connectionTimeoutMs` | Per-server connection timeout in ms (optional) |
| `connectTimeout` | Per-server connection timeout in seconds (optional) |
| `timeout` / `requestTimeoutMs` | Per-server MCP request timeout in seconds or ms |
| `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` |
| `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints |
| `clientCert` / `clientKey` | mTLS client certificate and key paths |
| `supportsParallelToolCalls` | Hint that concurrent calls are safe for this server |
| Field | Description |
| --------------------------- | -------------------------------------------------------------------------------------- |
| `url` | HTTP or HTTPS URL of the remote server (required) |
| `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) |
| `connectionTimeoutMs` | Per-server connection timeout in ms (optional) |
| `requestTimeoutMs` | Per-server MCP request timeout in milliseconds |
| `auth: "oauth"` | Use MCP OAuth credentials saved by `openclaw mcp login` |
| `sslVerify` | Set false only for explicitly trusted private HTTPS endpoints |
| `clientCert` / `clientKey` | mTLS client certificate and key paths |
| `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.
@@ -792,8 +790,8 @@ Example:
"streaming-tools": {
"url": "https://mcp.example.com/stream",
"transport": "streamable-http",
"connectTimeout": 10,
"timeout": 30,
"connectionTimeoutMs": 10000,
"requestTimeoutMs": 30000,
"headers": {
"Authorization": "Bearer <token>"
}
-3
View File
@@ -45,9 +45,6 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with
- Launched from inside a configured agent workspace directory, TUI auto-selects
that agent for the session key default (unless `--session` is explicitly
`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,
but Gateway-only features are unavailable.
- Local mode adds `/auth [provider]` to the TUI command surface.
+1 -1
View File
@@ -1433,7 +1433,7 @@ Batches rapid text-only messages from the same sender into a single agent turn.
### Other message keys
- `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.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.
-7
View File
@@ -318,9 +318,6 @@ Configures inbound media understanding (image/audio/video):
tools: {
media: {
concurrency: 2,
asyncCompletion: {
directSend: false, // deprecated: completions stay agent-mediated
},
audio: {
enabled: true,
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`.
**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>
</AccordionGroup>
+4 -6
View File
@@ -95,8 +95,8 @@ target server during config edits.
remote: {
url: "https://example.com/mcp",
transport: "streamable-http", // streamable-http | sse
timeout: 20,
connectTimeout: 5,
requestTimeoutMs: 20000,
connectionTimeoutMs: 5000,
supportsParallelToolCalls: true,
headers: {
Authorization: "Bearer ${MCP_REMOTE_TOKEN}",
@@ -130,10 +130,8 @@ target server during config edits.
`openclaw doctor --fix` normalize into the canonical `transport` field.
- `mcp.servers.<name>.enabled`: set `false` to keep a saved server definition
while excluding it from embedded OpenClaw MCP discovery and tool projection.
- `mcp.servers.<name>.timeout` / `requestTimeoutMs`: per-server MCP request
timeout in seconds or milliseconds.
- `mcp.servers.<name>.connectTimeout` / `connectionTimeoutMs`: per-server
connection timeout in seconds or milliseconds.
- `mcp.servers.<name>.requestTimeoutMs`: per-server MCP request timeout in milliseconds.
- `mcp.servers.<name>.connectionTimeoutMs`: per-server connection timeout in milliseconds.
- `mcp.servers.<name>.supportsParallelToolCalls`: optional concurrency hint for
adapters that can choose whether to issue parallel MCP tool calls.
- `mcp.servers.<name>.auth`: set `"oauth"` for HTTP MCP servers that require
+7 -2
View File
@@ -283,6 +283,11 @@ That stages grounded durable candidates into the short-term dreaming store while
| `browser.profiles.*.driver: "extension"` | `"existing-session"` |
| `browser.relayBindHost` | removed (legacy Chrome extension relay setting) |
| `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) |
| `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) |
@@ -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 delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
- 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.
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.
+2 -3
View File
@@ -1138,9 +1138,8 @@ sessionId})`; create, branch, continue, list, and fork flows live in their
the imported sources. Plugin target writebacks update matching `cron_jobs`
rows instead of loading and replacing the whole cron store.
- Gateway startup ignores legacy `notify: true` markers in the runtime
projection. Doctor translates them into explicit SQLite delivery when
`cron.webhook` is valid, removes inert markers when it is unset, and preserves
them with a warning when the configured webhook is invalid.
projection. Doctor reads the retired raw `cron.webhook` only while translating
those markers into explicit SQLite delivery, then removes the config key.
- Outbound and session delivery queues now store queue status, entry kind,
session key, channel, target, account id, retry count, last attempt/error,
recovery state, and platform-send markers as typed columns in the shared
+1 -1
View File
@@ -285,7 +285,7 @@ select it to open the owning Approvals page.
- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, agent model/thinking overrides, and best-effort delivery toggles.
- 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.
- `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>
</AccordionGroup>
+1 -9
View File
@@ -52,7 +52,7 @@ openclaw tui --local
- Header: connection URL, current agent, current session.
- Chat log: user messages, assistant replies, system notices, tool cards.
- 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.
## Mental model: agents + sessions
@@ -66,14 +66,6 @@ openclaw tui --local
- `per-sender` (default): each agent has many sessions.
- `global`: the TUI always uses the `global` session (the picker may be empty).
- 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:
`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.
+2 -13
View File
@@ -8,8 +8,6 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
mapAllowFromEntries,
normalizeChannelDmPolicy,
resolveChannelDmAllowFrom,
resolveChannelDmPolicy,
type ChannelDmPolicy,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
@@ -68,11 +66,7 @@ export function resolveDiscordAccountAllowFrom(params: {
);
const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined;
const allowFrom = resolveChannelDmAllowFrom({
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
});
const allowFrom = accountConfig?.allowFrom ?? rootConfig?.allowFrom;
return allowFrom ? mapAllowFromEntries(allowFrom) : undefined;
}
@@ -85,12 +79,7 @@ export function resolveDiscordAccountDmPolicy(params: {
);
const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.discord as DiscordAccountConfig | undefined;
const policy = resolveChannelDmPolicy({
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
defaultPolicy: "pairing",
});
return normalizeChannelDmPolicy(policy);
return normalizeChannelDmPolicy(accountConfig?.dmPolicy ?? rootConfig?.dmPolicy ?? "pairing");
}
export function createDiscordActionGate(params: {
@@ -308,8 +308,7 @@ export function createDiscordMessagingActionContext(params: {
const directOperator = params.options?.conversationReadOrigin === "direct-operator";
const currentReadContext = params.options?.readContext;
const directDmEnabled =
accountConfig.dm?.enabled !== false &&
(accountConfig.dmPolicy ?? accountConfig.dm?.policy ?? "pairing") !== "disabled";
accountConfig.dm?.enabled !== false && (accountConfig.dmPolicy ?? "pairing") !== "disabled";
const withOpts = (extra?: Record<string, unknown>) =>
createDiscordActionOptions({ cfg: params.cfg, accountId, extra });
const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(params.cfg);
+11 -7
View File
@@ -911,7 +911,8 @@ describe("handleDiscordMessagingAction", () => {
qa: {
token: "token",
groupPolicy: "open",
dm: { enabled: false, policy: "disabled" },
dm: { enabled: false },
dmPolicy: "disabled",
guilds: {
"111": {
channels: {
@@ -936,7 +937,8 @@ describe("handleDiscordMessagingAction", () => {
discord: {
token: "token",
groupPolicy: "open",
dm: { enabled: true, policy: "pairing", groupEnabled: false },
dm: { enabled: true, groupEnabled: false },
dmPolicy: "pairing",
},
},
} as OpenClawConfig,
@@ -953,9 +955,9 @@ describe("handleDiscordMessagingAction", () => {
discord: {
token: "token",
groupPolicy: "open",
dmPolicy: "pairing",
dm: {
enabled: true,
policy: "pairing",
groupEnabled: true,
groupChannels: ["allowed-group"],
},
@@ -995,9 +997,9 @@ describe("handleDiscordMessagingAction", () => {
discord: {
token: "token",
groupPolicy: "disabled",
dmPolicy: "disabled",
dm: {
enabled: false,
policy: "disabled",
groupEnabled: true,
groupChannels: ["allowed-group"],
},
@@ -1027,7 +1029,8 @@ describe("handleDiscordMessagingAction", () => {
discord: {
token: "token",
groupPolicy: "disabled",
dm: { enabled: true, policy: "pairing" },
dm: { enabled: true },
dmPolicy: "pairing",
},
},
} as OpenClawConfig;
@@ -1051,9 +1054,9 @@ describe("handleDiscordMessagingAction", () => {
discord: {
token: "token",
groupPolicy: "open",
dmPolicy: "pairing",
dm: {
enabled: true,
policy: "pairing",
groupEnabled: false,
},
},
@@ -1079,7 +1082,8 @@ describe("handleDiscordMessagingAction", () => {
discord: {
token: "token",
groupPolicy: "open",
dm: { enabled: true, policy: "pairing" },
dm: { enabled: true },
dmPolicy: "pairing",
guilds: {
"111": {
channels: {
@@ -68,7 +68,8 @@ describe("discordPlugin security", () => {
channels: {
discord: {
token: "discord-token",
dm: { policy: "allowlist", allowFrom: [" discord:<@!123456789> "] },
dmPolicy: "allowlist",
allowFrom: [" discord:<@!123456789> "],
},
},
} as OpenClawConfig;
+4 -12
View File
@@ -39,19 +39,12 @@ describe("discord config schema", () => {
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({
dm: { policy: "open", allowFrom: [] },
});
expect(issues[0]?.path.join(".")).toBe("dm.allowFrom");
});
it('accepts legacy dm.policy="open" with top-level allowFrom alias', () => {
expectValidDiscordConfig({
dm: { policy: "open", allowFrom: ["123"] },
allowFrom: ["*"],
});
expect(issues[0]?.path.join(".")).toBe("dm");
});
it("accepts textChunkLimit without reviving legacy message limits", () => {
@@ -130,9 +123,9 @@ describe("discord config schema", () => {
it("loads guild map and dm group settings", () => {
const cfg = expectValidDiscordConfig({
enabled: true,
allowFrom: ["steipete"],
dm: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["openclaw-dm"],
},
@@ -309,7 +302,7 @@ describe("discord config schema", () => {
it("coerces safe-integer numeric allowlist entries to strings", () => {
const cfg = expectValidDiscordConfig({
allowFrom: [123],
dm: { allowFrom: [456], groupChannels: [789] },
dm: { groupChannels: [789] },
guilds: {
"123": {
users: [111],
@@ -323,7 +316,6 @@ describe("discord config schema", () => {
});
expect(cfg.allowFrom).toEqual(["123"]);
expect(cfg.dm?.allowFrom).toEqual(["456"]);
expect(cfg.dm?.groupChannels).toEqual(["789"]);
expect(cfg.guilds?.["123"]?.users).toEqual(["111"]);
expect(cfg.guilds?.["123"]?.roles).toEqual(["222"]);
+1 -9
View File
@@ -9,11 +9,7 @@ export const discordChannelConfigUiHints = {
},
...createChannelConfigUiHints({
channelLabel: "Discord",
dmPolicy: {
channelKey: "discord",
includeLegacyNestedPolicy: true,
legacyNestedPolicyOrder: "after",
},
dmPolicy: { channelKey: "discord" },
configWrites: true,
mentionPatterns: {
targetDescription: "Discord channel IDs",
@@ -195,10 +191,6 @@ export const discordChannelConfigUiHints = {
label: "Discord Realtime Speaker 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": {
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.",
@@ -21,7 +21,7 @@ describe("Discord directory contract", () => {
channels: {
discord: {
token: "discord-test",
dm: { allowFrom: ["<@111>", "<@!333>", "nope"] },
allowFrom: ["<@111>", "<@!333>", "nope"],
dms: { "222": {} },
guilds: {
"123": {
@@ -62,7 +62,7 @@ describe("Discord directory contract", () => {
channels: {
discord: {
token: envSecret,
dm: { allowFrom: ["<@111>"] },
allowFrom: ["<@111>"],
guilds: {
"123": {
channels: {
@@ -78,14 +78,14 @@ describe("Discord directory contract", () => {
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 = {
channels: {
discord: {
allowFrom: ["<@111>"],
accounts: {
work: {
dm: { allowFrom: ["<@222>"] },
allowFrom: ["<@222>"],
},
},
},
+3 -1
View File
@@ -545,7 +545,9 @@ describe("discord doctor", () => {
const result = maybeRepairDiscordNumericIds(cfg, "openclaw doctor --fix");
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?.roles).toEqual(["222"]);
expect(result.changes).not.toHaveLength(0);
@@ -67,8 +67,7 @@ describe("discord exec approvals", () => {
const cfg = buildConfig(
{ enabled: true },
{
allowFrom: ["123"],
dm: { allowFrom: ["456"] },
allowFrom: ["123", "456"],
defaultTo: "user:789",
},
);
@@ -105,7 +105,7 @@ export function createDiscordPreflightArgs(params: {
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
dmPolicy: params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing",
dmPolicy: params.discordConfig?.dmPolicy ?? "pairing",
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: createNoopThreadBindingManager("default"),
@@ -71,9 +71,9 @@ function createModelPickerContext(): ModelPickerContext {
},
channels: {
discord: {
dmPolicy: "open",
dm: {
enabled: true,
policy: "open",
},
},
},
@@ -266,7 +266,9 @@ describe("createDiscordNativeCommand option wiring", () => {
const cfg = {
channels: {
discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
},
},
} as OpenClawConfig;
@@ -502,7 +504,8 @@ describe("createDiscordNativeCommand option wiring", () => {
session: { dmScope: "main" },
channels: {
discord: {
dm: { enabled: true, policy: "disabled" },
dm: { enabled: true },
dmPolicy: "disabled",
},
},
} as OpenClawConfig;
@@ -510,7 +513,9 @@ describe("createDiscordNativeCommand option wiring", () => {
session: { dmScope: "per-channel-peer" },
channels: {
discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
},
},
} as OpenClawConfig;
@@ -607,9 +612,9 @@ describe("createDiscordNativeCommand option wiring", () => {
it("returns no autocomplete choices for group DMs outside dm.groupChannels", async () => {
const discordConfig = {
dmPolicy: "open",
dm: {
enabled: true,
policy: "open",
groupEnabled: true,
groupChannels: ["allowed-group"],
},
@@ -42,7 +42,9 @@ function createConfig(): OpenClawConfig {
return {
channels: {
discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
},
},
} as OpenClawConfig;
@@ -87,7 +89,9 @@ function createConfiguredAcpCase(params: {
? {
channels: {
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 = {
channels: {
discord: {
dm: { enabled: true, policy: "open", allowFrom: ["user:owner"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["user:owner"],
},
},
} as OpenClawConfig;
@@ -647,7 +653,9 @@ describe("Discord native plugin command dispatch", () => {
},
channels: {
discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
},
},
} as OpenClawConfig;
@@ -872,9 +880,9 @@ describe("Discord native plugin command dispatch", () => {
},
channels: {
discord: {
dmPolicy: "open",
dm: {
enabled: true,
policy: "open",
groupEnabled: true,
groupChannels: ["allowed-group"],
},
@@ -41,7 +41,9 @@ function createConfig(params?: { requireMention?: boolean }): OpenClawConfig {
},
channels: {
discord: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
guilds: {
guild1: {
requireMention: true,
@@ -100,7 +100,7 @@ describe("Discord security audit findings", () => {
},
},
},
} satisfies OpenClawConfig,
} as OpenClawConfig,
expectFinding: true,
},
{
@@ -122,13 +122,17 @@ describe("Discord security audit findings", () => {
},
},
},
} satisfies OpenClawConfig,
} as unknown as OpenClawConfig,
expectFinding: false,
},
])("$name", async (testCase) => {
const discordConfig = testCase.cfg.channels?.discord;
if (!discordConfig) {
throw new Error("discord config required");
}
const findings = await collectFindings({
cfg: testCase.cfg,
config: testCase.cfg.channels.discord,
config: discordConfig,
});
expect(
+6 -7
View File
@@ -87,14 +87,13 @@ describe("createDiscordPluginBase", () => {
});
describe("discordConfigAdapter", () => {
it("resolves top-level allowFrom before legacy dm.allowFrom", () => {
it("resolves canonical allowFrom", () => {
const cfg = {
channels: {
discord: {
accounts: {
default: {
allowFrom: ["123"],
dm: { allowFrom: ["456"] },
},
},
},
@@ -104,7 +103,7 @@ describe("discordConfigAdapter", () => {
expect(discordConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["123"]);
});
it("falls back to legacy dm.allowFrom", () => {
it("ignores retired nested dm.allowFrom", () => {
const cfg = {
channels: {
discord: {
@@ -117,17 +116,17 @@ describe("discordConfigAdapter", () => {
},
} 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 = {
channels: {
discord: {
allowFrom: ["root"],
accounts: {
work: {
dm: { allowFrom: ["account-legacy"] },
allowFrom: ["account"],
},
},
},
@@ -135,7 +134,7 @@ describe("discordConfigAdapter", () => {
} as OpenClawConfig;
expect(discordConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "work" })).toEqual([
"account-legacy",
"account",
]);
});
+5 -6
View File
@@ -159,13 +159,13 @@ describe("resolveDiscordTarget", () => {
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 = {
channels: {
discord: {
accounts: {
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 = {
channels: {
discord: {
accounts: {
default: {
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 = {
channels: {
discord: {
allowFrom: ["123"],
accounts: {
work: {
dm: { allowFrom: ["456"] },
allowFrom: ["456"],
},
},
},
+1 -3
View File
@@ -103,9 +103,7 @@ export async function authorizeDiscordVoiceIngress(params: {
});
const admissionAllowList = normalizeDiscordAllowList(
params.admissionAllowFrom ??
params.discordConfig.allowFrom ??
params.discordConfig.dm?.allowFrom,
params.admissionAllowFrom ?? params.discordConfig.allowFrom ?? params.discordConfig.allowFrom,
["discord:", "user:", "pk:"],
);
const admissionAllowed = admissionAllowList
@@ -5537,7 +5537,7 @@ describe("DiscordVoiceManager", () => {
realtime: {
provider: "openai",
model: "gpt-realtime-2",
voice: "cedar",
speakerVoice: "cedar",
toolPolicy: "safe-read-only",
consultPolicy: "always",
requireWakeName: true,
+1 -1
View File
@@ -669,7 +669,7 @@ export class DiscordVoiceManager {
}
const { route, voiceRoute, agentSessionMode, agentSessionTarget } = routeInfo;
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();
+1 -1
View File
@@ -24,7 +24,7 @@ export function resolveDiscordVoiceAccess(params: {
const admissionAllowFrom =
resolveDiscordAccountAllowFrom({ cfg: params.cfg, accountId: params.accountId }) ??
params.discordConfig.allowFrom ??
params.discordConfig.dm?.allowFrom ??
params.discordConfig.allowFrom ??
[];
return {
admissionAllowFrom,
+2 -4
View File
@@ -828,7 +828,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
this.harness.outputActivity.markPlaybackStarted();
const realtimeConfig = this.realtimeConfig;
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 }
: realtimeConfig?.speakerVoiceId
? { voice: realtimeConfig.speakerVoiceId }
: realtimeConfig?.voice
? { voice: realtimeConfig.voice }
: {}),
: {}),
...(typeof realtimeConfig?.minBargeInAudioEndMs === "number"
? { minBargeInAudioEndMs: realtimeConfig.minBargeInAudioEndMs }
: {}),
+10 -10
View File
@@ -49,7 +49,7 @@ describe("googlechat message actions", () => {
...overrides,
config: {
groupPolicy: "open",
dm: { policy: "open" },
dmPolicy: "open",
...overrideConfig,
},
};
@@ -75,7 +75,7 @@ describe("googlechat message actions", () => {
{
enabled: true,
credentialSource: "service-account",
config: { actions: { reactions: true } },
config: {},
},
]);
@@ -99,14 +99,14 @@ describe("googlechat message actions", () => {
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull();
});
it("keeps the legacy reaction gate from changing account-scoped discovery", () => {
resolveGoogleChatAccount.mockImplementation(({ accountId }: { accountId?: string | null }) => ({
enabled: true,
credentialSource: "service-account",
config: {
actions: { reactions: accountId === "work" },
},
}));
it("keeps account-scoped discovery send-only", () => {
resolveGoogleChatAccount.mockImplementation(
({ accountId: _accountId }: { accountId?: string | null }) => ({
enabled: true,
credentialSource: "service-account",
config: {},
}),
);
for (const accountId of ["default", "work"]) {
expect(
@@ -6,7 +6,7 @@ describe("googleChatApprovalAuth", () => {
it("authorizes stable users/* ids and ignores email-style approvers", () => {
expect(
googleChatApprovalAuth.authorizeActorAction({
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } },
cfg: { channels: { googlechat: { allowFrom: ["users/123"] } } },
senderId: "users/123",
action: "approve",
approvalKind: "exec",
@@ -15,7 +15,7 @@ describe("googleChatApprovalAuth", () => {
expect(
googleChatApprovalAuth.authorizeActorAction({
cfg: { channels: { googlechat: { dm: { allowFrom: ["owner@example.com"] } } } },
cfg: { channels: { googlechat: { allowFrom: ["owner@example.com"] } } },
senderId: "users/attacker",
action: "approve",
approvalKind: "exec",
+1 -1
View File
@@ -20,7 +20,7 @@ const googleChatApproval = createChannelApprovalAuth({
channelLabel: "Google Chat",
resolveInputs: ({ cfg, accountId }) => {
const account = resolveGoogleChatAccount({ cfg, accountId }).config;
return { allowFrom: account.dm?.allowFrom, defaultTo: account.defaultTo };
return { allowFrom: account.allowFrom, defaultTo: account.defaultTo };
},
normalizeApprover: normalizeGoogleChatApproverId,
});
@@ -67,13 +67,13 @@ function createTarget(): WebhookTarget {
enabled: true,
credentialSource: "inline",
config: {
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
},
config: {
channels: {
googlechat: {
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
},
},
@@ -52,7 +52,7 @@ const cfg: OpenClawConfig = {
audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat",
appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
},
};
@@ -431,7 +431,7 @@ describe("googleChatApprovalNativeRuntime", () => {
},
audienceType: "app-url",
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,
audience: "https://chat-app.example.test/googlechat",
appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
};
const execApprovalPayload: ReplyPayload = {
@@ -59,7 +59,7 @@ describe("googleChatApprovalCapability", () => {
audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat",
appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
},
},
@@ -81,7 +81,7 @@ describe("googleChatApprovalCapability", () => {
private_key: "test-key",
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",
},
audienceType: "project-number",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
},
},
@@ -119,7 +119,7 @@ describe("googleChatApprovalCapability", () => {
},
audienceType: "app-url" as const,
audience: "https://chat-app.example.test/googlechat",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
};
expect(
@@ -169,7 +169,7 @@ describe("googleChatApprovalCapability", () => {
},
audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
},
},
@@ -189,7 +189,7 @@ describe("googleChatApprovalCapability", () => {
},
audienceType: "project-number",
audience: "1234567890",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
},
},
@@ -200,7 +200,7 @@ describe("googleChatApprovalCapability", () => {
it("preserves Google Chat approval actor authorization", () => {
expect(
googleChatApprovalCapability.authorizeActorAction?.({
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } },
cfg: { channels: { googlechat: { allowFrom: ["users/123"] } } },
senderId: "users/123",
action: "approve",
approvalKind: "plugin",
@@ -209,7 +209,7 @@ describe("googleChatApprovalCapability", () => {
expect(
googleChatApprovalCapability.authorizeActorAction?.({
cfg: { channels: { googlechat: { dm: { allowFrom: ["users/123"] } } } },
cfg: { channels: { googlechat: { allowFrom: ["users/123"] } } },
senderId: "users/999",
action: "approve",
approvalKind: "plugin",
@@ -237,7 +237,7 @@ describe("googleChatApprovalCapability", () => {
audienceType: "app-url",
audience: "https://alpha.example.com/googlechat",
appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
beta: {
enabled: true,
@@ -250,7 +250,7 @@ describe("googleChatApprovalCapability", () => {
audienceType: "app-url",
audience: "https://beta.example.com/googlechat",
appPrincipal: "987654321098765432109",
dm: { allowFrom: ["users/456"] },
allowFrom: ["users/456"],
},
},
},
@@ -298,7 +298,7 @@ describe("googleChatApprovalCapability", () => {
audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat",
appPrincipal: "123456789012345678901",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
},
};
+1 -1
View File
@@ -202,7 +202,7 @@ export const googleChatApprovalCapability: ChannelApprovalCapability =
accountId && accountId !== "default"
? `channels.googlechat.accounts.${accountId}`
: "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,
hasApprovers: ({ cfg, accountId }) =>
+1 -1
View File
@@ -62,7 +62,7 @@ const googleChatConfigAdapter = createScopedChannelConfigAdapter<
"botUser",
"name",
],
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
@@ -30,7 +30,6 @@ describe("googlechatPlugin config adapter", () => {
channels: {
googlechat: {
serviceAccount: { client_email: "bot@example.com" },
actions: { reactions: true },
},
},
} as OpenClawConfig;
@@ -64,9 +63,7 @@ describe("googlechatPlugin config adapter", () => {
provider: "google_chat_service_account",
id: "value",
},
dm: {
allowFrom: ["users/123"],
},
allowFrom: ["users/123"],
defaultTo: "spaces/AAA",
},
},
@@ -93,7 +90,7 @@ describe("googlechatPlugin config adapter", () => {
},
audienceType: "app-url",
audience: "https://chat-app.example.test/googlechat",
dm: { allowFrom: ["users/123"] },
allowFrom: ["users/123"],
},
},
} as OpenClawConfig;
@@ -86,8 +86,8 @@ const collectGoogleChatSecurityWarnings = composeAccountWarningCollectors<
>(
collectGoogleChatGroupPolicyWarnings,
(account) =>
account.config.dm?.policy === "open" &&
'- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".',
account.config.dmPolicy === "open" &&
'- Google Chat DMs are open to anyone. Set channels.googlechat.dmPolicy="pairing" or "allowlist".',
);
export const googlechatGroupsAdapter = {
@@ -99,7 +99,7 @@ export const googlechatDirectoryAdapter = createChannelDirectoryAdapter({
listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedGoogleChatAccount>({
...params,
resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
resolveAllowFrom: (account) => account.config.allowFrom,
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
}),
listGroups: async (params) =>
@@ -113,9 +113,9 @@ export const googlechatDirectoryAdapter = createChannelDirectoryAdapter({
export const googlechatSecurityAdapter = {
dm: {
channelKey: "googlechat",
resolvePolicy: (account: ResolvedGoogleChatAccount) => account.config.dm?.policy,
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
allowFromPathSuffix: "dm.",
resolvePolicy: (account: ResolvedGoogleChatAccount) => account.config.dmPolicy,
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.allowFrom,
allowFromPathSuffix: "",
normalizeEntry: (raw: string) => formatGoogleChatAllowFromEntry(raw),
},
collectWarnings: collectGoogleChatSecurityWarnings,
+4 -6
View File
@@ -480,7 +480,7 @@ describe("googlechat directory", () => {
channels: {
googlechat: {
serviceAccount: { client_email: "bot@example.com" },
dm: { allowFrom: ["users/alice", "googlechat:bob"] },
allowFrom: ["users/alice", "googlechat:bob"],
groups: {
"spaces/AAA": {},
"spaces/BBB": {},
@@ -521,7 +521,7 @@ describe("googlechat directory", () => {
channels: {
googlechat: {
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;
@@ -548,10 +548,8 @@ describe("googlechatPlugin security", () => {
channels: {
googlechat: {
serviceAccount: { client_email: "bot@example.com" },
dm: {
policy: "allowlist",
allowFrom: [" googlechat:user:Bob@Example.com "],
},
dmPolicy: "allowlist",
allowFrom: [" googlechat:user:Bob@Example.com "],
},
},
} as OpenClawConfig;
+2 -2
View File
@@ -131,7 +131,7 @@ export const googlechatPlugin = createChatChannelPlugin({
},
actions: googlechatActions,
doctor: {
dmAllowFromMode: "nestedOnly",
dmAllowFromMode: "topOnly",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
@@ -192,7 +192,7 @@ export const googlechatPlugin = createChatChannelPlugin({
audience: account.config.audience,
webhookPath: account.config.webhookPath,
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);
});
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";
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", () => {
const result = normalizeCompatibilityConfig({
cfg: {
@@ -83,6 +100,30 @@ describe("googlechat doctor contract", () => {
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", () => {
const result = normalizeCompatibilityConfig({
cfg: {
@@ -17,12 +17,17 @@ type GoogleChatChannelsConfig = NonNullable<OpenClawConfig["channels"]>;
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "googlechat",
streaming: { defaultMode: "partial", deliveryOnly: true },
dm: { root: true, accounts: true },
});
function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
return asObjectRecord(value)?.streamMode !== undefined;
}
function hasRetiredReactions(value: unknown): boolean {
return Object.hasOwn(asObjectRecord(asObjectRecord(value)?.actions) ?? {}, "reactions");
}
function hasLegacyGoogleChatGroupAllowAlias(value: unknown): boolean {
const groups = asObjectRecord(asObjectRecord(value)?.groups);
if (!groups) {
@@ -84,6 +89,21 @@ function normalizeGoogleChatEntry(params: {
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);
if (groups) {
const normalized = normalizeGoogleChatGroups({
@@ -101,6 +121,18 @@ function normalizeGoogleChatEntry(params: {
}
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"],
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: {
accountId: "default",
config: {
dm: {
policy: "allowlist",
allowFrom,
},
dmPolicy: "allowlist",
allowFrom,
},
} as never,
senderId,
@@ -170,7 +168,7 @@ describe("googlechat inbound access policy", () => {
const account = {
accountId: "default",
config: {
dm: { policy: "pairing" },
dmPolicy: "pairing",
},
};
@@ -305,10 +303,8 @@ describe("googlechat inbound access policy", () => {
account: {
accountId: "default",
config: {
dm: {
policy: "allowlist",
allowFrom: ["accessGroup:operators"],
},
dmPolicy: "allowlist",
allowFrom: ["accessGroup:operators"],
},
} as never,
});
@@ -327,10 +323,8 @@ describe("googlechat inbound access policy", () => {
account: {
accountId: "default",
config: {
dm: {
policy: "allowlist",
allowFrom: ["users/alice"],
},
dmPolicy: "allowlist",
allowFrom: ["users/alice"],
groups: {
"spaces/AAA": {
enabled: true,
+2 -2
View File
@@ -264,8 +264,8 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
const groupEntry = groupConfigResolved.entry;
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
let effectiveWasMentioned: boolean | undefined;
const dmPolicy = account.config.dm?.policy ?? "pairing";
const rawConfigAllowFrom = normalizeStringEntries(account.config.dm?.allowFrom);
const dmPolicy = account.config.dmPolicy ?? "pairing";
const rawConfigAllowFrom = normalizeStringEntries(account.config.allowFrom);
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const groupActivation = (() => {
if (!isGroup) {
+13 -21
View File
@@ -38,57 +38,49 @@ const promptAllowFrom = createPromptParsedAllowFromForAccount({
entries: mergeAllowFromEntries(undefined, splitSetupEntries(raw)),
}),
getExistingAllowFrom: ({ cfg, accountId }) =>
resolveGoogleChatAccount({ cfg, accountId }).config.dm?.allowFrom ?? [],
resolveGoogleChatAccount({ cfg, accountId }).config.allowFrom ?? [],
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId,
patch: {
dm: {
...resolveGoogleChatAccount({ cfg, accountId }).config.dm,
allowFrom,
},
},
patch: { allowFrom },
}),
});
const googlechatDmPolicy: ChannelSetupDmPolicy = {
label: "Google Chat",
channel,
policyKey: "channels.googlechat.dm.policy",
allowFromKey: "channels.googlechat.dm.allowFrom",
policyKey: "channels.googlechat.dmPolicy",
allowFromKey: "channels.googlechat.allowFrom",
resolveConfigKeys: (cfg, accountId) =>
(accountId ?? resolveDefaultGoogleChatAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
? {
policyKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dm.policy`,
allowFromKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dm.allowFrom`,
policyKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.dmPolicy`,
allowFromKey: `channels.googlechat.accounts.${accountId ?? resolveDefaultGoogleChatAccountId(cfg)}.allowFrom`,
}
: {
policyKey: "channels.googlechat.dm.policy",
allowFromKey: "channels.googlechat.dm.allowFrom",
policyKey: "channels.googlechat.dmPolicy",
allowFromKey: "channels.googlechat.allowFrom",
},
getCurrent: (cfg, accountId) =>
resolveGoogleChatAccount({
cfg,
accountId: accountId ?? resolveDefaultGoogleChatAccountId(cfg),
}).config.dm?.policy ?? "pairing",
}).config.dmPolicy ?? "pairing",
setPolicy: (cfg, policy, accountId) => {
const resolvedAccountId = accountId ?? resolveDefaultGoogleChatAccountId(cfg);
const currentDm = resolveGoogleChatAccount({
const current = resolveGoogleChatAccount({
cfg,
accountId: resolvedAccountId,
}).config.dm;
}).config;
return applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId: resolvedAccountId,
patch: {
dm: {
...currentDm,
policy,
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(currentDm?.allowFrom) } : {}),
},
dmPolicy: policy,
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(current.allowFrom) } : {}),
},
});
},
+18 -34
View File
@@ -258,15 +258,11 @@ describe("googlechat setup", () => {
{
channels: {
googlechat: {
dm: {
policy: "disabled",
},
dmPolicy: "disabled",
accounts: {
alerts: {
serviceAccount: { client_email: "bot@example.com" },
dm: {
policy: "allowlist",
},
dmPolicy: "allowlist",
},
},
},
@@ -324,8 +320,8 @@ describe("googlechat setup", () => {
it("reports account-scoped config keys for named accounts", () => {
expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
allowFromKey: "channels.googlechat.accounts.alerts.dm.allowFrom",
policyKey: "channels.googlechat.accounts.alerts.dmPolicy",
allowFromKey: "channels.googlechat.accounts.alerts.allowFrom",
});
});
@@ -334,15 +330,11 @@ describe("googlechat setup", () => {
channels: {
googlechat: {
defaultAccount: "alerts",
dm: {
policy: "disabled",
},
dmPolicy: "disabled",
accounts: {
alerts: {
serviceAccount: { client_email: "bot@example.com" },
dm: {
policy: "allowlist",
},
dmPolicy: "allowlist",
},
},
},
@@ -351,13 +343,13 @@ describe("googlechat setup", () => {
expect(googlechatSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
expect(googlechatSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
allowFromKey: "channels.googlechat.accounts.alerts.dm.allowFrom",
policyKey: "channels.googlechat.accounts.alerts.dmPolicy",
allowFromKey: "channels.googlechat.accounts.alerts.allowFrom",
});
const next = googlechatSetupWizard.dmPolicy?.setPolicy(cfg, "open");
expect(next?.channels?.googlechat?.dm?.policy).toBe("disabled");
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open");
expect(next?.channels?.googlechat?.dmPolicy).toBe("disabled");
expect(next?.channels?.googlechat?.accounts?.alerts?.dmPolicy).toBe("open");
});
it("uses configured defaultAccount for omitted allowFrom prompt context", async () => {
@@ -371,15 +363,11 @@ describe("googlechat setup", () => {
channels: {
googlechat: {
defaultAccount: "alerts",
dm: {
allowFrom: ["users/root"],
},
allowFrom: ["users/root"],
accounts: {
alerts: {
serviceAccount: { client_email: "bot@example.com" },
dm: {
allowFrom: ["users/alerts"],
},
allowFrom: ["users/alerts"],
},
},
},
@@ -388,10 +376,8 @@ describe("googlechat setup", () => {
prompter,
});
expect(next?.channels?.googlechat?.dm?.allowFrom).toEqual(["users/root"]);
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual([
"users/123456789",
]);
expect(next?.channels?.googlechat?.allowFrom).toEqual(["users/root"]);
expect(next?.channels?.googlechat?.accounts?.alerts?.allowFrom).toEqual(["users/123456789"]);
});
it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
@@ -399,9 +385,7 @@ describe("googlechat setup", () => {
{
channels: {
googlechat: {
dm: {
allowFrom: ["users/123"],
},
allowFrom: ["users/123"],
accounts: {
alerts: {
serviceAccount: { client_email: "bot@example.com" },
@@ -414,9 +398,9 @@ describe("googlechat setup", () => {
"alerts",
);
expect(next?.channels?.googlechat?.dm?.policy).toBeUndefined();
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open");
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual(["users/123", "*"]);
expect(next?.channels?.googlechat?.dmPolicy).toBeUndefined();
expect(next?.channels?.googlechat?.accounts?.alerts?.dmPolicy).toBe("open");
expect(next?.channels?.googlechat?.accounts?.alerts?.allowFrom).toEqual(["users/123", "*"]);
});
it("keeps startAccount pending until abort, then unregisters", async () => {
-2
View File
@@ -17,8 +17,6 @@ const ThreadBindingsSchema = z
maxAgeHours: z.number().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})
.strict();
-4
View File
@@ -15,10 +15,6 @@ interface LineThreadBindingsConfig {
maxAgeHours?: number;
spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
}
interface LineAccountBaseConfig {
-2
View File
@@ -33,8 +33,6 @@ const matrixThreadBindingsSchema = z
maxAgeHours: z.number().nonnegative().optional(),
spawnSessions: z.boolean().optional(),
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})
.optional();
-4
View File
@@ -72,10 +72,6 @@ type MatrixThreadBindingsConfig = {
maxAgeHours?: number;
spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
};
type MatrixExecApprovalTarget = "dm" | "channel" | "both";
+20 -2
View File
@@ -113,7 +113,6 @@ const MCP_CONNECTION_FIELDS = [
"url",
"connectionTimeoutMs",
"requestTimeoutMs",
"timeout",
] as const;
export function importsMcpSensitiveValues(
@@ -173,7 +172,26 @@ export function mapMcpServer(
} else if (!transport && readString(next.url)) {
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(
value.supportsParallelToolCalls ?? value.supports_parallel_tool_calls,
);
+2 -2
View File
@@ -115,8 +115,8 @@ describe("Hermes migration config mapping", () => {
enabled: false,
command: "npx",
args: ["-y", "mcp-server-time"],
timeout: 45,
connectTimeout: 10,
connectionTimeoutMs: 10_000,
requestTimeoutMs: 45_000,
supportsParallelToolCalls: true,
},
});
@@ -436,7 +436,7 @@ export async function createMatrixQaOpenClawCliRuntime(params: {
FORCE_COLOR: "0",
NO_COLOR: "1",
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
OPENCLAW_NO_AUTO_UPDATE: "1",
OPENCLAW_STATE_DIR: stateDir,
};
return {
@@ -82,7 +82,7 @@ export async function createMatrixQaCliSelfVerificationRuntime(params: {
FORCE_COLOR: "0",
NO_COLOR: "1",
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
OPENCLAW_NO_AUTO_UPDATE: "1",
OPENCLAW_STATE_DIR: stateDir,
};
const run = async (args: string[], timeoutMs = params.context.timeoutMs, stdin?: string) =>
@@ -145,7 +145,7 @@ export async function createMatrixQaCliE2eeSetupRuntime(params: {
FORCE_COLOR: "0",
NO_COLOR: "1",
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
OPENCLAW_NO_AUTO_UPDATE: "1",
OPENCLAW_STATE_DIR: stateDir,
};
const run = async (args: string[], timeoutMs = params.context.timeoutMs, stdin?: string) =>
@@ -190,7 +190,7 @@ export async function createMatrixQaCliGatewayRuntime(params: {
...requireMatrixQaCliRuntimeEnv(params.context),
FORCE_COLOR: "0",
NO_COLOR: "1",
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
OPENCLAW_NO_AUTO_UPDATE: "1",
};
const run = async (args: string[], timeoutMs = params.context.timeoutMs) =>
await runMatrixQaOpenClawCli({
@@ -58,10 +58,6 @@ type MatrixQaThreadBindingsConfigOverrides = {
maxAgeHours?: number;
spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
};
type MatrixQaExecApprovalsConfigOverrides = {
agentFilter?: string[];
@@ -6,7 +6,6 @@ type SlackReplyToMode = "off" | "first" | "all" | "batched";
type SlackReplyToModeAccount = {
replyToMode?: SlackReplyToMode;
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
dm?: { replyToMode?: SlackReplyToMode };
};
function normalizeSlackChatType(raw?: string): "direct" | "group" | "channel" | undefined {
@@ -31,8 +30,5 @@ export function resolveSlackReplyToMode(
if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) {
return account.replyToModeByChatType[normalized] ?? "off";
}
if (normalized === "direct" && account.dm?.replyToMode !== undefined) {
return account.dm.replyToMode;
}
return account.replyToMode ?? "off";
}
+8 -9
View File
@@ -363,7 +363,7 @@ describe("resolveSlackAccount allowFrom precedence", () => {
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({
cfg: {
channels: {
@@ -374,15 +374,14 @@ describe("resolveSlackAccount allowFrom precedence", () => {
},
},
},
},
} as never,
accountId: "work",
});
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 = {
channels: {
slack: {
@@ -391,14 +390,14 @@ describe("resolveSlackAccount allowFrom precedence", () => {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
dm: { allowFrom: ["account-legacy"] },
allowFrom: ["account"],
},
},
},
},
} 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", () => {
@@ -419,7 +418,7 @@ describe("resolveSlackAccount allowFrom precedence", () => {
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 = {
channels: {
slack: {
@@ -428,7 +427,7 @@ describe("resolveSlackAccount allowFrom precedence", () => {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
dm: { policy: "allowlist" },
dmPolicy: "allowlist",
},
},
},
@@ -448,7 +447,7 @@ describe("resolveSlackAccount allowFrom precedence", () => {
Work: {
botToken: "xoxb-work",
appToken: "xapp-work",
dm: { policy: "allowlist" },
dmPolicy: "allowlist",
allowFrom: ["U123"],
},
},
+2 -12
View File
@@ -10,8 +10,6 @@ import {
import {
mapAllowFromEntries,
normalizeChannelDmPolicy,
resolveChannelDmAllowFrom,
resolveChannelDmPolicy,
type ChannelDmPolicy,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
@@ -202,10 +200,7 @@ export function resolveSlackAccountAllowFrom(params: {
);
const accountConfig = resolveSlackAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined;
const allowFrom = resolveChannelDmAllowFrom({
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
});
const allowFrom = accountConfig?.allowFrom ?? rootConfig?.allowFrom;
return allowFrom ? mapAllowFromEntries(allowFrom) : undefined;
}
@@ -232,12 +227,7 @@ export function resolveSlackAccountDmPolicy(params: {
);
const accountConfig = resolveSlackAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined;
const policy = resolveChannelDmPolicy({
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
defaultPolicy: "pairing",
});
return normalizeChannelDmPolicy(policy);
return normalizeChannelDmPolicy(accountConfig?.dmPolicy ?? rootConfig?.dmPolicy ?? "pairing");
}
export function resolveSlackAccount(params: {
+1 -1
View File
@@ -193,7 +193,7 @@ function hasPotentialSlackNamedPolicy(params: {
}
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";
}
+1 -2
View File
@@ -7,8 +7,7 @@ describe("isSlackApprovalAuthorizedSender", () => {
const cfg = {
channels: {
slack: {
allowFrom: ["slack:U123OWNER"],
dm: { allowFrom: ["<@U234DM>"] },
allowFrom: ["slack:U123OWNER", "<@U234DM>"],
defaultTo: "user:U345DEFAULT",
execApprovals: { enabled: true, approvers: ["user:U999EXEC"] },
},
+4 -2
View File
@@ -801,7 +801,8 @@ describe("slackPlugin security", () => {
cfg: {
channels: {
slack: {
dm: { policy: "allowlist", allowFrom: [" slack:U123 "] },
dmPolicy: "allowlist",
allowFrom: [" slack:U123 "],
},
},
} as OpenClawConfig,
@@ -811,7 +812,8 @@ describe("slackPlugin security", () => {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { policy: "allowlist", allowFrom: [" slack:U123 "] },
dmPolicy: "allowlist",
allowFrom: [" slack:U123 "],
},
},
} as OpenClawConfig,
+2 -5
View File
@@ -211,11 +211,8 @@ describe("slack config schema", () => {
);
});
it('accepts legacy dm.policy="open" with top-level allowFrom alias', () => {
expectSlackConfigValid({
dm: { policy: "open", allowFrom: ["U123"] },
allowFrom: ["*"],
});
it("rejects legacy nested DM access keys", () => {
expectSlackConfigIssue({ dm: { policy: "open", allowFrom: ["U123"] } }, "dm");
});
it("accepts user token config fields", () => {
+1 -1
View File
@@ -17,7 +17,7 @@ export const slackChannelConfigUiHints = {
},
...createChannelConfigUiHints({
channelLabel: "Slack",
dmPolicy: { channelKey: "slack", includeLegacyNestedPolicy: true },
dmPolicy: { channelKey: "slack" },
configWrites: true,
mentionPatterns: {
targetDescription: "Slack channel IDs",
@@ -56,7 +56,7 @@ describe("Slack directory contract", () => {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { allowFrom: ["U123", "user:U999"] },
allowFrom: ["U123", "user:U999"],
dms: { U234: {} },
channels: { C111: { users: ["U777"] } },
},
@@ -83,7 +83,7 @@ describe("Slack directory contract", () => {
slack: {
botToken: envSecret,
appToken: envSecret,
dm: { allowFrom: ["U123"] },
allowFrom: ["U123"],
channels: { C111: {} },
},
},
@@ -99,7 +99,7 @@ describe("Slack directory contract", () => {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { allowFrom: ["U100", "U200"] },
allowFrom: ["U100", "U200"],
dms: { U300: {} },
},
},
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { normalizeCompatibilityConfig } from "./doctor-contract.js";
describe("slack doctor contract", () => {
it("moves direct DM reply mode to the chat-type map", () => {
const result = normalizeCompatibilityConfig({
cfg: {
channels: {
slack: {
dm: { replyToMode: "all" },
accounts: { work: { dm: { replyToMode: "first" } } },
},
},
} as never,
});
expect(result.config.channels?.slack).toEqual({
dm: {},
replyToModeByChatType: { direct: "all" },
accounts: {
work: { dm: {}, replyToModeByChatType: { direct: "first" } },
},
});
});
});
+43
View File
@@ -34,6 +34,31 @@ function hasLegacySlackThreadMentionPolicy(value: unknown): boolean {
return Boolean(thread && Object.hasOwn(thread, "requireExplicitMention"));
}
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: {
value: Record<string, unknown>;
pathPrefix: string;
@@ -110,6 +135,19 @@ function normalizeSlackChannelAllowAliases(params: {
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
...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"],
message:
@@ -163,6 +201,7 @@ export function normalizeCompatibilityConfig({
}
let updated = rawEntry;
let changed = aliases.config !== cfg;
changed = migrateDmReplyMode(updated, "channels.slack", changes) || changed;
const normalizedThreadPolicy = normalizeSlackThreadMentionPolicy({
value: updated,
@@ -196,6 +235,10 @@ export function normalizeCompatibilityConfig({
if (!account) {
continue;
}
if (migrateDmReplyMode(account, `channels.slack.accounts.${accountId}`, changes)) {
nextAccounts[accountId] = account;
accountsChanged = true;
}
const normalizedAccountThreadPolicy = normalizeSlackThreadMentionPolicy({
value: account,
pathPrefix: `channels.slack.accounts.${accountId}`,
+1 -2
View File
@@ -91,8 +91,7 @@ describe("slack exec approvals", () => {
const cfg = buildConfig(
{ enabled: true },
{
allowFrom: ["slack:U123"],
dm: { allowFrom: ["<@U456>"] },
allowFrom: ["slack:U123", "<@U456>"],
defaultTo: "user:U789",
},
);
+3 -1
View File
@@ -293,7 +293,9 @@ export const defaultSlackTestConfig = () => ({
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
},
},
@@ -62,7 +62,9 @@ describe("monitorSlackProvider tool results", () => {
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
replyToMode,
},
},
@@ -109,7 +111,9 @@ describe("monitorSlackProvider tool results", () => {
: {}),
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
channels: { C1: { allow: true, requireMention: true } },
},
},
@@ -152,7 +156,9 @@ describe("monitorSlackProvider tool results", () => {
channels: {
slack: {
historyLimit: 5,
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
channels,
},
},
@@ -187,7 +193,9 @@ describe("monitorSlackProvider tool results", () => {
...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";
}) {
const slackChannelConfig: Record<string, unknown> = {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
channels: { C1: { allow: true, requireMention: false } },
...(params?.groupPolicy ? { groupPolicy: params.groupPolicy } : {}),
...(params?.replyToMode ? { replyToMode: params.replyToMode } : {}),
@@ -262,7 +272,9 @@ describe("monitorSlackProvider tool results", () => {
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
},
},
@@ -504,7 +516,9 @@ describe("monitorSlackProvider tool results", () => {
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
requireMention: false,
},
@@ -536,7 +550,9 @@ describe("monitorSlackProvider tool results", () => {
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
requireMention: false,
},
@@ -628,7 +644,9 @@ describe("monitorSlackProvider tool results", () => {
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
},
},
@@ -714,7 +732,9 @@ describe("monitorSlackProvider tool results", () => {
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
},
},
@@ -217,7 +217,6 @@ describe("assertEnterpriseSlackPolicyConfig", () => {
["prefixed channels key", { channels: { "channel:general": {} } }],
["allowFrom", { allowFrom: ["ursula"] }],
["prefixed allowFrom", { allowFrom: ["slack:ursula"] }],
["legacy DM allowFrom", { dm: { allowFrom: ["ursula"] } }],
["group DM channel", { dm: { groupChannels: ["general"] } }],
["reaction allowlist", { reactionNotifications: "allowlist", reactionAllowlist: ["ursula"] }],
["channel users", { channels: { C01234567: { users: ["ursula"] } } }],
@@ -118,8 +118,8 @@ export function assertEnterpriseSlackPolicyConfig(params: {
predicate: isStableSlackAllowlistUserEntry,
});
assertStableEntries({
values: config.dm?.allowFrom,
path: `channels.slack.accounts.${accountId}.dm.allowFrom`,
values: config.allowFrom,
path: `channels.slack.accounts.${accountId}.allowFrom`,
predicate: isStableSlackAllowlistUserEntry,
});
assertStableEntries({
@@ -277,8 +277,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
enabled: true,
accounts: {
soltea: {
dm: { enabled: true },
dmPolicy: "open",
dm: { enabled: true, policy: "open" },
},
},
},
@@ -293,8 +293,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
const prepared = await prepareSlackMessage({
ctx,
account: createSlackAccount({
dm: { enabled: true },
dmPolicy: "open",
dm: { enabled: true, policy: "open" },
}),
message: createSlackMessage({ channel: "D999", user: "U123", text: "hello" }),
opts: { source: "message" },
@@ -2149,18 +2149,6 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
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 () => {
const { storePath } = storeFixture.makeTmpStorePath();
const replies = vi
@@ -4471,7 +4459,7 @@ describe("prepareSlackMessage sender prefix", () => {
it("detects /new as control command when prefixed with Slack mention", async () => {
const ctx = createSenderPrefixCtx({
channels: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } },
channels: { dm: { enabled: true }, dmPolicy: "open", allowFrom: ["*"] },
allowFrom: ["U1"],
useAccessGroups: true,
slashCommand: {
@@ -344,7 +344,9 @@ describe("user identity provider transport", () => {
identity: "user",
userToken: "test-user-token",
appToken: "test-app-token",
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
},
},
@@ -485,7 +487,9 @@ describe("user identity provider transport", () => {
mode: "http",
userToken: "test-user-token",
signingSecret: "test-signing-secret",
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
dm: { enabled: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
},
},
@@ -77,18 +77,6 @@ describe("buildSlackThreadingToolContext", () => {
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", () => {
expect(
resolveReplyToModeWithConfig({
+12 -2
View File
@@ -198,7 +198,12 @@ describe("telegram doctor", () => {
} 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?.direct?.["123"]).toEqual({ requireTopic: true });
expect(telegram?.accounts?.work?.dm).toBeUndefined();
@@ -232,7 +237,12 @@ describe("telegram doctor", () => {
} 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?.accounts?.work?.dm).toBeUndefined();
expect(result.changes).toEqual([
-3
View File
@@ -71,9 +71,6 @@ describe("resolveWhatsAppAuthDir", () => {
it("merges top-level and account-specific config through shared helpers", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
messages: {
messagePrefix: "[global]",
},
channels: {
whatsapp: {
sendReadReceipts: false,
+1 -1
View File
@@ -133,7 +133,7 @@ export function resolveWhatsAppAccount(params: {
name: normalizeOptionalString(merged.name),
enabled,
sendReadReceipts: merged.sendReadReceipts ?? true,
messagePrefix: merged.messagePrefix ?? params.cfg.messages?.messagePrefix,
messagePrefix: merged.messagePrefix,
defaultTo: merged.defaultTo,
authDir,
isLegacyAuthDir: isLegacy,
@@ -139,8 +139,8 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
)?.identity,
resolveIdentityNamePrefix: (cfg: { messages?: { responsePrefix?: string } }, _agentId: string) =>
cfg.messages?.responsePrefix,
resolveMessagePrefix: (cfg: { messages?: { messagePrefix?: string } }) =>
cfg.messages?.messagePrefix,
resolveMessagePrefix: (_cfg: unknown, _agentId: string, opts?: { configured?: string }) =>
opts?.configured,
runEmbeddedAgent: vi.fn(),
}));
@@ -28,7 +28,7 @@ export function resolveMessagePrefix(
agentId: string,
opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string },
): string {
const configured = opts?.configured ?? cfg.messages?.messagePrefix;
const configured = opts?.configured;
if (configured !== undefined) {
return configured;
}
@@ -36,7 +36,7 @@ export function buildInboundLine(params: {
visibleReplyTo?: WhatsAppReplyContext | null;
}) {
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, {
configured: cfg.channels?.whatsapp?.messagePrefix,
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", () => {
it("moves flat delivery aliases at root and account level with root seeding", () => {
const result = normalizeCompatibilityConfig({
+52 -4
View File
@@ -20,8 +20,55 @@ const streamingAliasMigration = defineChannelAliasMigration({
streaming: { defaultMode: "partial", deliveryOnly: true },
});
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] =
streamingAliasMigration.legacyConfigRules;
const hasExposeErrorText = (value: unknown): boolean =>
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 >
// accounts.default > root), while the retired flat keys resolved per key
@@ -103,12 +150,13 @@ export function normalizeCompatibilityConfig({
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const ackReaction = normalizeAckReactionConfig({ cfg });
const retiredConfig = removeExposeErrorText(ackReaction.config, ackReaction.changes);
const accountsBefore = asObjectRecord(
asObjectRecord((ackReaction.config.channels as Record<string, unknown> | undefined)?.whatsapp)
asObjectRecord((retiredConfig.channels as Record<string, unknown> | undefined)?.whatsapp)
?.accounts,
);
const aliases = streamingAliasMigration.normalizeChannelConfig({
cfg: ackReaction.config,
cfg: retiredConfig,
changes: ackReaction.changes,
});
return {
@@ -288,7 +288,7 @@ describe("web inbound media saves with extension", () => {
monitorWebInbox({
cfg: {
channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined },
messages: { responsePrefix: undefined },
web: { whatsapp: { connectTimeoutMs: 12_345 } },
} as never,
verbose: false,
@@ -309,7 +309,7 @@ describe("web inbound media saves with extension", () => {
const listener = await monitorWebInbox({
cfg: {
channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined },
messages: { responsePrefix: undefined },
} as never,
verbose: false,
onMessage,
@@ -362,7 +362,7 @@ describe("web inbound media saves with extension", () => {
const listener = await monitorWebInbox({
cfg: {
channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined },
messages: { responsePrefix: undefined },
} as never,
verbose: false,
onMessage,
@@ -410,7 +410,7 @@ describe("web inbound media saves with extension", () => {
const listener = await monitorWebInbox({
cfg: {
channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined },
messages: { responsePrefix: undefined },
} as never,
verbose: false,
onMessage,
@@ -447,7 +447,7 @@ describe("web inbound media saves with extension", () => {
const listener = await monitorWebInbox({
cfg: {
channels: { whatsapp: { allowFrom: ["*"] } },
messages: { messagePrefix: undefined, responsePrefix: undefined },
messages: { responsePrefix: undefined },
} as never,
verbose: false,
onMessage,
+1 -2
View File
@@ -632,12 +632,11 @@ vi.mock("./auto-reply/monitor/message-line.runtime.js", () => ({
resolveMessagePrefix: (
cfg: {
channels?: { whatsapp?: { messagePrefix?: string; allowFrom?: string[] } };
messages?: { messagePrefix?: string };
},
_agentId: string,
params?: { configured?: string; hasAllowFrom?: boolean },
) => {
const configured = params?.configured ?? cfg.messages?.messagePrefix;
const configured = params?.configured ?? cfg.channels?.whatsapp?.messagePrefix;
if (configured !== undefined) {
return configured;
}
+1
View File
@@ -45,6 +45,7 @@ export const RELEASE_METADATA_PATHS = new Set([
"apps/android/version.json",
"apps/ios/CHANGELOG.md",
"apps/macos/Sources/OpenClaw/Resources/Info.plist",
"docs/.generated/config-baseline.counts.json",
"docs/.generated/config-baseline.sha256",
"docs/install/updating.md",
"package.json",
+22 -9
View File
@@ -20,23 +20,36 @@ const result = await writeConfigDocBaselineArtifacts({
if (checkOnly) {
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);
}
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"),
);
if (result.countBudgetError) {
console.error(
`Config baseline count budget invalid: ${result.countBudgetError}. Run \`pnpm config:docs:gen\` to recreate ${path.relative(repoRoot, result.countsPath)}.`,
);
}
for (const violation of result.countViolations) {
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);
}
console.log(
[
`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.core)} (gitignored, local only)`,
`Wrote ${path.relative(repoRoot, result.jsonPaths.channel)} (gitignored, local only)`,
+2 -1
View File
@@ -284,7 +284,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: SwarmConfig exposes the tools.swarm contract through config-types.
// +3: harness sessionFork capability params, result, and failure-code contracts.
// +2: upstream-link registry write/delete for harness-owned session forks.
8183,
// Harvest: mention-pattern schemas and helper exports -3.
8179,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
+1 -1
View File
@@ -71,7 +71,7 @@ function resolveMessagePrefix(
agentId: string,
opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string },
): string {
const configured = opts?.configured ?? cfg.messages?.messagePrefix;
const configured = opts?.configured;
if (configured !== undefined) {
return configured;
}
+6 -17
View File
@@ -32,11 +32,11 @@ describe("resolveMcpTransportConfig", () => {
});
});
it("resolves operator timeout aliases and parallel capability", () => {
it("resolves canonical timeouts and parallel capability", () => {
const resolved = resolveMcpTransportConfig("probe", {
command: "node",
timeout: 7,
connectTimeout: 2,
requestTimeoutMs: 7_000,
connectionTimeoutMs: 2_000,
supportsParallelToolCalls: true,
});
@@ -49,25 +49,14 @@ describe("resolveMcpTransportConfig", () => {
);
});
it("clamps oversized MCP request timeout aliases to the Node timer maximum", () => {
const seconds = resolveMcpTransportConfig("probe", {
command: "node",
connectTimeout: 1e306,
timeout: 1e306,
});
const milliseconds = resolveMcpTransportConfig("probe", {
it("clamps oversized canonical MCP timeouts to the Node timer maximum", () => {
const resolved = resolveMcpTransportConfig("probe", {
command: "node",
connectionTimeoutMs: 1e306,
requestTimeoutMs: 1e306,
});
expect(seconds).toEqual(
expect.objectContaining({
connectionTimeoutMs: MAX_TIMER_TIMEOUT_MS,
requestTimeoutMs: MAX_TIMER_TIMEOUT_MS,
}),
);
expect(milliseconds).toEqual(
expect(resolved).toEqual(
expect.objectContaining({
connectionTimeoutMs: MAX_TIMER_TIMEOUT_MS,
requestTimeoutMs: MAX_TIMER_TIMEOUT_MS,
-9
View File
@@ -3,7 +3,6 @@
*/
import {
clampPositiveTimerTimeoutMs,
finiteSecondsToTimerSafeMilliseconds,
resolvePositiveTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
@@ -76,10 +75,6 @@ function getConnectionTimeoutMs(rawServer: unknown): number {
if (milliseconds) {
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;
}
@@ -91,10 +86,6 @@ export function resolveMcpRequestTimeoutMs(
if (milliseconds) {
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);
}

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