refactor(config): compile toolsBySender policy and migrate legacy keys

This commit is contained in:
Peter Steinberger
2026-02-22 21:21:15 +01:00
parent c73837d269
commit 3f64d4ad7b
4 changed files with 292 additions and 57 deletions

View File

@@ -378,6 +378,49 @@ describe("doctor config flow", () => {
expect(cfg.channels.discord.accounts.work.allowFrom).toEqual(["*"]);
});
it("migrates legacy toolsBySender keys to typed id entries on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
whatsapp: {
groups: {
"123@g.us": {
toolsBySender: {
owner: { allow: ["exec"] },
alice: { deny: ["exec"] },
"id:owner": { deny: ["exec"] },
"username:@ops-bot": { allow: ["fs.read"] },
"*": { deny: ["exec"] },
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as unknown as {
channels: {
whatsapp: {
groups: {
"123@g.us": {
toolsBySender: Record<string, { allow?: string[]; deny?: string[] }>;
};
};
};
};
};
const toolsBySender = cfg.channels.whatsapp.groups["123@g.us"].toolsBySender;
expect(toolsBySender.owner).toBeUndefined();
expect(toolsBySender.alice).toBeUndefined();
expect(toolsBySender["id:owner"]).toEqual({ deny: ["exec"] });
expect(toolsBySender["id:alice"]).toEqual({ deny: ["exec"] });
expect(toolsBySender["username:@ops-bot"]).toEqual({ allow: ["fs.read"] });
expect(toolsBySender["*"]).toEqual({ deny: ["exec"] });
});
it("repairs googlechat dm.policy open by setting dm.allowFrom on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

View File

@@ -15,6 +15,7 @@ import {
readConfigFileSnapshot,
} from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
import {
listInterpreterLikeSafeBins,
resolveMergedSafeBinProfileFixtures,
@@ -836,6 +837,120 @@ function maybeRepairExecSafeBinProfiles(cfg: OpenClawConfig): {
return { config: next, changes, warnings };
}
type LegacyToolsBySenderKeyHit = {
toolsBySenderPath: Array<string | number>;
pathLabel: string;
key: string;
targetKey: string;
};
function collectLegacyToolsBySenderKeyHits(
value: unknown,
pathParts: Array<string | number>,
hits: LegacyToolsBySenderKeyHit[],
) {
if (Array.isArray(value)) {
for (const [index, entry] of value.entries()) {
collectLegacyToolsBySenderKeyHits(entry, [...pathParts, index], hits);
}
return;
}
const record = asObjectRecord(value);
if (!record) {
return;
}
const toolsBySender = asObjectRecord(record.toolsBySender);
if (toolsBySender) {
const path = [...pathParts, "toolsBySender"];
const pathLabel = formatPath(path);
for (const rawKey of Object.keys(toolsBySender)) {
const trimmed = rawKey.trim();
if (!trimmed || trimmed === "*" || parseToolsBySenderTypedKey(trimmed)) {
continue;
}
hits.push({
toolsBySenderPath: path,
pathLabel,
key: rawKey,
targetKey: `id:${trimmed}`,
});
}
}
for (const [key, nested] of Object.entries(record)) {
if (key === "toolsBySender") {
continue;
}
collectLegacyToolsBySenderKeyHits(nested, [...pathParts, key], hits);
}
}
function scanLegacyToolsBySenderKeys(cfg: OpenClawConfig): LegacyToolsBySenderKeyHit[] {
const hits: LegacyToolsBySenderKeyHit[] = [];
collectLegacyToolsBySenderKeyHits(cfg, [], hits);
return hits;
}
function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} {
const next = structuredClone(cfg);
const hits = scanLegacyToolsBySenderKeys(next);
if (hits.length === 0) {
return { config: cfg, changes: [] };
}
const summary = new Map<string, { migrated: number; dropped: number; examples: string[] }>();
let changed = false;
for (const hit of hits) {
const toolsBySender = asObjectRecord(resolvePathTarget(next, hit.toolsBySenderPath));
if (!toolsBySender || !(hit.key in toolsBySender)) {
continue;
}
const row = summary.get(hit.pathLabel) ?? { migrated: 0, dropped: 0, examples: [] };
if (toolsBySender[hit.targetKey] === undefined) {
toolsBySender[hit.targetKey] = toolsBySender[hit.key];
row.migrated++;
if (row.examples.length < 3) {
row.examples.push(`${hit.key} -> ${hit.targetKey}`);
}
} else {
row.dropped++;
if (row.examples.length < 3) {
row.examples.push(`${hit.key} (kept existing ${hit.targetKey})`);
}
}
delete toolsBySender[hit.key];
summary.set(hit.pathLabel, row);
changed = true;
}
if (!changed) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
for (const [pathLabel, row] of summary) {
if (row.migrated > 0) {
const suffix = row.examples.length > 0 ? ` (${row.examples.join(", ")})` : "";
changes.push(
`- ${pathLabel}: migrated ${row.migrated} legacy key${row.migrated === 1 ? "" : "s"} to typed id: entries${suffix}.`,
);
}
if (row.dropped > 0) {
changes.push(
`- ${pathLabel}: removed ${row.dropped} legacy key${row.dropped === 1 ? "" : "s"} where typed id: entries already existed.`,
);
}
}
return { config: next, changes };
}
async function maybeMigrateLegacyConfig(): Promise<string[]> {
const changes: string[] = [];
const home = resolveHomeDir();
@@ -991,6 +1106,15 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
pendingChanges = true;
cfg = allowFromRepair.config;
}
const toolsBySenderRepair = maybeRepairLegacyToolsBySenderKeys(candidate);
if (toolsBySenderRepair.changes.length > 0) {
note(toolsBySenderRepair.changes.join("\n"), "Doctor changes");
candidate = toolsBySenderRepair.config;
pendingChanges = true;
cfg = toolsBySenderRepair.config;
}
const safeBinProfileRepair = maybeRepairExecSafeBinProfiles(candidate);
if (safeBinProfileRepair.changes.length > 0) {
note(safeBinProfileRepair.changes.join("\n"), "Doctor changes");
@@ -1035,6 +1159,20 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
);
}
const toolsBySenderHits = scanLegacyToolsBySenderKeys(candidate);
if (toolsBySenderHits.length > 0) {
const sample = toolsBySenderHits[0];
const sampleLabel = sample ? `${sample.pathLabel}.${sample.key}` : "toolsBySender";
note(
[
`- Found ${toolsBySenderHits.length} legacy untyped toolsBySender key${toolsBySenderHits.length === 1 ? "" : "s"} (for example ${sampleLabel}).`,
"- Untyped sender keys are deprecated; use explicit prefixes (id:, e164:, username:, name:).",
`- Run "${formatCliCommand("openclaw doctor --fix")}" to migrate legacy keys to typed id: entries.`,
].join("\n"),
"Doctor warnings",
);
}
const safeBinCoverage = scanExecSafeBinCoverage(candidate);
if (safeBinCoverage.length > 0) {
const interpreterHits = safeBinCoverage.filter((hit) => hit.isInterpreter);