Skip to content

Commit 11f564e

Browse files
committed
fix(windows): preserve recovered gateway tokens
1 parent 2b5e01e commit 11f564e

4 files changed

Lines changed: 216 additions & 31 deletions

File tree

src/commands/doctor-gateway-services.test.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js"
120120
const originalStdinIsTTY = process.stdin.isTTY;
121121
const originalPlatform = process.platform;
122122
const originalUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
123+
const originalParentSupportsConfigWrite =
124+
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE;
123125

124126
function makeDoctorIo() {
125127
return { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
@@ -353,6 +355,12 @@ describe("maybeRepairGatewayServiceConfig", () => {
353355
} else {
354356
process.env.OPENCLAW_UPDATE_IN_PROGRESS = originalUpdateInProgress;
355357
}
358+
if (originalParentSupportsConfigWrite === undefined) {
359+
delete process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE;
360+
} else {
361+
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE =
362+
originalParentSupportsConfigWrite;
363+
}
356364
});
357365

358366
it("treats gateway.auth.token as source of truth for service token repairs", async () => {
@@ -914,6 +922,7 @@ describe("maybeRepairGatewayServiceConfig", () => {
914922
configurable: true,
915923
});
916924
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
925+
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE = "1";
917926

918927
await withEnvAsync(
919928
{
@@ -990,6 +999,7 @@ describe("maybeRepairGatewayServiceConfig", () => {
990999
configurable: true,
9911000
});
9921001
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
1002+
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE = "1";
9931003

9941004
await withEnvAsync(
9951005
{
@@ -1027,16 +1037,67 @@ describe("maybeRepairGatewayServiceConfig", () => {
10271037
);
10281038
expectGatewayAuthToken(replaceOptions.nextConfig, "stale-token");
10291039
expect(replaceOptions.afterWrite).toEqual({ mode: "auto" });
1030-
expect(replaceOptions.writeOptions).toEqual({
1031-
lastTouchedVersionOverride: "2026.5.14",
1032-
});
1040+
expect(replaceOptions.writeOptions).toEqual(
1041+
expect.objectContaining({
1042+
allowConfigSizeDrop: true,
1043+
skipPluginValidation: true,
1044+
lastTouchedVersionOverride: "2026.5.14",
1045+
}),
1046+
);
10331047
expectCallConfigGatewayAuthToken(mocks.buildGatewayInstallPlan, "stale-token");
10341048
expect(mocks.stage).not.toHaveBeenCalled();
10351049
expect(mocks.install).toHaveBeenCalledTimes(1);
10361050
},
10371051
);
10381052
});
10391053

1054+
it("leaves embedded service tokens untouched during legacy Windows update handoffs", async () => {
1055+
mockProcessPlatform("win32");
1056+
Object.defineProperty(process.stdin, "isTTY", {
1057+
value: false,
1058+
configurable: true,
1059+
});
1060+
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
1061+
delete process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE;
1062+
1063+
await withEnvAsync(
1064+
{
1065+
OPENCLAW_GATEWAY_TOKEN: undefined,
1066+
},
1067+
async () => {
1068+
mocks.readCommand.mockResolvedValue({
1069+
programArguments: gatewayProgramArguments,
1070+
environment: {
1071+
OPENCLAW_GATEWAY_TOKEN: "stale-token",
1072+
OPENCLAW_SERVICE_VERSION: "2026.5.25",
1073+
},
1074+
});
1075+
mocks.auditGatewayServiceConfig.mockResolvedValue({
1076+
ok: true,
1077+
issues: [],
1078+
});
1079+
mocks.buildGatewayInstallPlan.mockResolvedValue({
1080+
programArguments: gatewayProgramArguments,
1081+
workingDirectory: "/tmp",
1082+
environment: {
1083+
OPENCLAW_SERVICE_VERSION: "2026.5.26",
1084+
},
1085+
});
1086+
mocks.readRuntime.mockResolvedValue({ status: "running" });
1087+
1088+
await runNonInteractiveRepair({ updateInProgress: true });
1089+
1090+
expectNoteContaining(
1091+
"Legacy update parent cannot persist gateway.auth.token before service repair",
1092+
"Gateway",
1093+
);
1094+
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
1095+
expect(mocks.stage).not.toHaveBeenCalled();
1096+
expect(mocks.install).not.toHaveBeenCalled();
1097+
},
1098+
);
1099+
});
1100+
10401101
it("stages stopped Windows update repairs without activating the gateway", async () => {
10411102
mockProcessPlatform("win32");
10421103
Object.defineProperty(process.stdin, "isTTY", {

src/commands/doctor-gateway-services.ts

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
uninstallLegacySystemdUnits,
3333
type SystemdUnitScope,
3434
} from "../daemon/systemd.js";
35+
import { isTruthyEnvValue } from "../infra/env.js";
3536
import type { RuntimeEnv } from "../runtime.js";
3637
import { VERSION } from "../version.js";
3738
import { buildGatewayInstallPlan } from "./daemon-install-helpers.js";
@@ -45,6 +46,25 @@ import {
4546
isServiceRepairExternallyManaged,
4647
resolveServiceRepairPolicy,
4748
} from "./doctor-service-repair-policy.js";
49+
import {
50+
UPDATE_IN_PROGRESS_ENV,
51+
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
52+
} from "./doctor/shared/update-phase.js";
53+
54+
type GatewayServiceConfigRepairOptions = {
55+
allowConfigSizeDrop?: boolean;
56+
allowExecSecretRefs?: boolean;
57+
lastTouchedVersionOverride?: string;
58+
preservedLegacyRootKeys?: readonly string[];
59+
skipPluginValidation?: boolean;
60+
};
61+
62+
function shouldSkipLegacyUpdateRepairConfigWrite(env: NodeJS.ProcessEnv): boolean {
63+
return (
64+
isTruthyEnvValue(env[UPDATE_IN_PROGRESS_ENV]) &&
65+
!isTruthyEnvValue(env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV])
66+
);
67+
}
4868

4969
const execFileAsync = promisify(execFile);
5070
const EXECSTART_REPAIR_CODES = new Set<string>([
@@ -366,16 +386,16 @@ export async function maybeRepairGatewayServiceConfig(
366386
mode: "local" | "remote",
367387
runtime: RuntimeEnv,
368388
prompter: DoctorPrompter,
369-
options: { allowExecSecretRefs?: boolean; lastTouchedVersionOverride?: string } = {},
370-
) {
389+
options: GatewayServiceConfigRepairOptions = {},
390+
): Promise<OpenClawConfig> {
371391
if (resolveIsNixMode(process.env)) {
372392
note("Nix mode detected; skip service updates.", "Gateway");
373-
return;
393+
return cfg;
374394
}
375395

376396
if (mode === "remote") {
377397
note("Gateway mode is remote; skipped local service audit.", "Gateway");
378-
return;
398+
return cfg;
379399
}
380400

381401
const service = resolveGatewayService();
@@ -386,7 +406,7 @@ export async function maybeRepairGatewayServiceConfig(
386406
command = null;
387407
}
388408
if (!command) {
389-
return;
409+
return cfg;
390410
}
391411
const serviceInstallEnv = buildGatewayServiceRepairEnv(command);
392412
const serviceWrapperPath = resolveGatewayServiceWrapperPath(command);
@@ -514,7 +534,7 @@ export async function maybeRepairGatewayServiceConfig(
514534
if (sourceCheckoutWarning !== null && !hasEntrypointMismatch) {
515535
note(sourceCheckoutWarning, "Gateway service config");
516536
}
517-
return;
537+
return cfg;
518538
}
519539

520540
const serviceRepairPolicy = resolveServiceRepairPolicy();
@@ -546,15 +566,15 @@ export async function maybeRepairGatewayServiceConfig(
546566

547567
if (serviceRepairExternal) {
548568
note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway service config");
549-
return;
569+
return cfg;
550570
}
551571

552572
if (serviceRewriteBlocked) {
553573
note(
554574
"Gateway service is running; leaving supervisor metadata unchanged. Stop the service first or use `openclaw gateway install --force` when you want to replace the active launcher.",
555575
"Gateway service config",
556576
);
557-
return;
577+
return cfg;
558578
}
559579

560580
const updateRepairMode = isDoctorUpdateRepairMode(prompter.repairMode);
@@ -568,7 +588,7 @@ export async function maybeRepairGatewayServiceConfig(
568588
"Update-mode doctor detected gateway service drift but left the live systemd unit unchanged. Review the service file and run `openclaw gateway install --force` when you want OpenClaw to replace operator-owned systemd directives.",
569589
"Gateway service config",
570590
);
571-
return;
591+
return cfg;
572592
}
573593

574594
const repairMessage = needsAggressive
@@ -596,7 +616,7 @@ export async function maybeRepairGatewayServiceConfig(
596616
"Gateway service config",
597617
);
598618
}
599-
return;
619+
return cfg;
600620
}
601621
const serviceEmbeddedToken = readEmbeddedGatewayToken(command);
602622
const gatewayTokenForRepair = expectedGatewayToken ?? serviceEmbeddedToken;
@@ -614,6 +634,16 @@ export async function maybeRepairGatewayServiceConfig(
614634
!configuredGatewayToken &&
615635
gatewayTokenForRepair
616636
) {
637+
if (
638+
updateRepairWillRewriteWindowsTask &&
639+
shouldSkipLegacyUpdateRepairConfigWrite(process.env)
640+
) {
641+
note(
642+
"Legacy update parent cannot persist gateway.auth.token before service repair; leaving the existing gateway service unchanged.",
643+
"Gateway",
644+
);
645+
return cfg;
646+
}
617647
const nextCfg: OpenClawConfig = {
618648
...cfg,
619649
gateway: {
@@ -629,13 +659,14 @@ export async function maybeRepairGatewayServiceConfig(
629659
await replaceConfigFile({
630660
nextConfig: nextCfg,
631661
afterWrite: { mode: "auto" },
632-
...(options.lastTouchedVersionOverride
633-
? {
634-
writeOptions: {
635-
lastTouchedVersionOverride: options.lastTouchedVersionOverride,
636-
},
637-
}
638-
: {}),
662+
writeOptions: {
663+
allowConfigSizeDrop: options.allowConfigSizeDrop === true || updateRepairMode,
664+
skipPluginValidation: options.skipPluginValidation === true || updateRepairMode,
665+
preservedLegacyRootKeys: options.preservedLegacyRootKeys,
666+
...(options.lastTouchedVersionOverride
667+
? { lastTouchedVersionOverride: options.lastTouchedVersionOverride }
668+
: {}),
669+
},
639670
});
640671
cfgForServiceInstall = nextCfg;
641672
note(
@@ -646,7 +677,7 @@ export async function maybeRepairGatewayServiceConfig(
646677
);
647678
} catch (err) {
648679
runtime.error(`Failed to persist gateway.auth.token before service repair: ${String(err)}`);
649-
return;
680+
return cfg;
650681
}
651682
}
652683

@@ -681,6 +712,7 @@ export async function maybeRepairGatewayServiceConfig(
681712
} catch (err) {
682713
runtime.error(`Gateway service update failed: ${String(err)}`);
683714
}
715+
return cfgForServiceInstall;
684716
}
685717

686718
export async function maybeScanExtraGatewayServices(

0 commit comments

Comments
 (0)