fix(doctor): repair googlechat open dm wildcard auto-fix

This commit is contained in:
Sebastian
2026-02-16 21:25:16 -05:00
parent 81741c37fd
commit f7e75d2c5c
3 changed files with 172 additions and 19 deletions

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>, 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<string | number>) =>
list?.some((v) => String(v).trim() === "*") ?? false;
const ensureWildcard = (
account: Record<string, unknown>,
prefix: string,
mode: OpenPolicyAllowFromMode,
) => {
const dmEntry = account.dm;
const dm =
dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry)
? (dmEntry as Record<string, unknown>)
: undefined;
const dmPolicy =
(account.dmPolicy as string | undefined) ??
((account.dm as Record<string, unknown> | 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<string | number> | undefined;
const dm = account.dm as Record<string, unknown> | undefined;
const nestedAllowFrom = dm?.allowFrom as Array<string | number> | undefined;
const hasWildcard = (list?: Array<string | number>) =>
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<string | number>).push("*");
topAllowFrom.push("*");
changes.push(`- ${prefix}.allowFrom: added "*" (required by dmPolicy="open")`);
} else if (Array.isArray(nestedAllowFrom)) {
(dm!.allowFrom as Array<string | number>).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<string, Record<string, unknown>> | 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,
);
}
}
}