mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-30 22:33:37 +00:00
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
// Config guard tests cover program-level config checks before command execution.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { note } from "../../../packages/terminal-core/src/note.js";
|
|
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
|
|
import { formatCliCommand } from "../command-format.js";
|
|
import { ensureConfigReady, testApi } from "./config-guard.js";
|
|
|
|
const pluginPackagingRecoveryHint = [
|
|
"This is a plugin packaging issue, not a local config problem.",
|
|
"Update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.",
|
|
].join("\n");
|
|
|
|
const loadAndMaybeMigrateDoctorConfigMock = vi.hoisted(() => vi.fn());
|
|
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
|
const setRuntimeConfigSnapshotMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../../commands/doctor-config-preflight.js", () => ({
|
|
runDoctorConfigPreflight: loadAndMaybeMigrateDoctorConfigMock,
|
|
}));
|
|
|
|
vi.mock("../../config/config.js", () => ({
|
|
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
|
setRuntimeConfigSnapshot: setRuntimeConfigSnapshotMock,
|
|
}));
|
|
|
|
type ConfigIssue = { path: string; message: string };
|
|
|
|
function makeSnapshot() {
|
|
return {
|
|
exists: false,
|
|
valid: true,
|
|
issues: [] as ConfigIssue[],
|
|
warnings: [] as ConfigIssue[],
|
|
legacyIssues: [] as ConfigIssue[],
|
|
path: "/tmp/openclaw.json",
|
|
};
|
|
}
|
|
|
|
function makeRuntime() {
|
|
return {
|
|
error: vi.fn(),
|
|
exit: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function plainErrorCalls(runtime: ReturnType<typeof makeRuntime>): string[] {
|
|
const ansiPattern = new RegExp(String.raw`\u001b\[[0-9;]*m`, "g");
|
|
return runtime.error.mock.calls.map((call) => String(call[0]).replace(ansiPattern, ""));
|
|
}
|
|
|
|
async function withCapturedStdout(run: () => Promise<void>): Promise<string> {
|
|
const writes: string[] = [];
|
|
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((
|
|
chunk: unknown,
|
|
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
|
callback?: (error?: Error | null) => void,
|
|
) => {
|
|
writes.push(String(chunk));
|
|
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
done?.();
|
|
return true;
|
|
}) as typeof process.stdout.write);
|
|
try {
|
|
await run();
|
|
return writes.join("");
|
|
} finally {
|
|
writeSpy.mockRestore();
|
|
}
|
|
}
|
|
|
|
describe("ensureConfigReady", () => {
|
|
const resetConfigGuardStateForTests = testApi.resetConfigGuardStateForTests;
|
|
const tempRoots: string[] = [];
|
|
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
|
|
|
|
async function runEnsureConfigReady(commandPath: string[], suppressDoctorStdout = false) {
|
|
const runtime = makeRuntime();
|
|
await ensureConfigReady({ runtime: runtime as never, commandPath, suppressDoctorStdout });
|
|
return runtime;
|
|
}
|
|
|
|
function setInvalidSnapshot(overrides?: Partial<ReturnType<typeof makeSnapshot>>) {
|
|
const snapshot = {
|
|
...makeSnapshot(),
|
|
exists: true,
|
|
valid: false,
|
|
issues: [{ path: "channels.quietchat", message: "invalid" }],
|
|
...overrides,
|
|
};
|
|
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
|
|
loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({
|
|
snapshot,
|
|
baseConfig: {},
|
|
});
|
|
}
|
|
|
|
function useTempOpenClawHome(): string {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-guard-"));
|
|
tempRoots.push(root);
|
|
setTestEnvValue("OPENCLAW_HOME", root);
|
|
deleteTestEnvValue("OPENCLAW_STATE_DIR");
|
|
return root;
|
|
}
|
|
|
|
function writeLegacyTaskSidecarMarker(root: string): void {
|
|
const markerPath = path.join(root, ".openclaw", "tasks", "runs.sqlite");
|
|
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
fs.writeFileSync(markerPath, "");
|
|
}
|
|
|
|
function writePendingTaskSidecarArchiveMarker(root: string): void {
|
|
const markerPath = path.join(root, ".openclaw", "tasks", "runs.sqlite");
|
|
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
fs.writeFileSync(`${markerPath}.migrated`, "");
|
|
fs.writeFileSync(`${markerPath}-wal`, "");
|
|
}
|
|
|
|
function writeStateMarker(root: string, relativePath: string): void {
|
|
const markerPath = path.join(root, ".openclaw", relativePath);
|
|
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
fs.writeFileSync(markerPath, "{}");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
|
vi.clearAllMocks();
|
|
resetConfigGuardStateForTests();
|
|
for (const root of tempRoots.splice(0)) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
useTempOpenClawHome();
|
|
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
|
|
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => ({
|
|
snapshot: makeSnapshot(),
|
|
baseConfig: {},
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
envSnapshot?.restore();
|
|
envSnapshot = undefined;
|
|
for (const root of tempRoots.splice(0)) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "skips doctor flow for status task reads without legacy state",
|
|
commandPath: ["status"],
|
|
expectedDoctorCalls: 0,
|
|
},
|
|
{
|
|
name: "skips doctor flow for update status",
|
|
commandPath: ["update", "status"],
|
|
expectedDoctorCalls: 0,
|
|
},
|
|
{
|
|
name: "skips doctor flow for agent without legacy state",
|
|
commandPath: ["agent"],
|
|
expectedDoctorCalls: 0,
|
|
},
|
|
{
|
|
name: "runs doctor flow for commands that may mutate state without legacy state",
|
|
commandPath: ["message"],
|
|
expectedDoctorCalls: 1,
|
|
},
|
|
])("$name", async ({ commandPath, expectedDoctorCalls }) => {
|
|
await runEnsureConfigReady(commandPath);
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(expectedDoctorCalls);
|
|
if (expectedDoctorCalls > 0) {
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
|
|
migrateState: true,
|
|
migrateLegacyConfig: false,
|
|
invalidConfigNote: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("runs doctor flow when lightweight startup detection finds legacy state", async () => {
|
|
const root = useTempOpenClawHome();
|
|
writeLegacyTaskSidecarMarker(root);
|
|
|
|
await runEnsureConfigReady(["status"]);
|
|
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
|
|
migrateState: true,
|
|
migrateLegacyConfig: false,
|
|
invalidConfigNote: false,
|
|
});
|
|
});
|
|
|
|
it("runs doctor flow when lightweight startup detection finds a pending SQLite archive", async () => {
|
|
const root = useTempOpenClawHome();
|
|
writePendingTaskSidecarArchiveMarker(root);
|
|
|
|
await runEnsureConfigReady(["status"]);
|
|
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
|
|
migrateState: true,
|
|
migrateLegacyConfig: false,
|
|
invalidConfigNote: false,
|
|
});
|
|
});
|
|
|
|
it("runs doctor flow for legacy sessions without task sidecars", async () => {
|
|
const root = useTempOpenClawHome();
|
|
fs.mkdirSync(path.join(root, ".openclaw", "sessions"), { recursive: true });
|
|
|
|
await runEnsureConfigReady(["status"]);
|
|
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("runs doctor flow before agent commands when the legacy plugin install index exists", async () => {
|
|
const root = useTempOpenClawHome();
|
|
writeStateMarker(root, "plugins/installs.json");
|
|
|
|
await runEnsureConfigReady(["agent"]);
|
|
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
|
|
migrateState: true,
|
|
migrateLegacyConfig: false,
|
|
invalidConfigNote: false,
|
|
});
|
|
});
|
|
|
|
it("runs doctor flow before agent commands when default exec approvals must move to a custom state dir", async () => {
|
|
const root = useTempOpenClawHome();
|
|
const stateDir = path.join(root, "custom-state");
|
|
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
|
writeStateMarker(root, "exec-approvals.json");
|
|
|
|
await runEnsureConfigReady(["agent"]);
|
|
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
|
|
migrateState: true,
|
|
migrateLegacyConfig: false,
|
|
invalidConfigNote: false,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["Discord model picker preferences", "discord/model-picker-preferences.json"],
|
|
["Discord thread bindings", "discord/thread-bindings.json"],
|
|
["Feishu dedupe sidecar", "feishu/dedup/default.json"],
|
|
["Telegram bot info cache", "telegram/bot-info-default.json"],
|
|
["Telegram update offset", "telegram/update-offset-default.json"],
|
|
["Telegram sticker cache", "telegram/sticker-cache.json"],
|
|
["Telegram thread bindings", "telegram/thread-bindings-default.json"],
|
|
["Telegram pairing allowFrom", "credentials/telegram-allowFrom.json"],
|
|
["iMessage reply short-id cache", "imessage/reply-cache.jsonl"],
|
|
["iMessage sent echo cache", "imessage/sent-echoes.jsonl"],
|
|
["iMessage catchup cursor", "imessage/catchup/default__37a8eec1ce19.json"],
|
|
["WhatsApp root auth", "credentials/creds.json"],
|
|
])("runs doctor flow for bundled channel legacy state: %s", async (_label, relativePath) => {
|
|
const root = useTempOpenClawHome();
|
|
writeStateMarker(root, relativePath);
|
|
|
|
await runEnsureConfigReady(["status"]);
|
|
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("uses shared tilde expansion for OPENCLAW_HOME in the startup detector", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-guard-home-"));
|
|
tempRoots.push(root);
|
|
setTestEnvValue("HOME", root);
|
|
setTestEnvValue("OPENCLAW_HOME", "~/svc");
|
|
deleteTestEnvValue("OPENCLAW_STATE_DIR");
|
|
writeLegacyTaskSidecarMarker(path.join(root, "svc"));
|
|
|
|
await runEnsureConfigReady(["status"]);
|
|
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("runs doctor flow for read-only commands with configured custom session stores", async () => {
|
|
const root = useTempOpenClawHome();
|
|
const customStore = path.join(root, "sessions", "sessions.json");
|
|
const snapshot = {
|
|
...makeSnapshot(),
|
|
config: { session: { store: customStore } },
|
|
runtimeConfig: { session: { store: customStore } },
|
|
};
|
|
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
|
|
loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({
|
|
snapshot,
|
|
baseConfig: {},
|
|
});
|
|
|
|
await runEnsureConfigReady(["status"]);
|
|
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("pins a valid preflight snapshot for command code reuse", async () => {
|
|
const snapshot = {
|
|
...makeSnapshot(),
|
|
config: { runtime: true },
|
|
runtimeConfig: { runtime: true, materialized: true },
|
|
sourceConfig: { source: true },
|
|
};
|
|
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
|
|
|
|
await runEnsureConfigReady(["health"]);
|
|
|
|
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
|
|
snapshot.runtimeConfig,
|
|
snapshot.sourceConfig,
|
|
);
|
|
});
|
|
|
|
it("retries the cached config snapshot after a read rejection", async () => {
|
|
const originalVitest = process.env.VITEST;
|
|
process.env.VITEST = "false";
|
|
const transientError = new Error("temporary config read failure");
|
|
const recoveredSnapshot = makeSnapshot();
|
|
readConfigFileSnapshotMock
|
|
.mockRejectedValueOnce(transientError)
|
|
.mockResolvedValueOnce(recoveredSnapshot);
|
|
|
|
try {
|
|
await expect(runEnsureConfigReady(["health"])).rejects.toThrow(transientError);
|
|
await expect(runEnsureConfigReady(["health"])).resolves.toBeDefined();
|
|
await expect(runEnsureConfigReady(["health"])).resolves.toBeDefined();
|
|
} finally {
|
|
if (originalVitest === undefined) {
|
|
delete process.env.VITEST;
|
|
} else {
|
|
process.env.VITEST = originalVitest;
|
|
}
|
|
}
|
|
|
|
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(2);
|
|
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(undefined, undefined);
|
|
});
|
|
|
|
it("exits for invalid config on non-allowlisted commands", async () => {
|
|
setInvalidSnapshot();
|
|
const runtime = await runEnsureConfigReady(["message"]);
|
|
|
|
expect(plainErrorCalls(runtime)).toEqual([
|
|
"OpenClaw config is invalid",
|
|
"File: /tmp/openclaw.json",
|
|
"Problem:",
|
|
" - channels.quietchat: invalid",
|
|
"",
|
|
`Fix: ${formatCliCommand("openclaw doctor --fix")}`,
|
|
`Inspect: ${formatCliCommand("openclaw config validate")}`,
|
|
"Status, health, logs, tasks list/audit, and doctor commands still run with invalid config.",
|
|
]);
|
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("replaces doctor fix advice for plugin packaging-only invalid config", async () => {
|
|
setInvalidSnapshot({
|
|
issues: [
|
|
{
|
|
path: "plugins.slots.memory",
|
|
message: "plugin not found: source-only-pack",
|
|
},
|
|
],
|
|
warnings: [
|
|
{
|
|
path: "plugins",
|
|
message:
|
|
"plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.",
|
|
},
|
|
],
|
|
});
|
|
const runtime = await runEnsureConfigReady(["message"]);
|
|
const calls = plainErrorCalls(runtime);
|
|
|
|
expect(calls).toContain(`Fix: ${pluginPackagingRecoveryHint}`);
|
|
expect(calls).not.toContain(`Fix: ${formatCliCommand("openclaw doctor --fix")}`);
|
|
expect(runtime.exit).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it("does not exit for invalid config on allowlisted commands", async () => {
|
|
setInvalidSnapshot({
|
|
issues: [{ path: "agents.defaults", message: 'Unrecognized key: "agentRuntime"' }],
|
|
});
|
|
const statusRuntime = await runEnsureConfigReady(["status"]);
|
|
expect(statusRuntime.exit).not.toHaveBeenCalled();
|
|
|
|
const bareGatewayRuntime = await runEnsureConfigReady(["gateway"]);
|
|
expect(bareGatewayRuntime.exit).not.toHaveBeenCalled();
|
|
|
|
const gatewayRunRuntime = await runEnsureConfigReady(["gateway", "run"]);
|
|
expect(gatewayRunRuntime.exit).not.toHaveBeenCalled();
|
|
|
|
const gatewayRuntime = await runEnsureConfigReady(["gateway", "health"]);
|
|
expect(gatewayRuntime.exit).not.toHaveBeenCalled();
|
|
|
|
const tasksListRuntime = await runEnsureConfigReady(["tasks", "list"]);
|
|
expect(tasksListRuntime.exit).not.toHaveBeenCalled();
|
|
|
|
const tasksParentRuntime = await runEnsureConfigReady(["tasks"]);
|
|
expect(tasksParentRuntime.exit).not.toHaveBeenCalled();
|
|
|
|
const tasksAuditRuntime = await runEnsureConfigReady(["tasks", "audit"]);
|
|
expect(tasksAuditRuntime.exit).not.toHaveBeenCalled();
|
|
|
|
const tasksRunRuntime = await runEnsureConfigReady(["tasks", "run"]);
|
|
expect(tasksRunRuntime.exit).toHaveBeenCalledWith(1);
|
|
|
|
const doctorRuntime = await runEnsureConfigReady(["doctor", "fix"]);
|
|
expect(doctorRuntime.exit).not.toHaveBeenCalled();
|
|
expect(doctorRuntime.error).toHaveBeenCalledWith(expect.stringContaining("agentRuntime"));
|
|
});
|
|
|
|
it("allows an explicit invalid-config override", async () => {
|
|
setInvalidSnapshot();
|
|
const runtime = makeRuntime();
|
|
await ensureConfigReady({
|
|
runtime: runtime as never,
|
|
commandPath: ["plugins", "install"],
|
|
allowInvalid: true,
|
|
});
|
|
expect(runtime.exit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("runs doctor migration flow only once per module instance", async () => {
|
|
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
|
|
const runtimeA = makeRuntime();
|
|
const runtimeB = makeRuntime();
|
|
|
|
await ensureConfigReady({ runtime: runtimeA as never, commandPath: ["message"] });
|
|
await ensureConfigReady({ runtime: runtimeB as never, commandPath: ["message"] });
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("still runs doctor flow when stdout suppression is enabled", async () => {
|
|
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
|
|
await runEnsureConfigReady(["message"], true);
|
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("prevents preflight note noise when suppression is enabled", async () => {
|
|
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
|
|
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
|
note("Doctor warnings", "Config warnings");
|
|
return {
|
|
snapshot: makeSnapshot(),
|
|
baseConfig: {},
|
|
};
|
|
});
|
|
const output = await withCapturedStdout(async () => {
|
|
await runEnsureConfigReady(["message"], true);
|
|
});
|
|
expect(output).not.toContain("Doctor warnings");
|
|
});
|
|
|
|
it("allows preflight note noise when suppression is not enabled", async () => {
|
|
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
|
|
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
|
note("Doctor warnings", "Config warnings");
|
|
return {
|
|
snapshot: makeSnapshot(),
|
|
baseConfig: {},
|
|
};
|
|
});
|
|
const output = await withCapturedStdout(async () => {
|
|
await runEnsureConfigReady(["message"], false);
|
|
});
|
|
expect(output).toContain("Doctor warnings");
|
|
});
|
|
|
|
it("does not suppress unrelated concurrent stdout writes while suppressing preflight notes", async () => {
|
|
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
|
|
let releasePreflight: (() => void) | undefined;
|
|
let preflightStarted: (() => void) | undefined;
|
|
const preflightStartedPromise = new Promise<void>((resolve) => {
|
|
preflightStarted = resolve;
|
|
});
|
|
const releasePreflightPromise = new Promise<void>((resolve) => {
|
|
releasePreflight = resolve;
|
|
});
|
|
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
|
note("Doctor warnings", "Config warnings");
|
|
preflightStarted?.();
|
|
await releasePreflightPromise;
|
|
return {
|
|
snapshot: makeSnapshot(),
|
|
baseConfig: {},
|
|
};
|
|
});
|
|
|
|
let callbackCalled = false;
|
|
const output = await withCapturedStdout(async () => {
|
|
const ready = runEnsureConfigReady(["message"], true);
|
|
await preflightStartedPromise;
|
|
process.stdout.write("Concurrent output\n", () => {
|
|
callbackCalled = true;
|
|
});
|
|
releasePreflight?.();
|
|
await ready;
|
|
});
|
|
|
|
expect(output).toContain("Concurrent output");
|
|
expect(output).not.toContain("Doctor warnings");
|
|
expect(callbackCalled).toBe(true);
|
|
});
|
|
});
|