fix(update): continue after package doctor warnings (#91586)

* fix(update): continue after package doctor warnings

* fix(update): type advisory step rendering

* fix(update): preserve advisory doctor step state

* fix(update): share advisory doctor state

* fix(update): keep timed-out doctor failures blocking

* fix(update): require explicit doctor advisory result

* fix(update): reject malformed doctor advisory results

* fix(update): bound doctor advisory diagnostics

* fix(update): keep doctor advisory restart-neutral

* fix(update): protect doctor advisory IPC

* fix(update): scope doctor advisories to converging updater

* fix(update): scope doctor advisories to deferred repairs

* fix(update): secure doctor advisory IPC

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Jason (Json)
2026-06-13 21:03:57 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 889bc52ba5
commit 7259cb5c77
16 changed files with 779 additions and 33 deletions
+168
View File
@@ -12,6 +12,12 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js";
import { writePackageDistInventory } from "../infra/package-dist-inventory.js";
import { isBetaTag } from "../infra/update-channels.js";
import {
createDeferredConfiguredPluginRepairDoctorResult,
UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV,
writeUpdatePostInstallDoctorResult,
} from "../infra/update-doctor-result.js";
import type { UpdateRunResult } from "../infra/update-runner.js";
import { withEnvAsync } from "../test-utils/env.js";
import { VERSION } from "../version.js";
@@ -2633,6 +2639,9 @@ describe("update-cli", () => {
);
await fs.writeFile(entryPath, "export {};\n", "utf-8");
await writePackageDistInventory(pkgRoot);
readPackageVersion.mockImplementation(async (packageRoot: string) =>
packageRoot === pkgRoot ? "2026.4.21" : "1.0.0",
);
pathExists.mockImplementation(async (candidate: string) => {
try {
await fs.access(candidate);
@@ -2672,6 +2681,165 @@ describe("update-cli", () => {
).toBe("1");
});
it("continues package post-core work for explicit post-update doctor advisories", async () => {
const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-warning-");
const nodeModules = path.join(tempDir, "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
const entryPath = path.join(pkgRoot, "dist", "index.js");
mockPackageInstallStatus(pkgRoot);
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
"utf-8",
);
await fs.writeFile(entryPath, "export {};\n", "utf-8");
await writePackageDistInventory(pkgRoot);
pathExists.mockImplementation(async (candidate: string) => {
try {
await fs.access(candidate);
return true;
} catch {
return false;
}
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv, options) => {
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return {
stdout: `${nodeModules}\n`,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
if (Array.isArray(argv) && argv[1] === entryPath && argv[2] === "doctor") {
const env = options && typeof options !== "number" ? options.env : undefined;
const resultPath = env?.[UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV];
if (!resultPath) {
throw new Error("missing doctor result path");
}
await writeUpdatePostInstallDoctorResult({
resultPath,
result: createDeferredConfiguredPluginRepairDoctorResult([
"deferred configured plugin repair",
]),
});
return {
stdout: "",
stderr: "doctor deferred configured plugin repair",
code: UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
signal: null,
killed: false,
termination: "exit",
};
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
await updateCommand({ yes: true, restart: false, json: true });
const doctorCall = doctorCommandCall();
expect(doctorCall?.[0].slice(1)).toEqual([entryPath, "doctor", "--non-interactive", "--fix"]);
const postCoreCall = spawnCall();
expect(postCoreCall?.[0]).toMatch(/node/);
expect(postCoreCall?.[1]).toEqual([entryPath, "update", "--json", "--no-restart", "--yes"]);
expect(postCoreCall?.[2]?.env?.OPENCLAW_UPDATE_POST_CORE).toBe("1");
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined;
const doctorStep = jsonOutput?.steps.find((step) => step.name === "openclaw doctor");
expect(jsonOutput?.status).toBe("ok");
expect(doctorStep?.exitCode).toBe(UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE);
expect(doctorStep?.advisory).toEqual({
kind: "package-post-install-doctor",
message: expect.stringContaining("recoverable update-time repair warning"),
});
expect(doctorStep?.advisory?.message).not.toContain("gateway restart");
expect(doctorStep?.stderrTail).toContain("doctor deferred configured plugin repair");
expect(doctorStep?.stderrTail).toContain("deferred configured plugin repair");
});
it("fails package updates when the post-update doctor is killed after verification", async () => {
const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-timeout-");
const nodeModules = path.join(tempDir, "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
const entryPath = path.join(pkgRoot, "dist", "index.js");
mockPackageInstallStatus(pkgRoot);
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
"utf-8",
);
await fs.writeFile(entryPath, "export {};\n", "utf-8");
await writePackageDistInventory(pkgRoot);
pathExists.mockImplementation(async (candidate: string) => {
try {
await fs.access(candidate);
return true;
} catch {
return false;
}
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return {
stdout: `${nodeModules}\n`,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
if (Array.isArray(argv) && argv[1] === entryPath && argv[2] === "doctor") {
return {
stdout: "",
stderr: "doctor timed out",
code: 124,
signal: null,
killed: true,
termination: "timeout",
};
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
await updateCommand({ yes: true, restart: false, json: true });
const doctorCall = doctorCommandCall();
expect(doctorCall?.[0].slice(1)).toEqual([entryPath, "doctor", "--non-interactive", "--fix"]);
expect(spawn).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined;
const doctorStep = jsonOutput?.steps.find((step) => step.name === "openclaw doctor");
expect(doctorStep?.exitCode).toBe(124);
expect(doctorStep?.advisory).toBeUndefined();
expect(doctorStep?.termination).toBe("timeout");
expect(
vi
.mocked(defaultRuntime.log)
.mock.calls.map((call) => String(call[0]))
.join("\n"),
).not.toContain("Post-install doctor failed after the package install was verified");
});
it("runs package post-update doctor from the verified package root after a staged swap", async () => {
const tempDir = await createTrackedTempDir("openclaw-update-staged-doctor-");
const nodeModules = path.join(tempDir, "lib", "node_modules");
+33 -8
View File
@@ -5,6 +5,7 @@ import { theme } from "../../../packages/terminal-core/src/theme.js";
import { formatDurationPrecise } from "../../infra/format-time/format-duration.ts";
import type {
UpdateRunResult,
UpdateStepAdvisory,
UpdateStepInfo,
UpdateStepProgress,
} from "../../infra/update-runner.js";
@@ -37,10 +38,14 @@ const STEP_LABELS: Record<string, string> = {
"global install": "Installing global package",
};
function getStepLabel(step: UpdateStepInfo): string {
function getStepLabel(step: Pick<UpdateStepInfo, "name">): string {
return STEP_LABELS[step.name] ?? step.name;
}
function isAdvisoryStep(step: { advisory?: UpdateStepAdvisory }): boolean {
return step.advisory !== undefined;
}
/** Convert updater failure reasons and stderr tails into operator-facing recovery hints. */
export function inferUpdateFailureHints(result: UpdateRunResult): string[] {
if (result.status !== "error") {
@@ -138,12 +143,19 @@ export function createUpdateProgress(enabled: boolean): ProgressController {
const label = getStepLabel(step);
const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`);
const icon = step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717");
const icon = formatStepStatus(step);
currentSpinner.stop(`${icon} ${label} ${duration}`);
currentSpinner = null;
if (step.exitCode !== 0 && step.stderrTail) {
if (isAdvisoryStep(step) && step.stderrTail) {
const lines = step.stderrTail.split("\n").slice(-10);
for (const line of lines) {
if (line.trim()) {
defaultRuntime.log(` ${theme.warn(line)}`);
}
}
} else if (step.exitCode !== 0 && step.stderrTail) {
const lines = step.stderrTail.split("\n").slice(-10);
for (const line of lines) {
if (line.trim()) {
@@ -165,11 +177,17 @@ export function createUpdateProgress(enabled: boolean): ProgressController {
};
}
function formatStepStatus(exitCode: number | null): string {
if (exitCode === 0) {
function formatStepStatus(step: {
exitCode: number | null;
advisory?: UpdateStepAdvisory;
}): string {
if (isAdvisoryStep(step)) {
return theme.warn("!");
}
if (step.exitCode === 0) {
return theme.success("\u2713");
}
if (exitCode === null) {
if (step.exitCode === null) {
return theme.warn("?");
}
return theme.error("\u2717");
@@ -213,11 +231,18 @@ export function printResult(result: UpdateRunResult, opts: PrintResultOptions):
defaultRuntime.log("");
defaultRuntime.log(theme.heading("Steps:"));
for (const step of result.steps) {
const status = formatStepStatus(step.exitCode);
const status = formatStepStatus(step);
const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`);
defaultRuntime.log(` ${status} ${step.name} ${duration}`);
if (step.exitCode !== 0 && step.stderrTail) {
if (isAdvisoryStep(step) && step.stderrTail) {
const lines = step.stderrTail.split("\n").slice(0, 5);
for (const line of lines) {
if (line.trim()) {
defaultRuntime.log(` ${theme.warn(line)}`);
}
}
} else if (step.exitCode !== 0 && step.stderrTail) {
const lines = step.stderrTail.split("\n").slice(0, 5);
for (const line of lines) {
if (line.trim()) {
+6
View File
@@ -206,6 +206,9 @@ export async function runUpdateStep(params: {
durationMs,
exitCode: res.code,
stderrTail,
signal: res.signal,
killed: res.killed,
termination: res.termination,
});
return {
@@ -216,6 +219,9 @@ export async function runUpdateStep(params: {
exitCode: res.code,
stdoutTail: trimLogTail(res.stdout, MAX_LOG_CHARS),
stderrTail,
signal: res.signal,
killed: res.killed,
termination: res.termination,
};
}
+40 -10
View File
@@ -52,7 +52,10 @@ import {
import { createLowDiskSpaceWarning } from "../../infra/disk-space.js";
import { pathExists } from "../../infra/fs-safe.js";
import { readJsonIfExists, writeJson } from "../../infra/json-files.js";
import { runGlobalPackageUpdateSteps } from "../../infra/package-update-steps.js";
import {
markPackagePostInstallDoctorAdvisory,
runGlobalPackageUpdateSteps,
} from "../../infra/package-update-steps.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
@@ -75,6 +78,11 @@ import {
writeControlPlaneUpdateRestartSentinel,
type ControlPlaneUpdateSentinelMetaFile,
} from "../../infra/update-control-plane-sentinel.js";
import {
consumeUpdatePostInstallDoctorResult,
createUpdatePostInstallDoctorResultPath,
UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV,
} from "../../infra/update-doctor-result.js";
import {
canResolveRegistryVersionForPackageTarget,
createGlobalInstallEnv,
@@ -1558,15 +1566,24 @@ async function runPackageInstallUpdate(params: {
if (entryPath) {
await createUpdateConfigSnapshot();
const candidateHostVersion = await readPackageVersion(verifiedPackageRoot);
return await runUpdateStep({
const doctorResultPath = createUpdatePostInstallDoctorResultPath();
const doctorArgv = [
params.nodeRunner ?? resolveNodeRunner(),
entryPath,
"doctor",
"--non-interactive",
"--fix",
];
const doctorProgressInfo = {
name: `${CLI_NAME} doctor`,
argv: [
params.nodeRunner ?? resolveNodeRunner(),
entryPath,
"doctor",
"--non-interactive",
"--fix",
],
command: doctorArgv.join(" "),
index: 0,
total: 0,
};
params.progress?.onStepStart?.(doctorProgressInfo);
const doctorStep = await runUpdateStep({
name: `${CLI_NAME} doctor`,
argv: doctorArgv,
cwd: verifiedPackageRoot,
env: {
...resolvePostInstallDoctorEnv({
@@ -1576,13 +1593,26 @@ async function runPackageInstallUpdate(params: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
[UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV]: doctorResultPath,
...(candidateHostVersion === null
? {}
: { OPENCLAW_COMPATIBILITY_HOST_VERSION: candidateHostVersion }),
},
timeoutMs: params.timeoutMs,
progress: params.progress,
});
const doctorResult = await consumeUpdatePostInstallDoctorResult(doctorResultPath);
const completedDoctorStep = markPackagePostInstallDoctorAdvisory(doctorStep, doctorResult);
params.progress?.onStepComplete?.({
...doctorProgressInfo,
durationMs: completedDoctorStep.durationMs,
exitCode: completedDoctorStep.exitCode,
stderrTail: completedDoctorStep.stderrTail,
signal: completedDoctorStep.signal,
killed: completedDoctorStep.killed,
termination: completedDoctorStep.termination,
advisory: completedDoctorStep.advisory,
});
return completedDoctorStep;
}
return null;
},
@@ -1097,6 +1097,9 @@ describe("repairMissingConfiguredPluginInstalls", () => {
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
warnings: [],
deferredRepairDetails: [
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
records,
});
});
@@ -1463,6 +1466,9 @@ describe("repairMissingConfiguredPluginInstalls", () => {
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
warnings: [],
deferredRepairDetails: [
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
records,
});
});
@@ -1190,6 +1190,8 @@ export type RepairMissingPluginInstallsResult = {
changes: string[];
/** User-facing warnings for failed or skipped plugin install repairs. */
warnings: string[];
/** User-facing details for repairs explicitly deferred until post-core convergence. */
deferredRepairDetails?: string[];
/** Plugin ids whose install repair failed and should be preserved from cleanup passes. */
failedPluginIds?: string[];
/**
@@ -1337,6 +1339,7 @@ async function repairMissingPluginInstalls(params: {
const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys());
const changes: string[] = [];
const warnings: string[] = [];
const deferredRepairDetails: string[] = [];
const failedPluginIds = new Set<string>();
const deferredPluginIds = new Set<string>();
const preferNpmInstalls = isLegacyPackageUpdateDoctorPass(env);
@@ -1369,9 +1372,9 @@ async function repairMissingPluginInstalls(params: {
if (!record || !isInstalledRecordMissingOnDisk(record, env)) {
continue;
}
changes.push(
`Skipped package-manager repair for configured plugin "${pluginId}" during package update; rerun "openclaw doctor --fix" after the update completes.`,
);
const detail = `Skipped package-manager repair for configured plugin "${pluginId}" during package update; rerun "openclaw doctor --fix" after the update completes.`;
changes.push(detail);
deferredRepairDetails.push(detail);
}
}
@@ -1546,6 +1549,7 @@ async function repairMissingPluginInstalls(params: {
return {
changes,
warnings,
...(deferredRepairDetails.length > 0 ? { deferredRepairDetails } : {}),
...(failedPluginIds.size > 0
? {
failedPluginIds: [...failedPluginIds].toSorted((left, right) =>
@@ -392,6 +392,9 @@ describe("configured plugin install release step", () => {
'Skipped package-manager repair for configured plugin "codex" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
warnings: [],
deferredRepairDetails: [
'Skipped package-manager repair for configured plugin "codex" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
});
const { maybeRunConfiguredPluginInstallReleaseStep } =
@@ -426,13 +429,60 @@ describe("configured plugin install release step", () => {
warnings: [],
completed: false,
touchedConfig: false,
postInstallDoctorResult: {
status: "advisory",
advisory: expect.objectContaining({
kind: "package-post-install-doctor",
reason: "deferred-configured-plugin-repair",
details: [
'Skipped package-manager repair for configured plugin "codex" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
}),
},
});
});
it("does not report an advisory for update-time repair changes that were not deferred", async () => {
mocks.repairMissingPluginInstallsForIds.mockResolvedValue({
changes: ['Removed stale managed install record for bundled plugin "matrix".'],
warnings: [],
});
const { maybeRunConfiguredPluginInstallReleaseStep } =
await import("./release-configured-plugin-installs.js");
const result = await maybeRunConfiguredPluginInstallReleaseStep({
cfg: {
plugins: {
entries: {
matrix: { enabled: true },
},
},
},
currentVersion: "2026.5.2-beta.1",
touchedVersion: "2026.5.1",
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1",
},
});
expect(result).toEqual({
changes: ['Removed stale managed install record for bundled plugin "matrix".'],
warnings: [],
completed: false,
touchedConfig: false,
});
});
it("defers package-manager plugin release completion for writable legacy parents", async () => {
mocks.repairMissingPluginInstallsForIds.mockResolvedValue({
changes: ['Installed missing configured plugin "discord".'],
changes: [
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
warnings: [],
deferredRepairDetails: [
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
});
const { maybeRunConfiguredPluginInstallReleaseStep } =
@@ -458,10 +508,22 @@ describe("configured plugin install release step", () => {
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1",
});
expect(result).toEqual({
changes: ['Installed missing configured plugin "discord".'],
changes: [
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
warnings: [],
completed: false,
touchedConfig: false,
postInstallDoctorResult: {
status: "advisory",
advisory: expect.objectContaining({
kind: "package-post-install-doctor",
reason: "deferred-configured-plugin-repair",
details: [
'Skipped package-manager repair for configured plugin "discord" during package update; rerun "openclaw doctor --fix" after the update completes.',
],
}),
},
});
});
@@ -8,6 +8,10 @@ import { isChannelConfigured } from "../../../config/channel-configured.js";
import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { compareOpenClawVersions } from "../../../config/version.js";
import {
createDeferredConfiguredPluginRepairDoctorResult,
type UpdatePostInstallDoctorResult,
} from "../../../infra/update-doctor-result.js";
import { getOfficialExternalPluginCatalogEntry } from "../../../plugins/official-external-plugin-catalog.js";
import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js";
import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js";
@@ -341,6 +345,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: {
warnings: string[];
completed: boolean;
touchedConfig: boolean;
postInstallDoctorResult?: UpdatePostInstallDoctorResult;
}> {
const env = params.env ?? process.env;
const updateInProgress = shouldDeferConfiguredPluginInstallRepair(env);
@@ -360,11 +365,17 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: {
blockedPluginIds: collectBlockedPluginIds(params.cfg),
env,
});
const postInstallDoctorResult = createPostInstallDoctorResultForDeferredRepair({
updateInProgress,
details: repaired.deferredRepairDetails ?? [],
warnings: repaired.warnings,
});
return {
changes: repaired.changes,
warnings: repaired.warnings,
completed: repaired.warnings.length === 0,
touchedConfig: false,
...(postInstallDoctorResult ? { postInstallDoctorResult } : {}),
};
}
if (configured.pluginIds.length === 0 && configured.channelIds.length === 0) {
@@ -378,10 +389,27 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: {
env,
});
const completed = repaired.warnings.length === 0 && !updateInProgress;
const postInstallDoctorResult = createPostInstallDoctorResultForDeferredRepair({
updateInProgress,
details: repaired.deferredRepairDetails ?? [],
warnings: repaired.warnings,
});
return {
changes: repaired.changes,
warnings: repaired.warnings,
completed,
touchedConfig: completed,
...(postInstallDoctorResult ? { postInstallDoctorResult } : {}),
};
}
function createPostInstallDoctorResultForDeferredRepair(params: {
updateInProgress: boolean;
details: readonly string[];
warnings: readonly string[];
}): UpdatePostInstallDoctorResult | undefined {
if (!params.updateInProgress || params.warnings.length > 0 || params.details.length === 0) {
return undefined;
}
return createDeferredConfiguredPluginRepairDoctorResult(params.details);
}
+6 -1
View File
@@ -8,6 +8,7 @@ import {
} from "../commands/doctor/shared/update-phase.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { buildGatewayConnectionDetails } from "../gateway/call.js";
import type { UpdatePostInstallDoctorResult } from "../infra/update-doctor-result.js";
import type { RuntimeEnv } from "../runtime.js";
import type { HealthFinding } from "./health-checks.js";
import type { FlowContribution } from "./types.js";
@@ -29,7 +30,7 @@ type DoctorConfigResult = {
preservedLegacyRootKeys?: readonly string[];
};
type DoctorHealthFlowContext = {
export type DoctorHealthFlowContext = {
runtime: RuntimeEnv;
options: DoctorOptions;
prompter: DoctorPrompter;
@@ -45,6 +46,7 @@ type DoctorHealthFlowContext = {
gatewayHealthSkipped?: boolean;
gatewayStatus?: import("../commands/status.types.js").StatusSummary;
gatewayMemoryProbe?: Awaited<ReturnType<typeof probeGatewayMemoryStatus>>;
postInstallDoctorResult?: UpdatePostInstallDoctorResult;
};
type DoctorHealthContribution = FlowContribution & {
@@ -424,6 +426,9 @@ async function runReleaseConfiguredPluginInstallsHealth(
env: ctx.env ?? process.env,
touchedVersion: ctx.configResult.sourceLastTouchedVersion ?? ctx.cfg.meta?.lastTouchedVersion,
});
if (result.postInstallDoctorResult) {
ctx.postInstallDoctorResult = result.postInstallDoctorResult;
}
if (result.changes.length > 0) {
note(result.changes.join("\n"), "Doctor changes");
}
+18 -1
View File
@@ -3,6 +3,7 @@ import { intro as clackIntro, outro as clackOutro } from "@clack/prompts";
import { stylePromptTitle } from "../../packages/terminal-core/src/prompt-style.js";
import type { DoctorOptions } from "../commands/doctor-prompter.js";
import type { RuntimeEnv } from "../runtime.js";
import type { DoctorHealthFlowContext } from "./doctor-health-contributions.js";
// Interactive doctor entrypoint; lazy imports keep normal CLI startup light.
const intro = (message: string) => clackIntro(stylePromptTitle(message) ?? message);
@@ -68,7 +69,7 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions
prompter,
});
const { CONFIG_PATH } = await loadConfigModule();
const ctx = {
const ctx: DoctorHealthFlowContext = {
runtime: effectiveRuntime,
options,
prompter,
@@ -80,6 +81,22 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions
};
const { runDoctorHealthContributions } = await import("./doctor-health-contributions.js");
await runDoctorHealthContributions(ctx);
if (ctx.postInstallDoctorResult) {
const {
UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV,
writeUpdatePostInstallDoctorResult,
} = await import("../infra/update-doctor-result.js");
const resultPath = process.env[UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV]?.trim();
if (resultPath) {
await writeUpdatePostInstallDoctorResult({
resultPath,
result: ctx.postInstallDoctorResult,
});
effectiveRuntime.exit(UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE);
return;
}
}
outro("Doctor complete.");
}
+78
View File
@@ -5,9 +5,14 @@ import { describe, expect, it, vi } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { writePackageDistInventory } from "./package-dist-inventory.js";
import {
markPackagePostInstallDoctorAdvisory,
runGlobalPackageUpdateSteps,
type PackageUpdateStepResult,
} from "./package-update-steps.js";
import {
createDeferredConfiguredPluginRepairDoctorResult,
UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
} from "./update-doctor-result.js";
import {
resolveNpmGlobalPrefixLayoutFromPrefix,
type CommandRunner,
@@ -74,6 +79,79 @@ function createRootRunner(globalRoot: string): CommandRunner {
};
}
describe("markPackagePostInstallDoctorAdvisory", () => {
it("marks only explicit post-install doctor advisory exits", () => {
const step = markPackagePostInstallDoctorAdvisory(
{
exitCode: UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
stderrTail: "doctor deferred repair",
signal: null,
killed: false,
termination: "exit" as const,
},
createDeferredConfiguredPluginRepairDoctorResult(["deferred configured plugin repair"]),
);
expect(step.advisory).toEqual({
kind: "package-post-install-doctor",
message: expect.stringContaining("recoverable update-time repair warning"),
});
expect(step.stderrTail).toContain("doctor deferred repair");
expect(step.stderrTail).toContain("deferred configured plugin repair");
});
it("keeps advisory diagnostics bounded after appending deferred repair details", () => {
const step = markPackagePostInstallDoctorAdvisory(
{
exitCode: UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
stderrTail: "doctor deferred repair",
signal: null,
killed: false,
termination: "exit" as const,
},
createDeferredConfiguredPluginRepairDoctorResult([
`deferred configured plugin repair ${"x".repeat(10_000)}`,
]),
);
expect(step.stderrTail).toHaveLength(8_001);
expect(step.stderrTail).toMatch(/^/u);
expect(step.stderrTail).toContain("recoverable update-time repair warning");
});
it("does not mark unknown nonzero doctor exits as advisory", () => {
const step = markPackagePostInstallDoctorAdvisory(
{
exitCode: 1,
stderrTail: "doctor refused migration",
signal: null,
killed: false,
termination: "exit" as const,
},
null,
);
expect(step.advisory).toBeUndefined();
expect(step.stderrTail).toBe("doctor refused migration");
});
it("does not mark timed-out doctor exits as advisory when they report a code", () => {
const step = markPackagePostInstallDoctorAdvisory(
{
exitCode: 124,
stderrTail: "doctor timed out",
signal: null,
killed: true,
termination: "timeout" as const,
},
createDeferredConfiguredPluginRepairDoctorResult(["deferred configured plugin repair"]),
);
expect(step.advisory).toBeUndefined();
expect(step.stderrTail).toBe("doctor timed out");
});
});
describe("runGlobalPackageUpdateSteps", () => {
it("installs npm updates into a clean staged prefix before swapping the global package", async () => {
await withTempDir({ prefix: "openclaw-package-update-staged-" }, async (base) => {
+69 -4
View File
@@ -5,6 +5,14 @@ import path from "node:path";
import { pathExists } from "./fs-safe.js";
import { readPackageVersion } from "./package-json.js";
import { movePathWithCopyFallback } from "./replace-file.js";
import { trimLogTail } from "./restart-sentinel.js";
import {
PACKAGE_POST_INSTALL_DOCTOR_ADVISORY,
UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
type PackageUpdateStepAdvisory,
type UpdatePostInstallDoctorResult,
} from "./update-doctor-result.js";
export type { PackageUpdateStepAdvisory } from "./update-doctor-result.js";
import {
collectInstalledGlobalPackageErrors,
globalInstallArgs,
@@ -33,6 +41,10 @@ export type PackageUpdateStepResult = {
exitCode: number | null;
stdoutTail?: string | null;
stderrTail?: string | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
termination?: "exit" | "timeout" | "no-output-timeout" | "signal";
advisory?: PackageUpdateStepAdvisory;
};
type PackageUpdateStepRunner = (params: {
@@ -65,6 +77,60 @@ function formatError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function isBlockingPackageUpdateStep(step: PackageUpdateStepResult): boolean {
return step.exitCode !== 0 && step.advisory === undefined;
}
function isNormalProcessExit(step: {
signal?: NodeJS.Signals | null;
killed?: boolean;
termination?: "exit" | "timeout" | "no-output-timeout" | "signal";
}): boolean {
return (
step.termination !== "timeout" &&
step.termination !== "no-output-timeout" &&
step.termination !== "signal" &&
step.killed !== true &&
(step.signal === undefined || step.signal === null)
);
}
export function markPackagePostInstallDoctorAdvisory<
T extends {
exitCode: number | null;
stderrTail?: string | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
termination?: "exit" | "timeout" | "no-output-timeout" | "signal";
advisory?: PackageUpdateStepAdvisory;
},
>(
step: T,
result: UpdatePostInstallDoctorResult | null,
): T & {
advisory?: PackageUpdateStepAdvisory;
} {
if (
step.exitCode !== UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE ||
result?.status !== "advisory" ||
!isNormalProcessExit(step)
) {
return step;
}
const advisoryTail = [
step.stderrTail,
...result.advisory.details,
PACKAGE_POST_INSTALL_DOCTOR_ADVISORY.message,
]
.filter((line): line is string => Boolean(line?.trim()))
.join("\n");
return {
...step,
advisory: PACKAGE_POST_INSTALL_DOCTOR_ADVISORY,
stderrTail: trimLogTail(advisoryTail) ?? step.stderrTail,
};
}
async function removePathBestEffort(targetPath: string): Promise<boolean> {
try {
await fs.rm(targetPath, {
@@ -682,10 +748,9 @@ export async function runGlobalPackageUpdateSteps(params: {
}
}
const failedStep =
finalInstallStep.exitCode !== 0
? finalInstallStep
: (steps.find((step) => step !== updateStep && step.exitCode !== 0) ?? null);
const failedStep = isBlockingPackageUpdateStep(finalInstallStep)
? finalInstallStep
: (steps.find((step) => step !== updateStep && isBlockingPackageUpdateStep(step)) ?? null);
return {
steps,
+90
View File
@@ -0,0 +1,90 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
import {
consumeUpdatePostInstallDoctorResult,
createDeferredConfiguredPluginRepairDoctorResult,
createUpdatePostInstallDoctorResultPath,
writeUpdatePostInstallDoctorResult,
} from "./update-doctor-result.js";
const resultPaths: string[] = [];
afterEach(async () => {
await Promise.all(resultPaths.splice(0).map((resultPath) => fs.rm(resultPath, { force: true })));
});
describe("post-install doctor result IPC", () => {
it("round-trips typed advisory results and consumes the file", async () => {
const resultPath = createUpdatePostInstallDoctorResultPath();
resultPaths.push(resultPath);
const result = createDeferredConfiguredPluginRepairDoctorResult([
"deferred configured plugin repair",
]);
await writeUpdatePostInstallDoctorResult({ resultPath, result });
await expect(consumeUpdatePostInstallDoctorResult(resultPath)).resolves.toEqual(result);
await expect(fs.access(resultPath)).rejects.toThrow();
});
it("rejects result paths outside the secure OpenClaw temp root", async () => {
const tempRoot = resolvePreferredOpenClawTmpDir();
const resultPath = path.join(
`${tempRoot}-outside`,
`openclaw-update-doctor-${process.pid}-00000000-0000-4000-8000-000000000000.json`,
);
await expect(
writeUpdatePostInstallDoctorResult({
resultPath,
result: createDeferredConfiguredPluginRepairDoctorResult(["deferred repair"]),
}),
).rejects.toThrow("Unsafe post-install doctor result path");
await expect(fs.access(resultPath)).rejects.toThrow();
});
it("accepts newer child advisory copy and normalizes it to the parent copy", async () => {
const resultPath = createUpdatePostInstallDoctorResultPath();
resultPaths.push(resultPath);
await fs.writeFile(
resultPath,
JSON.stringify({
status: "advisory",
advisory: {
kind: "package-post-install-doctor",
reason: "deferred-configured-plugin-repair",
message: "newer child advisory wording",
details: ["deferred repair"],
},
}),
{ encoding: "utf8", mode: 0o600, flag: "wx" },
);
await expect(consumeUpdatePostInstallDoctorResult(resultPath)).resolves.toEqual(
createDeferredConfiguredPluginRepairDoctorResult(["deferred repair"]),
);
});
it("rejects malformed advisory payloads and consumes the file", async () => {
const resultPath = createUpdatePostInstallDoctorResultPath();
resultPaths.push(resultPath);
await fs.writeFile(
resultPath,
JSON.stringify({
status: "advisory",
advisory: {
kind: "package-post-install-doctor",
reason: "deferred-configured-plugin-repair",
message: "forged advisory",
details: [],
},
}),
{ encoding: "utf8", mode: 0o600, flag: "wx" },
);
await expect(consumeUpdatePostInstallDoctorResult(resultPath)).resolves.toBeNull();
await expect(fs.access(resultPath)).rejects.toThrow();
});
});
+129
View File
@@ -0,0 +1,129 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
// IPC contract between package update parents and the post-install doctor child.
export const UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV =
"OPENCLAW_UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH";
export const UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE = 86;
const UPDATE_POST_INSTALL_DOCTOR_RESULT_FILENAME_RE =
/^openclaw-update-doctor-\d+-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.json$/iu;
export type PackageUpdateStepAdvisory = {
kind: "package-post-install-doctor";
message: string;
};
export const PACKAGE_POST_INSTALL_DOCTOR_ADVISORY: PackageUpdateStepAdvisory = {
kind: "package-post-install-doctor",
message:
"Post-install doctor reported a recoverable update-time repair warning after the package install was verified; continuing with post-core plugin convergence.",
};
export type UpdatePostInstallDoctorResult = {
status: "advisory";
advisory: PackageUpdateStepAdvisory & {
reason: "deferred-configured-plugin-repair";
details: string[];
};
};
export function createUpdatePostInstallDoctorResultPath(): string {
return path.join(
resolvePreferredOpenClawTmpDir(),
`openclaw-update-doctor-${process.pid}-${randomUUID()}.json`,
);
}
function resolveSafeUpdatePostInstallDoctorResultPath(resultPath: string): string {
const tempRoot = path.resolve(resolvePreferredOpenClawTmpDir());
const resolvedPath = path.resolve(resultPath);
if (
path.dirname(resolvedPath) !== tempRoot ||
!UPDATE_POST_INSTALL_DOCTOR_RESULT_FILENAME_RE.test(path.basename(resolvedPath))
) {
throw new Error("Unsafe post-install doctor result path");
}
return resolvedPath;
}
export function createDeferredConfiguredPluginRepairDoctorResult(
details: readonly string[],
): UpdatePostInstallDoctorResult {
return {
status: "advisory",
advisory: {
...PACKAGE_POST_INSTALL_DOCTOR_ADVISORY,
reason: "deferred-configured-plugin-repair",
details: details.filter((line) => line.trim()),
},
};
}
export async function writeUpdatePostInstallDoctorResult(params: {
resultPath: string;
result: UpdatePostInstallDoctorResult;
}): Promise<void> {
const resultPath = resolveSafeUpdatePostInstallDoctorResultPath(params.resultPath);
// Advisory details can contain config-derived IDs; pre-existing paths must fail closed.
await fs.writeFile(resultPath, `${JSON.stringify(params.result)}\n`, {
encoding: "utf8",
mode: 0o600,
flag: "wx",
});
}
export async function consumeUpdatePostInstallDoctorResult(
resultPath: string,
): Promise<UpdatePostInstallDoctorResult | null> {
let safeResultPath: string;
try {
safeResultPath = resolveSafeUpdatePostInstallDoctorResultPath(resultPath);
} catch {
return null;
}
try {
const raw = await fs.readFile(safeResultPath, "utf8");
return parseUpdatePostInstallDoctorResult(JSON.parse(raw));
} catch {
return null;
} finally {
await fs.rm(safeResultPath, { force: true }).catch(() => {});
}
}
function parseUpdatePostInstallDoctorResult(value: unknown): UpdatePostInstallDoctorResult | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (record.status !== "advisory") {
return null;
}
const advisory = record.advisory;
if (!advisory || typeof advisory !== "object") {
return null;
}
const advisoryRecord = advisory as Record<string, unknown>;
const details = advisoryRecord.details;
if (
advisoryRecord.kind !== "package-post-install-doctor" ||
advisoryRecord.reason !== "deferred-configured-plugin-repair" ||
typeof advisoryRecord.message !== "string" ||
!Array.isArray(details) ||
details.length === 0 ||
!details.every((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
) {
return null;
}
return {
status: "advisory",
advisory: {
kind: "package-post-install-doctor",
reason: "deferred-configured-plugin-repair",
message: PACKAGE_POST_INSTALL_DOCTOR_ADVISORY.message,
details,
},
};
}
+8 -1
View File
@@ -27,7 +27,14 @@ export type GlobalInstallManager = "npm" | "pnpm" | "bun";
export type CommandRunner = (
argv: string[],
options: { timeoutMs: number; cwd?: string; env?: NodeJS.ProcessEnv },
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
) => Promise<{
stdout: string;
stderr: string;
code: number | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
termination?: "exit" | "timeout" | "no-output-timeout" | "signal";
}>;
type ResolvedGlobalInstallCommand = {
manager: GlobalInstallManager;
+29 -3
View File
@@ -14,7 +14,10 @@ import {
} from "./control-ui-assets.js";
import { readPackageName, readPackageVersion } from "./package-json.js";
import { normalizePackageTagInput } from "./package-tag.js";
import { runGlobalPackageUpdateSteps } from "./package-update-steps.js";
import {
runGlobalPackageUpdateSteps,
type PackageUpdateStepAdvisory,
} from "./package-update-steps.js";
import { trimLogTail } from "./restart-sentinel.js";
import { resolveStableNodePath } from "./stable-node-path.js";
import {
@@ -42,6 +45,8 @@ import {
type UpdatePackageManagerFailureReason,
} from "./update-package-manager.js";
export type UpdateStepAdvisory = PackageUpdateStepAdvisory;
export type UpdateStepResult = {
name: string;
command: string;
@@ -50,6 +55,10 @@ export type UpdateStepResult = {
exitCode: number | null;
stdoutTail?: string | null;
stderrTail?: string | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
termination?: "exit" | "timeout" | "no-output-timeout" | "signal";
advisory?: UpdateStepAdvisory;
};
export type UpdateRunResult = {
@@ -113,7 +122,14 @@ export type UpdateRunResult = {
type CommandRunner = (
argv: string[],
options: CommandOptions,
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
) => Promise<{
stdout: string;
stderr: string;
code: number | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
termination?: "exit" | "timeout" | "no-output-timeout" | "signal";
}>;
export type UpdateStepInfo = {
name: string;
@@ -126,6 +142,10 @@ export type UpdateStepCompletion = UpdateStepInfo & {
durationMs: number;
exitCode: number | null;
stderrTail?: string | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
termination?: "exit" | "timeout" | "no-output-timeout" | "signal";
advisory?: UpdateStepAdvisory;
};
export type UpdateStepProgress = {
@@ -427,6 +447,9 @@ async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
durationMs,
exitCode: result.code,
stderrTail,
signal: result.signal,
killed: result.killed,
termination: result.termination,
});
return {
@@ -437,6 +460,9 @@ async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
exitCode: result.code,
stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS),
stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS),
signal: result.signal,
killed: result.killed,
termination: result.termination,
};
}
@@ -688,7 +714,7 @@ async function buildUpdateCommandRunner(
...options,
env: mergeCommandEnvironments(defaultCommandEnv, options.env),
});
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
return res;
},
};
}