fix(update): tolerate legacy doctor metadata handoff

This commit is contained in:
Peter Steinberger
2026-04-29 04:15:23 +01:00
parent 6e31de5847
commit 7475b27887
8 changed files with 104 additions and 11 deletions

View File

@@ -106,7 +106,7 @@ Options:
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
Default: openai/gpt-5.5 for the OpenAI lane
Default: openai/gpt-5.4 for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
@@ -209,7 +209,7 @@ case "$PROVIDER" in
openai)
AUTH_CHOICE="openai-api-key"
AUTH_KEY_FLAG="openai-api-key"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4}"
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
;;
anthropic)

View File

@@ -144,7 +144,7 @@ Options:
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
Default: openai/gpt-5.5 for the OpenAI lane
Default: openai/gpt-5.4 for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
@@ -266,7 +266,7 @@ case "$PROVIDER" in
openai)
AUTH_CHOICE="openai-api-key"
AUTH_KEY_FLAG="openai-api-key"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4}"
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
;;
anthropic)

View File

@@ -122,7 +122,7 @@ Options:
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for agent-turn smoke checks.
Default: openai/gpt-5.5 for the OpenAI lane
Default: openai/gpt-5.4 for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
@@ -214,7 +214,7 @@ case "$PROVIDER" in
openai)
AUTH_CHOICE="openai-api-key"
AUTH_KEY_FLAG="openai-api-key"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4}"
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
;;
anthropic)

View File

@@ -140,7 +140,7 @@ Options:
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
Default: openai/gpt-5.5 for the OpenAI lane
Default: openai/gpt-5.4 for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
@@ -257,7 +257,7 @@ case "$PROVIDER" in
openai)
AUTH_CHOICE="openai-api-key"
AUTH_KEY_FLAG="openai-api-key"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4}"
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
;;
anthropic)

View File

@@ -94,6 +94,8 @@ const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE";
const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
const SERVICE_REFRESH_PATH_ENV_KEYS = [
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
@@ -569,6 +571,7 @@ async function runPackageInstallUpdate(params: {
env: {
...disableUpdatedPackageCompileCacheEnv(process.env),
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
},
timeoutMs: params.timeoutMs,
progress: params.progress,
@@ -1030,6 +1033,7 @@ async function maybeRestartService(params: {
defaultRuntime.log(theme.success("Daemon restarted successfully."));
defaultRuntime.log("");
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1";
try {
const interactiveDoctor =
process.stdin.isTTY && !params.opts.json && params.opts.yes !== true;
@@ -1040,6 +1044,7 @@ async function maybeRestartService(params: {
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
} finally {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
}
}
} catch (err) {

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { resolveDoctorHealthContributions } from "./doctor-health-contributions.js";
import {
resolveDoctorHealthContributions,
shouldSkipLegacyUpdateDoctorMetadataWrite,
} from "./doctor-health-contributions.js";
describe("doctor health contributions", () => {
it("repairs bundled runtime deps before channel-owned doctor paths can import runtimes", () => {
@@ -20,11 +23,47 @@ describe("doctor health contributions", () => {
expect(ids.indexOf("doctor:plugin-registry")).toBeGreaterThan(-1);
expect(ids.indexOf("doctor:plugin-registry")).toBeLessThan(ids.indexOf("doctor:write-config"));
});
it("checks command owner configuration before final config writes", () => {
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
expect(ids.indexOf("doctor:command-owner")).toBeGreaterThan(-1);
expect(ids.indexOf("doctor:command-owner")).toBeLessThan(ids.indexOf("doctor:write-config"));
});
it("skips metadata-only doctor writes under legacy update parents", () => {
expect(
shouldSkipLegacyUpdateDoctorMetadataWrite({
env: { OPENCLAW_UPDATE_IN_PROGRESS: "1" },
before: { gateway: { mode: "local" }, meta: { lastTouchedVersion: "2026.4.26" } },
after: {
gateway: { mode: "local" },
meta: { lastTouchedVersion: "2026.4.27" },
wizard: { lastRunCommand: "doctor" },
},
}),
).toBe(true);
});
it("keeps real doctor repairs writable during update", () => {
expect(
shouldSkipLegacyUpdateDoctorMetadataWrite({
env: { OPENCLAW_UPDATE_IN_PROGRESS: "1" },
before: { gateway: { mode: "local" } },
after: { gateway: { mode: "remote" } },
}),
).toBe(false);
});
it("keeps current update parents writable", () => {
expect(
shouldSkipLegacyUpdateDoctorMetadataWrite({
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1",
},
before: { meta: { lastTouchedVersion: "2026.4.26" } },
after: { meta: { lastTouchedVersion: "2026.4.27" } },
}),
).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import type { probeGatewayMemoryStatus } from "../commands/doctor-gateway-health.js";
import type { DoctorOptions, DoctorPrompter } from "../commands/doctor-prompter.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -39,6 +40,39 @@ export function resolveDoctorMode(cfg: OpenClawConfig): DoctorFlowMode {
return cfg.gateway?.mode === "remote" ? "remote" : "local";
}
const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
function isTruthyEnvValue(value: string | undefined): boolean {
if (!value) {
return false;
}
const normalized = value.trim().toLowerCase();
return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no";
}
function omitDoctorWriteMetadata(cfg: OpenClawConfig): OpenClawConfig {
const { meta: _meta, wizard: _wizard, ...rest } = cfg;
return rest;
}
export function shouldSkipLegacyUpdateDoctorMetadataWrite(params: {
env: NodeJS.ProcessEnv;
before: OpenClawConfig;
after: OpenClawConfig;
}): boolean {
if (!isTruthyEnvValue(params.env.OPENCLAW_UPDATE_IN_PROGRESS)) {
return false;
}
if (isTruthyEnvValue(params.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV])) {
return false;
}
return isDeepStrictEqual(
omitDoctorWriteMetadata(params.before),
omitDoctorWriteMetadata(params.after),
);
}
function createDoctorHealthContribution(params: {
id: string;
label: string;
@@ -495,6 +529,16 @@ async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise<void>
command: "doctor",
mode: resolveDoctorMode(ctx.cfg),
});
if (
shouldSkipLegacyUpdateDoctorMetadataWrite({
env: ctx.env,
before: ctx.cfgForPersistence,
after: ctx.cfg,
})
) {
ctx.runtime.log("Skipping doctor metadata-only config write during legacy update handoff.");
return;
}
await replaceConfigFile({
nextConfig: ctx.cfg,
afterWrite: { mode: "auto" },

View File

@@ -161,6 +161,8 @@ const MAX_LOG_CHARS = 8000;
const PREFLIGHT_MAX_COMMITS = 10;
const DEFAULT_PACKAGE_NAME = "openclaw";
const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]);
const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
const PREFLIGHT_TEMP_PREFIX =
process.platform === "win32" ? "ocu-pf-" : "openclaw-update-preflight-";
const PREFLIGHT_WORKTREE_DIRNAME = process.platform === "win32" ? "wt" : "worktree";
@@ -1292,7 +1294,10 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const doctorNodePath = await resolveStableNodePath(process.execPath);
const doctorArgv = [doctorNodePath, doctorEntry, "doctor", "--non-interactive", "--fix"];
const doctorStep = await runStep(
step("openclaw doctor", doctorArgv, gitRoot, { OPENCLAW_UPDATE_IN_PROGRESS: "1" }),
step("openclaw doctor", doctorArgv, gitRoot, {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
}),
);
steps.push(doctorStep);
if (doctorStep.exitCode !== 0) {