// Runs post-plugin convergence checks without retaining pre-update plugin modules. import os from "node:os"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV, UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV, UPDATE_POST_CORE_CONVERGENCE_ENV, } from "../../commands/doctor/shared/update-phase.js"; import { readConfigFileSnapshot } from "../../config/config.js"; import { resolveStateDir } from "../../config/paths.js"; import type { ConfigFileSnapshot } from "../../config/types.openclaw.js"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import { resolveAggregateSqliteInspectionTimeoutMs } from "../../infra/sqlite-readonly-worker.js"; import { collectStateDatabasePaths } from "../../infra/update-candidate-state.js"; import { readUpdateStateDatabaseSizes } from "../../infra/update-candidate-state.sizes.js"; import { UPDATE_RUN_ID_ENV } from "../../infra/update-control-plane-sentinel.js"; import { hasDeferredUpdateModelRetirement } from "../../infra/update-deferred-model-retirement.js"; import { consumeUpdatePostInstallDoctorResult, createUpdatePostInstallDoctorResultPath, UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV, UpdateDoctorError, type UpdatePostInstallDoctorResult, } from "../../infra/update-doctor-result.js"; import { createUpdateFailureFact, type UpdateFailureFact, } from "../../infra/update-failure-facts.js"; import { buildUpdateDoctorEnv } from "../../infra/update-runner-doctor.js"; import { redactSupportString } from "../../logging/diagnostic-support-redaction.js"; import { formatCommandOutput } from "../../process/command-error.js"; import { isPlainCommandExitFailure, runExec } from "../../process/exec.js"; import { defaultRuntime } from "../../runtime.js"; import { truncateUtf8Prefix, truncateUtf8Suffix } from "../../utils/utf8-truncate.js"; import { resolveNodeRunner } from "./shared.js"; import type { PostCorePluginUpdateResult } from "./update-command-plugins.js"; import { applyPostPluginUpdateReadiness } from "./update-command-post-plugin-readiness.js"; import { applyPostPluginConfigValidation, POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON, } from "./update-command-post-plugin-validation.js"; import { disableUpdatedPackageCompileCacheEnv, stripGatewayServiceMarkerEnv, } from "./update-command-service-env.js"; import { captureUpdateFinalizationDoctorOutput } from "./update-finalization-output.js"; type UpdateDoctorPhase = "pre-plugin" | "post-plugin"; export async function withPrePluginUpdateDoctorEnv(run: () => Promise): Promise { const previousValues = [ "OPENCLAW_UPDATE_IN_PROGRESS", UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV, UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV, UPDATE_POST_CORE_CONVERGENCE_ENV, ].map((key) => [key, process.env[key]] as const); process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1"; process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = "1"; process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1"; delete process.env[UPDATE_POST_CORE_CONVERGENCE_ENV]; try { return await run(); } finally { for (const [key, value] of previousValues) { if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } } } async function withNormalConfigValidation(run: () => Promise): Promise { const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS; process.env.OPENCLAW_UPDATE_IN_PROGRESS = "0"; try { return await run(); } finally { if (previousUpdateInProgress === undefined) { delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; } else { process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress; } } } function createPostPluginDoctorExecutionFailure( pluginUpdate: PostCorePluginUpdateResult, reason: string, failureFacts?: UpdateFailureFact[], ): PostCorePluginUpdateResult { return { ...pluginUpdate, status: "error", reason: POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON, ...(failureFacts?.length ? { failureFacts } : {}), warnings: [ ...(pluginUpdate.warnings ?? []), { reason, message: "Updated plugin migrations could not be run in a fresh process.", guidance: ["Run `openclaw update repair` to retry post-update plugin repair."], }, ], }; } export async function runUpdateFinalizationDoctorInFreshProcess(params: { phase: UpdateDoctorPhase; root: string; runId?: string; yes: boolean; json: boolean; workspaceSuggestions?: boolean; timeoutMs?: number; nodeRunner?: string; entryPath?: string; onWarnings?: (warnings: string[]) => void; }): Promise { const entryPath = params.entryPath ?? (await resolveGatewayInstallEntrypoint(params.root)); if (!entryPath) { throw new Error("Updated OpenClaw entrypoint not found for post-plugin doctor"); } const args = [ entryPath, "doctor", "--repair", "--non-interactive", ...(params.workspaceSuggestions ? [] : ["--no-workspace-suggestions"]), ...(params.yes ? ["--yes"] : []), ]; const baseEnv = stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)); delete baseEnv[UPDATE_POST_CORE_CONVERGENCE_ENV]; const doctorResultPath = createUpdatePostInstallDoctorResultPath(); let doctorResult: UpdatePostInstallDoctorResult | null = null; let result: { stdout?: unknown; stderr?: unknown } | undefined; try { result = await runExec(params.nodeRunner ?? resolveNodeRunner(), args, { cwd: params.root, timeoutMs: params.timeoutMs, maxBuffer: 4 * 1024 * 1024, logOutput: false, onOutputChunk: captureUpdateFinalizationDoctorOutput(params.phase), baseEnv, env: { [UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV]: doctorResultPath, ...(params.runId ? { [UPDATE_RUN_ID_ENV]: params.runId } : {}), // The outer updater owns service refresh and activation after every // migration finishes; a fresh Doctor must not resume its parked service. ...buildUpdateDoctorEnv({ allowGatewayServiceRepair: false, allowGatewayActivation: false, deferConfiguredPluginInstallRepair: true, }), ...(params.phase === "post-plugin" ? { [UPDATE_POST_CORE_CONVERGENCE_ENV]: "1" } : {}), }, }); } catch (error) { doctorResult = await consumeUpdatePostInstallDoctorResult(doctorResultPath); if (isRecord(error)) { result = error; // Enabling the existing result channel gives deferred plugin repair its // advisory exit code. Convergence below still owns that repair. if ( error.exitCode === UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE && isPlainCommandExitFailure({ ...error, failed: error.failed === true }) && doctorResult?.status === "advisory" ) { return; } } const exitCode = isRecord(error) && typeof error.exitCode === "number" ? error.exitCode : null; const redaction = { env: process.env, stateDir: resolveStateDir() }; const failureFacts = doctorResult?.failureFacts?.length ? doctorResult.failureFacts : [ createUpdateFailureFact({ check: "doctor", code: "doctor-failed", message: typeof result?.stderr === "string" && result.stderr.trim() ? result.stderr : error instanceof Error ? error.message : String(error), }), ]; const details = (["stderr", "stdout"] as const).flatMap((stream) => { const output = result?.[stream]; if (typeof output !== "string" || !output.trim()) { return []; } // Execa's message starts with full argv. Keep both actual diagnostics before // the bounded update handoff, without cutting a credential before redaction. const redacted = redactSupportString(output, redaction, { maxLength: Number.MAX_SAFE_INTEGER, }); const formatted = formatCommandOutput(redacted, 384); let excerpt = formatted; if (Buffer.byteLength(redacted) > 384 || Buffer.byteLength(formatted) > 384) { const beginning = formatCommandOutput(truncateUtf8Prefix(redacted, 256), 256); excerpt = `${truncateUtf8Prefix(beginning, 256)}\n...\n${truncateUtf8Suffix(formatted, 123)}`; } return excerpt ? [`${stream}: ${excerpt}`] : []; }); if (details.length > 0) { throw new UpdateDoctorError( `Updated ${params.phase} Doctor failed:\n${details.join("\n")}`, failureFacts, { cause: error, exitCode }, ); } throw new UpdateDoctorError( error instanceof Error ? error.message : String(error), failureFacts, { cause: error, exitCode }, ); } finally { doctorResult ??= await consumeUpdatePostInstallDoctorResult(doctorResultPath); if (doctorResult?.warnings?.length) { params.onWarnings?.(doctorResult.warnings); } // Clack writes directly to the child's stdout. Preserve diagnostics on either // exit path without letting them share the parent's JSON result stream. if (typeof result?.stdout === "string" && result.stdout.trim()) { defaultRuntime[params.json ? "error" : "log"](result.stdout.trimEnd()); } if (typeof result?.stderr === "string" && result.stderr.trim()) { defaultRuntime.error(result.stderr.trimEnd()); } } } async function validatePostPluginConfigInFreshProcess(params: { root: string; timeoutMs: number; entryPath: string; nodeRunner?: string; }): Promise { try { await runExec( params.nodeRunner ?? resolveNodeRunner(), [params.entryPath, "config", "validate", "--json"], { cwd: params.root, timeoutMs: params.timeoutMs, maxBuffer: 4 * 1024 * 1024, logOutput: false, baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)), env: { OPENCLAW_UPDATE_IN_PROGRESS: "0" }, }, ); return true; } catch { return false; } } export async function completePostCorePluginUpdate(params: { root: string; runId?: string; pluginUpdate: PostCorePluginUpdateResult; freshDoctorRequired: boolean; yes: boolean; json: boolean; timeoutMs?: number; nodeRunner?: string; beforeDoctor?: () => Promise; onWarnings?: (warnings: string[]) => void; }): Promise<{ pluginUpdate: PostCorePluginUpdateResult; configSnapshot: ConfigFileSnapshot; }> { let pluginUpdate = params.pluginUpdate; let entryPath: string | undefined; let freshConfigValid: boolean | undefined; if (pluginUpdate.status !== "error") { try { entryPath = await resolveGatewayInstallEntrypoint(params.root); if (!entryPath) { throw new Error("Updated OpenClaw entrypoint not found for post-plugin doctor"); } if (params.freshDoctorRequired || hasDeferredUpdateModelRetirement()) { await params.beforeDoctor?.(); await runUpdateFinalizationDoctorInFreshProcess({ ...params, entryPath, phase: "post-plugin", }); } } catch (err) { pluginUpdate = createPostPluginDoctorExecutionFailure( params.pluginUpdate, String(err), err instanceof UpdateDoctorError ? err.failureFacts : undefined, ); freshConfigValid = false; } } // Only the target runtime may write state after a version switch: observing // config here could migrate its database back to the parent's newer schema. const configSnapshot = await withNormalConfigValidation(() => readConfigFileSnapshot({ observe: false }), ); if (entryPath) { let checkTimeoutMs = params.timeoutMs; if (checkTimeoutMs === undefined) { // Doctor can grow shared and agent stores. Measure once after its writes settle. const env = { ...process.env }; const databases = await collectStateDatabasePaths( { stateDir: resolveStateDir(env), config: configSnapshot.sourceConfig, env }, { includeUnconfiguredAgents: false }, ); checkTimeoutMs = resolveAggregateSqliteInspectionTimeoutMs( "post-plugin checks", await readUpdateStateDatabaseSizes( Array.from(databases.values(), (database) => database.spellings[0]), { nodeRunner: process.execPath, sourceEnv: env, stagingRoot: os.tmpdir() }, ), ); } // No authored file is a valid unconfigured install, not an invalid config. // Existing files still need the target schema; every install needs readiness. freshConfigValid = (!configSnapshot.exists && configSnapshot.valid) || (await validatePostPluginConfigInFreshProcess({ ...params, entryPath, timeoutMs: checkTimeoutMs, })); if (freshConfigValid) { pluginUpdate = await applyPostPluginUpdateReadiness({ root: params.root, entryPath, pluginUpdate, timeoutMs: checkTimeoutMs, ...(params.nodeRunner ? { nodeRunner: params.nodeRunner } : {}), }); } } // Strict validity belongs to the target runtime even when no plugin changed. // The parent may retain the previous schema; its snapshot is best-effort context. pluginUpdate = applyPostPluginConfigValidation( pluginUpdate, freshConfigValid ?? configSnapshot.valid, ); return { pluginUpdate, configSnapshot }; }