mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 09:47:53 +00:00
refactor(config): config-surface reduction tranche 1 — retire dead keys, dedupe channel schemas, add growth ratchet (#111142)
* refactor(config): retire dead and aliased config keys via doctor migrations * refactor(config): dedupe bundled channel config schemas into shared builders * feat(config): add config-surface count ratchet to doc-baseline check * test(config): drop stale fixtures for retired config keys * fix(doctor): migrate only positive finite MCP timeout aliases * fix(migrate-hermes): emit canonical MCP timeouts only * fix(config): satisfy lint and contract gates
This commit is contained in:
+2
-1
@@ -266,8 +266,9 @@ ui/src/ui/__screenshots__
|
||||
ui/src/ui/views/__screenshots__
|
||||
ui/.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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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>"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+3
-3
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user