diff --git a/CHANGELOG.md b/CHANGELOG.md index a92b054d76..3749d93a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091) - CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky. - CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502) +- CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544) - Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior. - Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836) - Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc. diff --git a/src/commands/doctor-config-flow.e2e.test.ts b/src/commands/doctor-config-flow.e2e.test.ts index 9903e5ed24..949f765927 100644 --- a/src/commands/doctor-config-flow.e2e.test.ts +++ b/src/commands/doctor-config-flow.e2e.test.ts @@ -346,4 +346,103 @@ describe("doctor config flow", () => { }; expect(cfg.channels.discord.accounts.work.allowFrom).toEqual(["*"]); }); + + it("repairs googlechat dm.policy open by setting dm.allowFrom on repair", async () => { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + channels: { + googlechat: { + dm: { + policy: "open", + }, + }, + }, + }, + }); + + const cfg = result.cfg as unknown as { + channels: { + googlechat: { + dm: { + policy: string; + allowFrom: string[]; + }; + allowFrom?: string[]; + }; + }; + }; + + expect(cfg.channels.googlechat.dm.allowFrom).toEqual(["*"]); + expect(cfg.channels.googlechat.allowFrom).toBeUndefined(); + }); + + it("repairs googlechat account dm.policy open by setting dm.allowFrom on repair", async () => { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + channels: { + googlechat: { + accounts: { + work: { + dm: { + policy: "open", + }, + }, + }, + }, + }, + }, + }); + + const cfg = result.cfg as unknown as { + channels: { + googlechat: { + accounts: { + work: { + dm: { + policy: string; + allowFrom: string[]; + }; + allowFrom?: string[]; + }; + }; + }; + }; + }; + + expect(cfg.channels.googlechat.accounts.work.dm.allowFrom).toEqual(["*"]); + expect(cfg.channels.googlechat.accounts.work.allowFrom).toBeUndefined(); + }); + + it("recovers from stale googlechat top-level allowFrom by repairing dm.allowFrom", async () => { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + channels: { + googlechat: { + allowFrom: ["*"], + dm: { + policy: "open", + }, + }, + }, + }, + }); + + const cfg = result.cfg as unknown as { + channels: { + googlechat: { + dm: { + policy: string; + allowFrom: string[]; + }; + allowFrom?: string[]; + }; + }; + }; + + expect(cfg.channels.googlechat.dm.allowFrom).toEqual(["*"]); + expect(cfg.channels.googlechat.allowFrom).toBeUndefined(); + }); }); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 61d6a5d22a..44ca303c6c 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,12 +1,13 @@ +import type { ZodIssue } from "zod"; import fs from "node:fs/promises"; import path from "node:path"; -import type { ZodIssue } from "zod"; +import type { OpenClawConfig } from "../config/config.js"; +import type { DoctorOptions } from "./doctor-prompter.js"; import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, } from "../channels/telegram/allow-from.js"; import { formatCliCommand } from "../cli/command-format.js"; -import type { OpenClawConfig } from "../config/config.js"; import { OpenClawSchema, CONFIG_PATH, @@ -18,7 +19,6 @@ import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/acco import { note } from "../terminal/note.js"; import { isRecord, resolveHomeDir } from "../utils.js"; import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js"; -import type { DoctorOptions } from "./doctor-prompter.js"; import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js"; type UnrecognizedKeysIssue = ZodIssue & { @@ -571,34 +571,81 @@ function maybeRepairOpenPolicyAllowFrom(cfg: OpenClawConfig): { const next = structuredClone(cfg); const changes: string[] = []; - const ensureWildcard = (account: Record, prefix: string) => { + type OpenPolicyAllowFromMode = "topOnly" | "topOrNested" | "nestedOnly"; + + const resolveAllowFromMode = (channelName: string): OpenPolicyAllowFromMode => { + if (channelName === "googlechat") { + return "nestedOnly"; + } + if (channelName === "discord" || channelName === "slack") { + return "topOrNested"; + } + return "topOnly"; + }; + + const hasWildcard = (list?: Array) => + list?.some((v) => String(v).trim() === "*") ?? false; + + const ensureWildcard = ( + account: Record, + prefix: string, + mode: OpenPolicyAllowFromMode, + ) => { + const dmEntry = account.dm; + const dm = + dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry) + ? (dmEntry as Record) + : undefined; const dmPolicy = - (account.dmPolicy as string | undefined) ?? - ((account.dm as Record | undefined)?.policy as string | undefined); + (account.dmPolicy as string | undefined) ?? (dm?.policy as string | undefined) ?? undefined; if (dmPolicy !== "open") { return; } - // Check top-level allowFrom first, then nested dm.allowFrom const topAllowFrom = account.allowFrom as Array | undefined; - const dm = account.dm as Record | undefined; const nestedAllowFrom = dm?.allowFrom as Array | undefined; - const hasWildcard = (list?: Array) => - list?.some((v) => String(v).trim() === "*") ?? false; - - if (hasWildcard(topAllowFrom) || hasWildcard(nestedAllowFrom)) { + if (mode === "nestedOnly") { + if (hasWildcard(nestedAllowFrom)) { + return; + } + if (Array.isArray(nestedAllowFrom)) { + nestedAllowFrom.push("*"); + changes.push(`- ${prefix}.dm.allowFrom: added "*" (required by dmPolicy="open")`); + return; + } + const nextDm = dm ?? {}; + nextDm.allowFrom = ["*"]; + account.dm = nextDm; + changes.push(`- ${prefix}.dm.allowFrom: set to ["*"] (required by dmPolicy="open")`); return; } - // Prefer setting top-level allowFrom (it takes precedence) + if (mode === "topOrNested") { + if (hasWildcard(topAllowFrom) || hasWildcard(nestedAllowFrom)) { + return; + } + + if (Array.isArray(topAllowFrom)) { + topAllowFrom.push("*"); + changes.push(`- ${prefix}.allowFrom: added "*" (required by dmPolicy="open")`); + } else if (Array.isArray(nestedAllowFrom)) { + nestedAllowFrom.push("*"); + changes.push(`- ${prefix}.dm.allowFrom: added "*" (required by dmPolicy="open")`); + } else { + account.allowFrom = ["*"]; + changes.push(`- ${prefix}.allowFrom: set to ["*"] (required by dmPolicy="open")`); + } + return; + } + + if (hasWildcard(topAllowFrom)) { + return; + } if (Array.isArray(topAllowFrom)) { - (account.allowFrom as Array).push("*"); + topAllowFrom.push("*"); changes.push(`- ${prefix}.allowFrom: added "*" (required by dmPolicy="open")`); - } else if (Array.isArray(nestedAllowFrom)) { - (dm!.allowFrom as Array).push("*"); - changes.push(`- ${prefix}.dm.allowFrom: added "*" (required by dmPolicy="open")`); } else { account.allowFrom = ["*"]; changes.push(`- ${prefix}.allowFrom: set to ["*"] (required by dmPolicy="open")`); @@ -611,15 +658,21 @@ function maybeRepairOpenPolicyAllowFrom(cfg: OpenClawConfig): { continue; } + const allowFromMode = resolveAllowFromMode(channelName); + // Check the top-level channel config - ensureWildcard(channelConfig, `channels.${channelName}`); + ensureWildcard(channelConfig, `channels.${channelName}`, allowFromMode); // Check per-account configs (e.g. channels.discord.accounts.mybot) const accounts = channelConfig.accounts as Record> | undefined; if (accounts && typeof accounts === "object") { for (const [accountName, accountConfig] of Object.entries(accounts)) { if (accountConfig && typeof accountConfig === "object") { - ensureWildcard(accountConfig, `channels.${channelName}.accounts.${accountName}`); + ensureWildcard( + accountConfig, + `channels.${channelName}.accounts.${accountName}`, + allowFromMode, + ); } } }