Skip to content

Commit aec0c56

Browse files
committed
fix(update): harden legacy package handoff
1 parent b278098 commit aec0c56

19 files changed

Lines changed: 361 additions & 27 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
8383
- CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like `0.0.0.0`. Fixes #83286. Thanks @giodl73-repo.
8484
- CLI/plugins: ship the bundled memory CLI as a package entry so package-installed `openclaw memory` commands register correctly.
8585
- CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.
86+
- CLI/update: preserve old-parent-readable config metadata during legacy package handoffs, fall back to npm when ClawHub package artifacts are unavailable, and keep managed service package roots authoritative during updates.
8687
- Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.
8788
- Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.
8889
- CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.

src/cli/update-cli/update-command.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "../../daemon/const
3232
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
3333
import { disableCurrentOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
3434
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
35+
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
3536
import {
3637
readGatewayServiceState,
3738
resolveGatewayService,
@@ -1208,6 +1209,35 @@ async function tryInstallShellCompletion(opts: {
12081209
}
12091210
}
12101211

1212+
async function tryRealpathOrResolve(value: string): Promise<string> {
1213+
try {
1214+
return await fs.realpath(path.resolve(value));
1215+
} catch {
1216+
return path.resolve(value);
1217+
}
1218+
}
1219+
1220+
async function resolveManagedServicePackageUpdateRoot(params: {
1221+
root: string;
1222+
}): Promise<{ root: string; previousRoot: string } | null> {
1223+
const command = await resolveGatewayService()
1224+
.readCommand(process.env)
1225+
.catch(() => null);
1226+
const layout = await summarizeGatewayServiceLayout(command);
1227+
const serviceRoot = layout?.packageRoot;
1228+
if (!serviceRoot || layout.entrypointSourceCheckout === true) {
1229+
return null;
1230+
}
1231+
const [currentRootReal, serviceRootReal] = await Promise.all([
1232+
tryRealpathOrResolve(params.root),
1233+
tryRealpathOrResolve(serviceRoot),
1234+
]);
1235+
if (currentRootReal === serviceRootReal) {
1236+
return null;
1237+
}
1238+
return { root: serviceRoot, previousRoot: params.root };
1239+
}
1240+
12111241
async function runPackageInstallUpdate(params: {
12121242
root: string;
12131243
installKind: "git" | "package" | "unknown";
@@ -1218,6 +1248,7 @@ async function runPackageInstallUpdate(params: {
12181248
jsonMode: boolean;
12191249
managedServiceEnv?: NodeJS.ProcessEnv;
12201250
invocationCwd?: string;
1251+
honorPackageRoot?: boolean;
12211252
}): Promise<UpdateRunResult> {
12221253
const manager = await resolveGlobalManager({
12231254
root: params.root,
@@ -1231,6 +1262,7 @@ async function runPackageInstallUpdate(params: {
12311262
runCommand,
12321263
timeoutMs: params.timeoutMs,
12331264
pkgRoot: params.root,
1265+
honorPackageRoot: params.honorPackageRoot === true,
12341266
});
12351267
const pkgRoot = installTarget.packageRoot;
12361268
const packageName =
@@ -2722,7 +2754,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
27222754
}
27232755
const updateStepTimeoutMs = timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS;
27242756

2725-
const root = await resolveUpdateRoot();
2757+
let root = await resolveUpdateRoot();
27262758
if (postCoreUpdateResume) {
27272759
if (
27282760
postCoreUpdateChannel !== "stable" &&
@@ -2852,6 +2884,21 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
28522884
let fallbackToLatest = false;
28532885
let packageInstallSpec: string | null = null;
28542886
let packageAlreadyCurrent = false;
2887+
let managedServiceRootRedirect: { root: string; previousRoot: string } | null = null;
2888+
2889+
if (updateInstallKind === "package") {
2890+
managedServiceRootRedirect = await resolveManagedServicePackageUpdateRoot({ root });
2891+
if (managedServiceRootRedirect) {
2892+
root = managedServiceRootRedirect.root;
2893+
if (!opts.json) {
2894+
defaultRuntime.log(
2895+
theme.muted(
2896+
`Targeting managed gateway service package root: ${managedServiceRootRedirect.root}`,
2897+
),
2898+
);
2899+
}
2900+
}
2901+
}
28552902

28562903
if (updateInstallKind !== "git") {
28572904
currentVersion = switchToPackage ? null : await readPackageVersion(root);
@@ -2929,6 +2976,11 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
29292976
if (fallbackToLatest) {
29302977
notes.push("Beta channel resolves to latest for this run (fallback).");
29312978
}
2979+
if (managedServiceRootRedirect) {
2980+
notes.push(
2981+
`Package update targets managed service root ${managedServiceRootRedirect.root} instead of invoking root ${managedServiceRootRedirect.previousRoot}.`,
2982+
);
2983+
}
29322984
if (explicitTag && !canResolveRegistryVersionForPackageTarget(tag)) {
29332985
notes.push("Non-registry package specs skip npm version lookup and downgrade previews.");
29342986
}
@@ -3062,6 +3114,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
30623114
jsonMode: Boolean(opts.json),
30633115
managedServiceEnv: prePackageServiceStop?.serviceEnv,
30643116
invocationCwd,
3117+
honorPackageRoot: managedServiceRootRedirect !== null,
30653118
})
30663119
: await runGitUpdate({
30673120
root,

src/commands/doctor/shared/missing-configured-plugin-install.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ vi.mock("../../../plugins/clawhub.js", () => ({
116116
CLAWHUB_INSTALL_ERROR_CODE: {
117117
PACKAGE_NOT_FOUND: "package_not_found",
118118
VERSION_NOT_FOUND: "version_not_found",
119+
ARTIFACT_DOWNLOAD_UNAVAILABLE: "artifact_download_unavailable",
119120
},
120121
installPluginFromClawHub: mocks.installPluginFromClawHub,
121122
}));

src/commands/doctor/shared/missing-configured-plugin-install.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ const REPAIRABLE_PACKAGE_ENTRY_DIAGNOSTIC_MARKERS = [
8383
function shouldFallbackClawHubToNpm(result: { ok: false; code?: string }): boolean {
8484
return (
8585
result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
86-
result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND
86+
result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND ||
87+
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_DOWNLOAD_UNAVAILABLE
8788
);
8889
}
8990

src/commands/doctor/shared/release-configured-plugin-installs.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ describe("configured plugin install release step", () => {
374374
});
375375
});
376376

377-
it("defers package-manager plugin repair when an older updater supports post-doctor config writes", async () => {
377+
it("repairs package-manager plugins for legacy parents that only support doctor config writes", async () => {
378378
mocks.repairMissingPluginInstallsForIds.mockResolvedValue({
379379
changes: [],
380380
warnings: [],
@@ -405,8 +405,8 @@ describe("configured plugin install release step", () => {
405405
expect(result).toEqual({
406406
changes: [],
407407
warnings: [],
408-
completed: false,
409-
touchedConfig: false,
408+
completed: true,
409+
touchedConfig: true,
410410
});
411411
});
412412

src/commands/doctor/shared/update-phase.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
66
UPDATE_POST_CORE_CONVERGENCE_ENV,
77
isLegacyPackageUpdateDoctorPass,
8+
isLegacyParentWritableUpdateDoctorPass,
89
isPostCoreConvergencePass,
910
isUpdatePackageSwapInProgress,
1011
shouldDeferConfiguredPluginInstallRepair,
@@ -52,7 +53,7 @@ describe("update-phase env helpers", () => {
5253
[UPDATE_IN_PROGRESS_ENV]: "1",
5354
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
5455
}),
55-
).toBe(true);
56+
).toBe(false);
5657
expect(
5758
shouldDeferConfiguredPluginInstallRepair({
5859
[UPDATE_IN_PROGRESS_ENV]: "1",
@@ -79,12 +80,34 @@ describe("update-phase env helpers", () => {
7980
[UPDATE_IN_PROGRESS_ENV]: "1",
8081
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
8182
}),
82-
).toBe(false);
83+
).toBe(true);
8384
expect(
8485
isLegacyPackageUpdateDoctorPass({
8586
[UPDATE_IN_PROGRESS_ENV]: "1",
8687
[UPDATE_POST_CORE_CONVERGENCE_ENV]: "1",
8788
}),
8889
).toBe(false);
8990
});
91+
92+
it("identifies writable legacy parents that need old-readable config writes", () => {
93+
expect(
94+
isLegacyParentWritableUpdateDoctorPass({
95+
[UPDATE_IN_PROGRESS_ENV]: "1",
96+
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
97+
}),
98+
).toBe(true);
99+
expect(
100+
isLegacyParentWritableUpdateDoctorPass({
101+
[UPDATE_IN_PROGRESS_ENV]: "1",
102+
[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1",
103+
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
104+
}),
105+
).toBe(false);
106+
expect(
107+
isLegacyParentWritableUpdateDoctorPass({
108+
[UPDATE_POST_CORE_CONVERGENCE_ENV]: "1",
109+
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
110+
}),
111+
).toBe(false);
112+
});
90113
});

src/commands/doctor/shared/update-phase.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,21 @@ export function isUpdatePackageSwapInProgress(env: NodeJS.ProcessEnv): boolean {
4747
export function shouldDeferConfiguredPluginInstallRepair(env: NodeJS.ProcessEnv): boolean {
4848
return (
4949
isUpdatePackageSwapInProgress(env) &&
50-
(isTruthyEnvValue(env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]) ||
51-
isTruthyEnvValue(env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]))
50+
isTruthyEnvValue(env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV])
51+
);
52+
}
53+
54+
/**
55+
* True iff a new doctor is running inside a shipped parent that can persist
56+
* doctor repairs but does not understand the newer explicit deferral marker.
57+
* These writes must stay old-parent-readable because that parent resumes after
58+
* the candidate doctor exits.
59+
*/
60+
export function isLegacyParentWritableUpdateDoctorPass(env: NodeJS.ProcessEnv): boolean {
61+
return (
62+
isUpdatePackageSwapInProgress(env) &&
63+
isTruthyEnvValue(env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]) &&
64+
!isTruthyEnvValue(env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV])
5265
);
5366
}
5467

src/commands/onboarding-plugin-install.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ vi.mock("../plugins/clawhub.js", () => ({
4848
CLAWHUB_INSTALL_ERROR_CODE: {
4949
PACKAGE_NOT_FOUND: "package_not_found",
5050
VERSION_NOT_FOUND: "version_not_found",
51+
ARTIFACT_DOWNLOAD_UNAVAILABLE: "artifact_download_unavailable",
5152
},
5253
installPluginFromClawHub,
5354
}));

src/commands/onboarding-plugin-install.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export type OnboardingPluginInstallResult = {
6565
function shouldFallbackClawHubToNpm(result: { ok: false; code?: string }): boolean {
6666
return (
6767
result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
68-
result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND
68+
result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND ||
69+
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_DOWNLOAD_UNAVAILABLE
6970
);
7071
}
7172

src/config/io.meta.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ export const AUTO_MANAGED_CONFIG_META_PATHS = [
1414
export function stampConfigWriteMetadata(
1515
cfg: OpenClawConfig,
1616
now: string = new Date().toISOString(),
17+
version: string = VERSION,
1718
): OpenClawConfig {
1819
return {
1920
...cfg,
2021
meta: {
2122
...cfg.meta,
22-
[AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedVersion]: VERSION,
23+
[AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedVersion]: version,
2324
[AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedAt]: now,
2425
},
2526
};

0 commit comments

Comments
 (0)