mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
parent
889bc52ba5
commit
7259cb5c77
@@ -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");
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user