mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 07:21:28 +00:00
* feat: correlate native search outcomes in audit history Metadata-only audit ledger for agent runs and tool actions in the shared state DB: stable event identity, closed action/status/error vocabularies, one-way-hashed tool-call ids, never-inferred terminal outcomes for native web-search (explicit completed/failed only; otherwise unknown), bounded retention, audit.list gateway RPC and openclaw audit CLI. Squashed from the 82-commit audit stack for replay onto current main. * feat(audit): add audit.enabled config gate (default on) The metadata-only audit ledger records by default: an audit trail enabled only after an incident cannot explain the incident, and the rows are strictly less sensitive than the transcripts every install already stores. audit.enabled=false stops new writes at the gateway subscription seam; audit.list and openclaw audit keep serving existing records until they expire. Documented in the configuration reference, protocol page, and CLI reference. * fix: repair full-matrix CI findings after rebase - break the dynamic-tools/dynamic-tool-execution import cycle by extracting resolveCodexToolAbortTerminalReason into a leaf module - restore main's session-worktree protocol exports lost in the index.ts auto-merge - register the audit event writer worker as a knip entry point - docs table formatting; subagent wait-cancellation test scoped to its audit intent (outcome + timing) and advanced past main's new lifecycle-timeout retry grace
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
// CLI config readiness guard, legacy-state migration routing, and invalid-config allowances.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { withSuppressedNotes } from "../../../packages/terminal-core/src/note.js";
|
|
import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
|
|
import { resolveLegacyStateDirs, resolveOAuthDir, resolveStateDir } from "../../config/paths.js";
|
|
import type { ConfigFileSnapshot } from "../../config/types.js";
|
|
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
|
import type { RuntimeEnv } from "../../runtime.js";
|
|
import { shouldMigrateStateFromPath } from "../argv.js";
|
|
|
|
const ALLOWED_INVALID_COMMANDS = new Set([
|
|
"audit",
|
|
"doctor",
|
|
"logs",
|
|
"health",
|
|
"help",
|
|
"status",
|
|
]);
|
|
const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([
|
|
"run",
|
|
"status",
|
|
"probe",
|
|
"health",
|
|
"discover",
|
|
"call",
|
|
"install",
|
|
"uninstall",
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
]);
|
|
const ALLOWED_INVALID_TASK_SUBCOMMANDS = new Set(["list", "audit"]);
|
|
let didRunDoctorConfigFlow = false;
|
|
let configSnapshotPromise: Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> | null =
|
|
null;
|
|
|
|
function resetConfigGuardStateForTests() {
|
|
didRunDoctorConfigFlow = false;
|
|
configSnapshotPromise = null;
|
|
}
|
|
|
|
function fileOrDirExists(pathname: string): boolean {
|
|
try {
|
|
return fs.existsSync(pathname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function dirHasFile(dir: string, predicate: (name: string) => boolean): boolean {
|
|
try {
|
|
return fs
|
|
.readdirSync(dir, { withFileTypes: true })
|
|
.some((entry) => entry.isFile() && predicate(entry.name));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isLegacyWhatsAppAuthFile(name: string): boolean {
|
|
if (name === "creds.json" || name === "creds.json.bak") {
|
|
return true;
|
|
}
|
|
return name.endsWith(".json") && /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
|
|
}
|
|
|
|
function isLegacyTelegramStateFile(name: string): boolean {
|
|
return (
|
|
(name.startsWith("bot-info-") && name.endsWith(".json")) ||
|
|
(name.startsWith("update-offset-") && name.endsWith(".json")) ||
|
|
name === "sticker-cache.json" ||
|
|
(name.startsWith("thread-bindings-") && name.endsWith(".json"))
|
|
);
|
|
}
|
|
|
|
function hasLegacyIMessageStateFiles(stateDir: string): boolean {
|
|
return (
|
|
fileOrDirExists(path.join(stateDir, "imessage", "reply-cache.jsonl")) ||
|
|
fileOrDirExists(path.join(stateDir, "imessage", "sent-echoes.jsonl")) ||
|
|
dirHasFile(path.join(stateDir, "imessage", "catchup"), (name) => name.endsWith(".json"))
|
|
);
|
|
}
|
|
|
|
function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir: string): boolean {
|
|
if (
|
|
fileOrDirExists(path.join(stateDir, "discord", "model-picker-preferences.json")) ||
|
|
fileOrDirExists(path.join(stateDir, "discord", "thread-bindings.json"))
|
|
) {
|
|
return true;
|
|
}
|
|
if (dirHasFile(path.join(stateDir, "feishu", "dedup"), (name) => name.endsWith(".json"))) {
|
|
return true;
|
|
}
|
|
if (hasLegacyIMessageStateFiles(stateDir)) {
|
|
return true;
|
|
}
|
|
if (
|
|
fileOrDirExists(path.join(oauthDir, "telegram-allowFrom.json")) ||
|
|
dirHasFile(path.join(stateDir, "telegram"), isLegacyTelegramStateFile)
|
|
) {
|
|
return true;
|
|
}
|
|
return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile);
|
|
}
|
|
|
|
function hasLegacyExecApprovalsMigrationInput(stateDir: string): boolean {
|
|
if (!process.env.OPENCLAW_STATE_DIR?.trim()) {
|
|
return false;
|
|
}
|
|
const homeDir = resolveRequiredHomeDir(process.env, os.homedir);
|
|
const sourcePath = path.join(homeDir, ".openclaw", "exec-approvals.json");
|
|
const targetPath = path.join(stateDir, "exec-approvals.json");
|
|
return (
|
|
path.resolve(sourcePath) !== path.resolve(targetPath) &&
|
|
fileOrDirExists(sourcePath) &&
|
|
!fileOrDirExists(targetPath)
|
|
);
|
|
}
|
|
|
|
function hasPendingSqliteSidecarArchive(sourcePath: string): boolean {
|
|
return (
|
|
fileOrDirExists(`${sourcePath}.migrated`) &&
|
|
["-shm", "-wal", "-journal"].some((suffix) => fileOrDirExists(`${sourcePath}${suffix}`))
|
|
);
|
|
}
|
|
|
|
function hasLegacyStateMigrationInputs(): boolean {
|
|
// Only run migration prompts when old state actually exists in known legacy locations.
|
|
const stateDir = resolveStateDir(process.env, os.homedir);
|
|
const oauthDir = resolveOAuthDir(process.env, stateDir);
|
|
if (
|
|
!process.env.OPENCLAW_STATE_DIR?.trim() &&
|
|
resolveLegacyStateDirs(() => resolveRequiredHomeDir(process.env, os.homedir)).some(
|
|
fileOrDirExists,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
const sqliteSidecarPaths = [
|
|
path.join(stateDir, "flows", "registry.sqlite"),
|
|
path.join(stateDir, "plugin-state", "state.sqlite"),
|
|
path.join(stateDir, "tasks", "runs.sqlite"),
|
|
];
|
|
return (
|
|
[
|
|
path.join(stateDir, "agent"),
|
|
path.join(stateDir, "agents"),
|
|
path.join(stateDir, "plugins", "installs.json"),
|
|
path.join(stateDir, "sessions"),
|
|
].some(fileOrDirExists) ||
|
|
sqliteSidecarPaths.some(
|
|
(sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath),
|
|
) ||
|
|
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) ||
|
|
hasLegacyExecApprovalsMigrationInput(stateDir)
|
|
);
|
|
}
|
|
|
|
function shouldRunStateMigrationOnlyWithLegacyInputs(commandPath: string[]): boolean {
|
|
const commandName = commandPath[0];
|
|
const subcommandName = commandPath[1];
|
|
return (
|
|
commandName === "agent" ||
|
|
commandName === "status" ||
|
|
(commandName === "tasks" &&
|
|
(subcommandName === undefined || ALLOWED_INVALID_TASK_SUBCOMMANDS.has(subcommandName)))
|
|
);
|
|
}
|
|
|
|
function snapshotHasConfiguredSessionStore(
|
|
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
|
|
): boolean {
|
|
const cfg = snapshot.runtimeConfig ?? snapshot.config;
|
|
const store = cfg?.session?.store;
|
|
return typeof store === "string" && store.trim().length > 0;
|
|
}
|
|
|
|
async function getConfigSnapshot() {
|
|
// Tests often mutate config fixtures; caching can make those flaky.
|
|
if (process.env.VITEST === "true") {
|
|
return readConfigFileSnapshot();
|
|
}
|
|
if (!configSnapshotPromise) {
|
|
const pendingSnapshot = readConfigFileSnapshot();
|
|
configSnapshotPromise = pendingSnapshot;
|
|
pendingSnapshot.catch(() => {
|
|
if (configSnapshotPromise === pendingSnapshot) {
|
|
configSnapshotPromise = null;
|
|
}
|
|
});
|
|
}
|
|
return configSnapshotPromise;
|
|
}
|
|
|
|
export async function ensureConfigReady(params: {
|
|
runtime: RuntimeEnv;
|
|
commandPath?: string[];
|
|
suppressDoctorStdout?: boolean;
|
|
allowInvalid?: boolean;
|
|
beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise<boolean>;
|
|
}): Promise<void> {
|
|
const commandPath = params.commandPath ?? [];
|
|
let preflightSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>> | null = null;
|
|
const shouldConsiderStateMigration = shouldMigrateStateFromPath(commandPath);
|
|
const requiresLegacyStateInput = shouldRunStateMigrationOnlyWithLegacyInputs(commandPath);
|
|
const runStateMigrationPreflight = async () => {
|
|
didRunDoctorConfigFlow = true;
|
|
const runDoctorConfigPreflight = async () =>
|
|
(await import("../../commands/doctor-config-preflight.js")).runDoctorConfigPreflight({
|
|
migrateState: true,
|
|
migrateLegacyConfig: false,
|
|
invalidConfigNote: false,
|
|
...(params.beforeStateMigrations
|
|
? { beforeStateMigrations: params.beforeStateMigrations }
|
|
: {}),
|
|
});
|
|
return !params.suppressDoctorStdout
|
|
? (await runDoctorConfigPreflight()).snapshot
|
|
: (await withSuppressedNotes(runDoctorConfigPreflight)).snapshot;
|
|
};
|
|
if (
|
|
!didRunDoctorConfigFlow &&
|
|
shouldConsiderStateMigration &&
|
|
(!requiresLegacyStateInput || hasLegacyStateMigrationInputs())
|
|
) {
|
|
preflightSnapshot = await runStateMigrationPreflight();
|
|
}
|
|
|
|
let snapshot = preflightSnapshot ?? (await getConfigSnapshot());
|
|
if (
|
|
!preflightSnapshot &&
|
|
!didRunDoctorConfigFlow &&
|
|
shouldConsiderStateMigration &&
|
|
requiresLegacyStateInput &&
|
|
snapshot.valid &&
|
|
snapshotHasConfiguredSessionStore(snapshot)
|
|
) {
|
|
preflightSnapshot = await runStateMigrationPreflight();
|
|
snapshot = preflightSnapshot;
|
|
}
|
|
const commandName = commandPath[0];
|
|
const subcommandName = commandPath[1];
|
|
const isBareGatewayForegroundRun =
|
|
commandName === "gateway" && (subcommandName === undefined || subcommandName.trim() === "");
|
|
const isReadOnlyTaskStateCommand =
|
|
commandName === "tasks" &&
|
|
(subcommandName === undefined || ALLOWED_INVALID_TASK_SUBCOMMANDS.has(subcommandName));
|
|
const allowInvalid = commandName
|
|
? params.allowInvalid === true ||
|
|
ALLOWED_INVALID_COMMANDS.has(commandName) ||
|
|
isReadOnlyTaskStateCommand ||
|
|
isBareGatewayForegroundRun ||
|
|
(commandName === "gateway" &&
|
|
subcommandName &&
|
|
ALLOWED_INVALID_GATEWAY_SUBCOMMANDS.has(subcommandName))
|
|
: false;
|
|
const { formatConfigIssueLines } = await import("../../config/issue-format.js");
|
|
const issues =
|
|
snapshot.exists && !snapshot.valid
|
|
? formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true })
|
|
: [];
|
|
const legacyIssues =
|
|
snapshot.legacyIssues.length > 0 ? formatConfigIssueLines(snapshot.legacyIssues, "-") : [];
|
|
|
|
const invalid = snapshot.exists && !snapshot.valid;
|
|
if (!invalid) {
|
|
setRuntimeConfigSnapshot(snapshot.runtimeConfig ?? snapshot.config, snapshot.sourceConfig);
|
|
}
|
|
if (!invalid) {
|
|
return;
|
|
}
|
|
|
|
const [
|
|
{ colorize, isRich, theme },
|
|
{ shortenHomePath },
|
|
{ formatCliCommand },
|
|
{ isPluginPackagingRuntimeOutputInvalidConfigSnapshot },
|
|
{ formatPluginPackagingRuntimeOutputRecoveryHint },
|
|
] = await Promise.all([
|
|
import("../../../packages/terminal-core/src/theme.js"),
|
|
import("../../utils.js"),
|
|
import("../command-format.js"),
|
|
import("../../config/recovery-policy.js"),
|
|
import("../config-recovery-hints.js"),
|
|
]);
|
|
const rich = isRich();
|
|
const muted = (value: string) => colorize(rich, theme.muted, value);
|
|
const error = (value: string) => colorize(rich, theme.error, value);
|
|
const heading = (value: string) => colorize(rich, theme.heading, value);
|
|
const commandText = (value: string) => colorize(rich, theme.command, value);
|
|
|
|
params.runtime.error(heading("OpenClaw config is invalid"));
|
|
params.runtime.error(`${muted("File:")} ${muted(shortenHomePath(snapshot.path))}`);
|
|
if (issues.length > 0) {
|
|
params.runtime.error(muted("Problem:"));
|
|
params.runtime.error(issues.map((issue) => ` ${error(issue)}`).join("\n"));
|
|
}
|
|
if (legacyIssues.length > 0) {
|
|
params.runtime.error(muted("Legacy config keys detected:"));
|
|
params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n"));
|
|
}
|
|
params.runtime.error("");
|
|
const fixHint = isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot)
|
|
? formatPluginPackagingRuntimeOutputRecoveryHint()
|
|
: commandText(formatCliCommand("openclaw doctor --fix"));
|
|
params.runtime.error(`${muted("Fix:")} ${fixHint}`);
|
|
params.runtime.error(
|
|
`${muted("Inspect:")} ${commandText(formatCliCommand("openclaw config validate"))}`,
|
|
);
|
|
params.runtime.error(
|
|
muted(
|
|
"Audit, status, health, logs, tasks list/audit, and doctor commands still run with invalid config.",
|
|
),
|
|
);
|
|
if (!allowInvalid) {
|
|
params.runtime.exit(1);
|
|
}
|
|
}
|
|
|
|
export const testApi = {
|
|
resetConfigGuardStateForTests,
|
|
};
|
|
export { testApi as __test__ };
|