diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index ac8def22f57e..bb89835fe84b 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -103,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`) && @@ -137,7 +154,8 @@ function hasLegacyStateMigrationInputs(): boolean { sqliteSidecarPaths.some( (sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath), ) || - hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) + hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) || + hasCrossStateDirApprovalMigrationInputs(stateDir) ); } @@ -195,6 +213,7 @@ export async function ensureConfigReady(params: { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + crossStateDirImports: false, ...(params.beforeStateMigrations ? { beforeStateMigrations: params.beforeStateMigrations } : {}), diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 2287f2963859..508343023cd3 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -11,7 +11,6 @@ import { noteOpencodeProviderOverrides, } from "./doctor-config-analysis.js"; import { runDoctorConfigPreflight } from "./doctor-config-preflight.js"; -import { normalizeCompatibilityConfigValues } from "./doctor/shared/legacy-config-core-migrate.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; import { emitDoctorNotes, sanitizeDoctorNote } from "./doctor/emit-notes.js"; import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js"; @@ -24,6 +23,7 @@ import { collectMissingDefaultAccountBindingWarnings, collectMissingExplicitDefaultAccountWarnings, } from "./doctor/shared/default-account-warnings.js"; +import { normalizeCompatibilityConfigValues } from "./doctor/shared/legacy-config-core-migrate.js"; function hasLegacyInternalHookHandlers(raw: unknown): boolean { const handlers = (raw as { hooks?: { internal?: { handlers?: unknown } } })?.hooks?.internal @@ -145,7 +145,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; diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 6d3e34811851..c3dec77c6c44 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -514,16 +514,15 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise 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");