fix(doctor): keep automated repair from moving approval state (#103353)

* fix(doctor): isolate automated cross-state imports

* test(update): cover isolated doctor finalization
This commit is contained in:
Peter Steinberger
2026-07-10 05:42:36 +01:00
committed by GitHub
parent 4e3d91a020
commit cd9db5ed9a
20 changed files with 314 additions and 30 deletions
+2
View File
@@ -26,6 +26,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- **OpenAI-compatible streamed tool calls:** execute complete native tool calls from streams that end with SSE `data: [DONE]` but omit `finish_reason`, while keeping transport EOF and visible-text cases fail-closed. (#98124, #97994) Thanks @SunnyShu0925.
- **Doctor state isolation:** prevent automated update and Gateway watch repair from importing and archiving default-home exec or plugin-binding approvals when `OPENCLAW_STATE_DIR` points elsewhere, keep implicit CLI preflight notice-only, and reserve cross-state imports for direct operator doctor runs. (#103247, #103317)
- **Doctor clean-state guidance:** stop suggesting `openclaw doctor --fix` after a clean run with no config changes while preserving targeted repair hints. (#103233)
- **OpenCode Zen model catalog:** refresh the provider-owned static seed for Claude Sonnet 5, Grok 4.5, Hy3 Free, Kimi K2.7 Code, and MiniMax M3 with verified routing, pricing, limits, and input capabilities, remove retired free-tier rows, and expose the same catalog through unauthenticated model listing. (#103184)
- **Managed browser launch:** surface asynchronous Chrome bootstrap and runtime spawn failures as browser errors while keeping Gateway alive, and retain process error handling through later lifecycle failures.
- **Browser node-proxy downloads:** transfer every action-produced download to the Gateway media store, align a 10 MiB per-file and 16 MiB aggregate transport budget, and rewrite plural download paths to Gateway-local files without traversing page-controlled result data.
+8 -5
View File
@@ -89,12 +89,15 @@ The default approval socket follows the same root:
`$OPENCLAW_STATE_DIR/exec-approvals.sock`, or
`~/.openclaw/exec-approvals.sock` when the variable is unset.
Releases before 2026.6.11 always kept the file in `~/.openclaw`. If
Releases before 2026.6.6 always kept the file in `~/.openclaw`. If
`OPENCLAW_STATE_DIR` points somewhere else and an approvals file still exists
in the default directory, run `openclaw doctor --fix` once to import it into
the state directory (the original is archived with a `.migrated` suffix).
OpenClaw never imports it automatically: a gateway pointed at a temporary or
staging state directory must not capture the default installation's approvals.
in the default directory, run `openclaw doctor --fix` directly once to import
it into the state directory (the original is archived with a `.migrated`
suffix). Interactive doctor can also preview and confirm the import. Automated
update and Gateway watch repair runs never import across state directories: a
temporary or staging state directory must not capture the default
installation's approvals. The same boundary applies to legacy
`plugin-binding-approvals.json` imports into shared SQLite state.
Example schema:
@@ -55,6 +55,7 @@ git_cli="$git_root/openclaw.mjs"
package_version="$(node -p "require(\"$npm_root/package.json\").version")"
update_doctor_env="OPENCLAW_UPDATE_IN_PROGRESS=1"
update_doctor_env+=" OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS=1"
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1"
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1"
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1"
@@ -161,6 +162,7 @@ run_flow() {
fi
assert_entrypoint "$unit_path" "$doctor_expected"
assert_no_env_key "$unit_path" "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS"
}
run_flow \
@@ -177,6 +179,85 @@ run_flow \
"$update_doctor_env $npm_bin doctor --repair --force --yes --non-interactive" \
"$npm_entry"
plugin_binding_approval_count() {
local database_path="$1"
if [ ! -f "$database_path" ]; then
echo "0"
return
fi
node --no-warnings - "$database_path" <<'NODE'
const { DatabaseSync } = require("node:sqlite");
const database = new DatabaseSync(process.argv[2]);
const table = database
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("plugin_binding_approvals");
const row = table
? database.prepare("SELECT COUNT(*) AS count FROM plugin_binding_approvals").get()
: { count: 0 };
database.close();
process.stdout.write(String(row.count));
NODE
}
run_cross_state_approval_flow() {
local name="cross-state-approvals"
local automated_log="/tmp/openclaw-doctor-switch-${name}-automated.log"
local direct_log="/tmp/openclaw-doctor-switch-${name}-direct.log"
local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"
echo "== Flow: $name =="
openclaw_test_state_create "switch-${name}" empty
export USER="testuser"
local default_state_dir="$HOME/.openclaw"
local custom_state_dir="$HOME/custom-state"
local exec_source="$default_state_dir/exec-approvals.json"
local plugin_source="$default_state_dir/plugin-binding-approvals.json"
local state_database="$custom_state_dir/state/openclaw.sqlite"
mkdir -p "$default_state_dir" "$custom_state_dir"
printf '%s\n' '{"version":1,"socket":{"token":"legacy-token"},"defaults":{"security":"deny","ask":"always"}}' >"$exec_source"
printf '%s\n' '{"version":1,"approvals":[{"pluginRoot":"/plugins/codex-a","pluginId":"codex","channel":"telegram","accountId":"default","approvedAt":2345}]}' >"$plugin_source"
local exec_source_hash
local plugin_source_hash
exec_source_hash="$(sha256sum "$exec_source" | awk '{print $1}')"
plugin_source_hash="$(sha256sum "$plugin_source" | awk '{print $1}')"
if ! openclaw_e2e_maybe_timeout "$command_timeout" env \
OPENCLAW_STATE_DIR="$custom_state_dir" \
OPENCLAW_CONFIG_PATH="$custom_state_dir/openclaw.json" \
OPENCLAW_UPDATE_IN_PROGRESS=1 \
"$npm_bin" doctor --repair --yes --non-interactive >"$automated_log" 2>&1; then
openclaw_e2e_print_log "$automated_log"
exit 1
fi
test "$(sha256sum "$exec_source" | awk '{print $1}')" = "$exec_source_hash"
test "$(sha256sum "$plugin_source" | awk '{print $1}')" = "$plugin_source_hash"
test ! -e "$exec_source.migrated"
test ! -e "$plugin_source.migrated"
test ! -e "$custom_state_dir/exec-approvals.json"
test "$(plugin_binding_approval_count "$state_database")" = "0"
if ! openclaw_e2e_maybe_timeout "$command_timeout" env \
-u OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS \
-u OPENCLAW_UPDATE_IN_PROGRESS \
OPENCLAW_STATE_DIR="$custom_state_dir" \
OPENCLAW_CONFIG_PATH="$custom_state_dir/openclaw.json" \
"$npm_bin" doctor --repair --yes --non-interactive >"$direct_log" 2>&1; then
openclaw_e2e_print_log "$direct_log"
exit 1
fi
test ! -e "$exec_source"
test ! -e "$plugin_source"
test "$(sha256sum "$exec_source.migrated" | awk '{print $1}')" = "$exec_source_hash"
test "$(sha256sum "$plugin_source.migrated" | awk '{print $1}')" = "$plugin_source_hash"
test -e "$custom_state_dir/exec-approvals.json"
test "$(plugin_binding_approval_count "$state_database")" = "1"
}
run_cross_state_approval_flow
run_proxy_env_flow() {
local name="proxy-env-cleanup"
local install_log="/tmp/openclaw-doctor-switch-${name}-install.log"
@@ -206,6 +287,7 @@ run_proxy_env_flow() {
} >>"$unit_path"
if ! openclaw_e2e_maybe_timeout "$command_timeout" env \
OPENCLAW_UPDATE_IN_PROGRESS=1 \
OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS=1 \
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1 \
OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1 \
OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1 \
@@ -216,6 +298,7 @@ run_proxy_env_flow() {
fi
assert_no_env_key "$unit_path" "HTTP_PROXY"
assert_no_env_key "$unit_path" "HTTPS_PROXY"
assert_no_env_key "$unit_path" "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS"
}
run_proxy_env_flow
+8 -1
View File
@@ -20,6 +20,10 @@ const WATCH_LOCK_POLL_MS = 100;
const WATCH_SHUTDOWN_KILL_GRACE_MS = 5_000;
const WATCH_LOCK_DIR = path.join(".local", "watch-node");
const AUTO_DOCTOR_DISABLE_VALUES = new Set(["0", "false", "no", "off"]);
// The source watcher cannot import the TypeScript owner; keep this literal
// aligned with src/commands/doctor-invocation.ts.
const DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV =
"OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS";
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
const buildDoctorRunnerArgs = () => [WATCH_NODE_RUNNER, "doctor", "--fix", "--non-interactive"];
@@ -452,7 +456,10 @@ export async function runWatchMain(params = {}) {
watchProcess = deps.spawn(deps.process.execPath, buildDoctorRunnerArgs(), {
cwd: deps.cwd,
detached: useChildProcessGroup,
env: childEnv,
env: {
...childEnv,
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
},
stdio: "inherit",
});
watchProcess.on("error", (error) => {
+35 -10
View File
@@ -182,6 +182,7 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
});
}
});
@@ -203,6 +204,7 @@ describe("ensureConfigReady", () => {
migrateLegacyConfig: false,
invalidConfigNote: false,
observe: false,
crossStateDirImports: false,
});
});
@@ -217,6 +219,7 @@ describe("ensureConfigReady", () => {
migrateLegacyConfig: false,
invalidConfigNote: false,
observe: false,
crossStateDirImports: false,
});
});
@@ -227,6 +230,7 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
requireStartupMigrationCheckpoint: true,
});
});
@@ -259,6 +263,7 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
});
});
@@ -282,6 +287,7 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
});
});
@@ -306,6 +312,7 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
});
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
migratedSnapshot.runtimeConfig,
@@ -322,18 +329,36 @@ describe("ensureConfigReady", () => {
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it("does not run doctor flow for default-state-dir exec approvals when a custom state dir is set", async () => {
// Cross-state-dir imports are doctor-owned; the implicit preflight must not
// trigger (and must never archive) files that belong to the default dir.
const root = useTempOpenClawHome();
const stateDir = path.join(root, "custom-state");
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
writeStateMarker(root, "exec-approvals.json");
it.each([
{ commandPath: ["agent"], source: "exec-approvals.json" },
{ commandPath: ["status"], source: "plugin-binding-approvals.json" },
{ commandPath: ["plugins", "list"], source: "exec-approvals.json" },
{ commandPath: ["tasks", "list"], source: "plugin-binding-approvals.json" },
])(
"runs notice-only preflight for $commandPath with default-state $source",
async ({ commandPath, source }) => {
const root = useTempOpenClawHome();
const stateDir = path.join(root, "custom-state");
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
writeStateMarker(root, source);
const sourcePath = path.join(root, ".openclaw", source);
const sourceRaw = fs.readFileSync(sourcePath, "utf8");
await runEnsureConfigReady(["agent"]);
await runEnsureConfigReady(commandPath);
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
});
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
...(commandPath[0] === "status" ? { observe: false } : {}),
crossStateDirImports: false,
});
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
expect(fs.existsSync(path.join(stateDir, "exec-approvals.json"))).toBe(false);
},
);
it.each([
["Discord model picker preferences", "discord/model-picker-preferences.json"],
+26 -2
View File
@@ -4,7 +4,12 @@ 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 {
resolveLegacyStateDirs,
resolveNewStateDir,
resolveOAuthDir,
resolveStateDir,
} from "../../config/paths.js";
import type { ConfigFileSnapshot } from "../../config/types.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { ExitError, type RuntimeEnv } from "../../runtime.js";
@@ -98,6 +103,23 @@ function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir:
return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile);
}
function hasCrossStateDirApprovalMigrationInputs(stateDir: string): boolean {
if (!process.env.OPENCLAW_STATE_DIR?.trim()) {
return false;
}
const homeDir = resolveRequiredHomeDir(process.env, os.homedir);
const defaultStateDir = resolveNewStateDir(() => homeDir);
if (path.resolve(defaultStateDir) === path.resolve(stateDir)) {
return false;
}
const execApprovalsSource = path.join(defaultStateDir, "exec-approvals.json");
const execApprovalsTarget = path.join(stateDir, "exec-approvals.json");
return (
(fileOrDirExists(execApprovalsSource) && !fileOrDirExists(execApprovalsTarget)) ||
fileOrDirExists(path.join(defaultStateDir, "plugin-binding-approvals.json"))
);
}
function hasPendingSqliteSidecarArchive(sourcePath: string): boolean {
return (
fileOrDirExists(`${sourcePath}.migrated`) &&
@@ -133,7 +155,8 @@ function hasLegacyStateMigrationInputs(): boolean {
sqliteSidecarPaths.some(
(sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath),
) ||
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir)
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) ||
hasCrossStateDirApprovalMigrationInputs(stateDir)
);
}
@@ -209,6 +232,7 @@ export async function ensureConfigReady(params: {
migrateLegacyConfig: false,
invalidConfigNote: false,
...(commandName === "status" ? { observe: false } : {}),
crossStateDirImports: false,
...(shouldRequireStartupMigrationCheckpoint(commandPath)
? { requireStartupMigrationCheckpoint: true }
: {}),
+28 -1
View File
@@ -1,6 +1,7 @@
// Register maintenance tests cover maintenance command registration in the CLI program.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
import { registerMaintenanceCommands } from "./register.maintenance.js";
const mocks = vi.hoisted(() => ({
@@ -68,6 +69,10 @@ describe("registerMaintenanceCommands doctor action", () => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("exits with code 0 after successful doctor run", async () => {
doctorCommand.mockResolvedValue(undefined);
@@ -101,6 +106,28 @@ describe("registerMaintenanceCommands doctor action", () => {
const [runtimeArg, options] = commandCall(doctorCommand);
expect(runtimeArg).toBe(runtime);
expect(options.repair).toBe(true);
expect(options.crossStateDirImports).toBe(true);
});
it("denies cross-state imports when an automation parent disables them", async () => {
doctorCommand.mockResolvedValue(undefined);
vi.stubEnv(DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV, "1");
await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]);
const [, options] = commandCall(doctorCommand);
expect(options.repair).toBe(true);
expect(options.crossStateDirImports).toBe(false);
});
it("denies cross-state imports for older update parents", async () => {
doctorCommand.mockResolvedValue(undefined);
vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1");
await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]);
const [, options] = commandCall(doctorCommand);
expect(options.crossStateDirImports).toBe(false);
});
it("runs doctor lint mode without invoking repair doctor", async () => {
+2
View File
@@ -2,6 +2,7 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { resolveDoctorCrossStateDirImports } from "../../commands/doctor-invocation.js";
import { defaultRuntime } from "../../runtime.js";
import { runCommandWithRuntime } from "../cli-utils.js";
@@ -96,6 +97,7 @@ export function registerMaintenanceCommands(program: Command) {
deep: Boolean(opts.deep),
postUpgrade: Boolean(opts.postUpgrade),
json: Boolean(opts.json),
crossStateDirImports: resolveDoctorCrossStateDirImports(),
});
defaultRuntime.exit(0);
});
+2
View File
@@ -7406,6 +7406,7 @@ describe("update-cli", () => {
nonInteractive: true,
repair: true,
yes: true,
crossStateDirImports: false,
});
expect(syncPluginCall()?.channel).toBe("stable");
expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true);
@@ -7505,6 +7506,7 @@ describe("update-cli", () => {
nonInteractive: true,
repair: true,
yes: false,
crossStateDirImports: false,
});
expect(syncPluginCall()?.channel).toBe("beta");
expect(syncPluginCall()?.config).toEqual({
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
import {
buildGatewayInstallEntrypointCandidates as resolveGatewayInstallEntrypointCandidates,
resolveGatewayInstallEntrypoint,
@@ -216,6 +217,7 @@ describe("resolvePostInstallDoctorEnv", () => {
expect(env.PATH).toBe("/bin");
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1");
expect(env.OPENCLAW_STATE_DIR).toBe(path.join("/srv/openclaw", "daemon-state"));
expect(env.OPENCLAW_CONFIG_PATH).toBe(
path.join("/srv/openclaw", "daemon-state", "openclaw.json"),
@@ -234,6 +236,7 @@ describe("resolvePostInstallDoctorEnv", () => {
expect(env.PATH).toBe("/bin");
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1");
expect(env.OPENCLAW_STATE_DIR).toBe("/caller/state");
expect(env.OPENCLAW_PROFILE).toBe("caller");
});
+8 -1
View File
@@ -16,6 +16,7 @@ import {
checkShellCompletionStatus,
ensureCompletionCacheExists,
} from "../../commands/doctor-completion.js";
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
import { doctorCommand } from "../../commands/doctor.js";
import {
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
@@ -1494,7 +1495,10 @@ export function resolvePostInstallDoctorEnv(params?: {
serviceEnv?: NodeJS.ProcessEnv;
invocationCwd?: string;
}): NodeJS.ProcessEnv {
const resolvedEnv = disableUpdatedPackageCompileCacheEnv(params?.baseEnv ?? process.env);
const resolvedEnv: NodeJS.ProcessEnv = {
...disableUpdatedPackageCompileCacheEnv(params?.baseEnv ?? process.env),
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
};
if (!params?.serviceEnv) {
return resolvedEnv;
}
@@ -2802,6 +2806,7 @@ async function maybeRestartService(params: {
process.stdin.isTTY && !params.opts.json && params.opts.yes !== true;
await doctorCommand(defaultRuntime, {
nonInteractive: !interactiveDoctor,
crossStateDirImports: false,
});
} catch (err) {
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
@@ -2988,6 +2993,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
nonInteractive: true,
repair: true,
yes: opts.yes === true,
crossStateDirImports: false,
});
configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
if (requestedChannel) {
@@ -3478,6 +3484,7 @@ async function continuePostCoreUpdateInFreshProcess(params: {
env: {
...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
[POST_CORE_UPDATE_ENV]: "1",
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
...(params.requestedChannel
+28 -1
View File
@@ -14,6 +14,7 @@ type TerminalNote = (message: string, title?: string) => void;
const terminalNoteMock = vi.hoisted(() => vi.fn<TerminalNote>());
const callGatewayMock = vi.hoisted(() => vi.fn());
const runDoctorRepairSequenceMock = vi.hoisted(() => vi.fn());
const runDoctorConfigPreflightOptionsMock = vi.hoisted(() => vi.fn());
const collectDoctorPreviewNotesParamsMock = vi.hoisted(() => vi.fn());
const collectImplicitFallbackClobberWarningsMock = vi.hoisted(() =>
vi.fn<(cfg: unknown) => string[]>(() => []),
@@ -1301,7 +1302,8 @@ vi.mock("./doctor-config-preflight.js", async () => {
}
return {
runDoctorConfigPreflight: vi.fn(async () => {
runDoctorConfigPreflight: vi.fn(async (options: unknown) => {
runDoctorConfigPreflightOptionsMock(options);
const injected = getDoctorConfigInputForTest();
const configPath = injected?.path ?? resolveConfigPath();
let parsed: Record<string, unknown> = injected?.config
@@ -1530,6 +1532,31 @@ describe("doctor config flow", () => {
collectImplicitFallbackClobberWarningsMock.mockClear();
collectImplicitFallbackClobberWarningsMock.mockReturnValue([]);
noteImplicitFallbackClobberWarningsMock.mockClear();
runDoctorConfigPreflightOptionsMock.mockClear();
});
it("grants config preflight cross-state imports only with repair and direct capability", async () => {
await runDoctorConfigWithInput({
config: {},
repair: true,
run: ({ options, confirm }) =>
loadAndMaybeMigrateDoctorConfig({
options: { ...options, crossStateDirImports: true },
confirm: async () => confirm(),
}),
});
expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ crossStateDirImports: true }),
);
await runDoctorConfigWithInput({
config: {},
repair: true,
run: loadAndMaybeMigrateDoctorConfig,
});
expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ crossStateDirImports: false }),
);
});
it("preserves invalid config for doctor repairs", async () => {
+1 -1
View File
@@ -141,7 +141,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
const preflight = await runDoctorConfigPreflight({
repairPrefixedConfig: shouldRepair,
recoverCorruptTargetStore: shouldRepair,
crossStateDirImports: shouldRepair,
crossStateDirImports: shouldRepair && params.options.crossStateDirImports === true,
});
const snapshot = preflight.snapshot;
const baseCfg = preflight.baseConfig;
+16
View File
@@ -0,0 +1,16 @@
/** Internal doctor invocation capabilities shared by direct and automated callers. */
import { isTruthyEnvValue } from "../infra/env.js";
import { UPDATE_IN_PROGRESS_ENV } from "./doctor/shared/update-phase.js";
export const DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV =
"OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS";
/** Direct CLI doctor owns cross-state imports unless its automation parent denies them. */
export function resolveDoctorCrossStateDirImports(env: NodeJS.ProcessEnv = process.env): boolean {
// Older update parents know only OPENCLAW_UPDATE_IN_PROGRESS. Treat that
// existing cross-version handshake as deny-by-default for a newer doctor.
return !(
isTruthyEnvValue(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]) ||
isTruthyEnvValue(env[UPDATE_IN_PROGRESS_ENV])
);
}
+2
View File
@@ -10,4 +10,6 @@ export type DoctorOptions = {
allowExec?: boolean;
postUpgrade?: boolean;
json?: boolean;
/** Internal capability granted only to direct operator-owned doctor invocations. */
crossStateDirImports?: boolean;
};
@@ -1446,6 +1446,10 @@ describe("doctor health contributions", () => {
await contribution.run(ctx);
expect(mocks.detectLegacyStateMigrations).toHaveBeenCalledWith({
cfg,
crossStateDirImports: false,
});
expect(mocks.runLegacyStateMigrations).toHaveBeenCalledWith({
detected,
config: cfg,
@@ -1453,6 +1457,48 @@ describe("doctor health contributions", () => {
});
});
it("grants legacy-state cross-state imports only to capable doctor origins", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
const detected = { preview: [], warnings: [], notices: [] };
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
const directRepairContext = {
cfg: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { nonInteractive: true, repair: true, crossStateDirImports: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(directRepairContext);
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
cfg: {},
crossStateDirImports: true,
});
const interactivePrompter = buildDoctorPrompter(false);
interactivePrompter.repairMode.canPrompt = true;
interactivePrompter.repairMode.nonInteractive = false;
await contribution.run({
...directRepairContext,
prompter: interactivePrompter,
options: { crossStateDirImports: true },
});
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
cfg: {},
crossStateDirImports: true,
});
const automatedRepairContext = {
...directRepairContext,
options: { nonInteractive: true, repair: true, crossStateDirImports: false },
};
await contribution.run(automatedRepairContext);
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
cfg: {},
crossStateDirImports: false,
});
});
it("prints legacy state migration notices during manual doctor runs", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
const detected = { preview: ["legacy sessions"], warnings: [], notices: [] };
+6 -7
View File
@@ -518,16 +518,15 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
await import("../commands/doctor-state-migrations.js");
const { note } = await loadNoteModule();
// Cross-state-dir imports (default home dir -> OPENCLAW_STATE_DIR) are
// allowed here only when the operator either confirms the previewed plan
// interactively or asked for repair; a bare non-interactive doctor stays
// read-only toward the default state dir.
// Only a direct operator-owned doctor may inspect the default state dir for
// imports. Automated repair callers explicitly lack this capability so a
// temporary OPENCLAW_STATE_DIR cannot capture and archive production trust.
const operatorCanApproveCrossStateDirImports =
ctx.prompter.repairMode.canPrompt || ctx.prompter.shouldRepair;
const legacyState = await detectLegacyStateMigrations({
cfg: ctx.cfg,
crossStateDirImports:
ctx.options.nonInteractive !== true ||
ctx.options.repair === true ||
ctx.options.yes === true,
ctx.options.crossStateDirImports === true && operatorCanApproveCrossStateDirImports,
});
if (legacyState.warnings.length > 0) {
note(legacyState.warnings.join("\n"), "Doctor warnings");
+2
View File
@@ -960,6 +960,7 @@ describe("runGatewayUpdate", () => {
expect(result.status).toBe("ok");
expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
expect(doctorEnv?.OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART).toBe("1");
@@ -2688,6 +2689,7 @@ describe("runGatewayUpdate", () => {
expect(calls).toContain(doctorCommand);
expect(result.steps.map((step) => step.name)).toContain("openclaw doctor");
expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
expect(doctorEnv?.OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR).toBe("1");
+3
View File
@@ -6,6 +6,7 @@ import {
normalizeStringEntries,
uniqueStrings,
} from "@openclaw/normalization-core/string-normalization";
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../commands/doctor-invocation.js";
import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
import {
@@ -1628,6 +1629,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const doctorStep = await runStep(
step("openclaw doctor", doctorArgv, gitRoot, {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
...(opts.deferConfiguredPluginInstallRepair
? { [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1" }
: {}),
@@ -1823,6 +1825,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
timeoutMs,
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
[UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV]: "1",
[UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV]: allowGatewayServiceRepair
+5 -1
View File
@@ -343,7 +343,7 @@ describe("watch-node script", () => {
.mockReturnValueOnce(gatewayA)
.mockReturnValueOnce(doctor)
.mockReturnValueOnce(gatewayB);
const { watcher, fakeProcess, runPromise } = startWatchRun({ spawn });
const { watcher, fakeProcess, runPromise } = startWatchRun({ env: {}, spawn });
gatewayA.emit("exit", 1, null);
await new Promise((resolve) => {
@@ -360,6 +360,7 @@ describe("watch-node script", () => {
"--non-interactive",
]);
expect(requireSpawnOptions(spawn, 1).stdio).toBe("inherit");
expect(requireSpawnEnv(spawn, 1).OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1");
doctor.emit("exit", 0, null);
await new Promise((resolve) => {
@@ -371,6 +372,9 @@ describe("watch-node script", () => {
expect(restartedGatewaySpawnCall[0]).toBe("/usr/local/bin/node");
expect(restartedGatewaySpawnCall[1]).toEqual(["scripts/run-node.mjs", "gateway", "--force"]);
expect(requireSpawnOptions(spawn, 2).stdio).toBe("inherit");
expect(
requireSpawnEnv(spawn, 2).OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS,
).toBeUndefined();
fakeProcess.emit("SIGINT");
const exitCode = await runPromise;