Skip to content

Commit 601ce90

Browse files
[AI] fix(update-cli): warn+continue on Windows schtasks access-denied
When schtasks /Change /DISABLE fails with a Windows access-denied error (reported in localized text such as 'ERROR: Access is denied.', '错误: 拒绝访问。', or 'Zugriff verweigert'), the package update no longer hard-exits. The task remains in its prior enabled state and the update proceeds with a warning telling the user to run 'openclaw gateway restart' from an elevated shell if the gateway fails to restart after the update. Other schtasks disable failures (timeouts, malformed output, etc.) stay fatal so real environmental problems are not masked. Related to #111756
1 parent 8d5ad80 commit 601ce90

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

src/cli/update-cli.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4318,6 +4318,79 @@ describe("update-cli", () => {
43184318
},
43194319
);
43204320

4321+
it.each([
4322+
{ label: "en", detail: "ERROR: Access is denied." },
4323+
{ label: "zh", detail: "错误: 拒绝访问。" },
4324+
{ label: "de", detail: "Zugriff verweigert" },
4325+
])(
4326+
"continues a no-restart package update when Windows Scheduled Task disable reports access denied ($label)",
4327+
async ({ detail }) => {
4328+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
4329+
mockPackageInstallStatus(createCaseDir("openclaw-update-task-access-denied"));
4330+
serviceReadCommand.mockResolvedValue({
4331+
programArguments: ["openclaw", "gateway", "run"],
4332+
environment: {
4333+
OPENCLAW_SERVICE_MARKER: "openclaw",
4334+
OPENCLAW_SERVICE_KIND: "gateway",
4335+
},
4336+
});
4337+
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
4338+
suspendScheduledTaskAutoStartForUpdate.mockRejectedValueOnce(
4339+
new Error(`schtasks disable failed: ${detail}`),
4340+
);
4341+
4342+
await updateCommand({ yes: true, restart: false });
4343+
platformSpy.mockRestore();
4344+
4345+
// Hard-fail path is bypassed: update proceeds with package install and
4346+
// never reaches the access-restricted exit branch.
4347+
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
4348+
expect(packageInstallCommandCall()).toBeDefined();
4349+
// The task was never actually suspended, so the resume path must not run.
4350+
expect(resumeScheduledTaskAutoStartAfterUpdate).not.toHaveBeenCalled();
4351+
const warnLog = vi
4352+
.mocked(defaultRuntime.log)
4353+
.mock.calls.map((call) => String(call[0]))
4354+
.find((line) =>
4355+
line.includes("Could not disable the Windows Scheduled Task before update"),
4356+
);
4357+
expect(warnLog).toBeDefined();
4358+
expect(warnLog).toContain(detail);
4359+
expect(warnLog).toContain("openclaw gateway restart");
4360+
},
4361+
);
4362+
4363+
it("still hard-fails a no-restart package update when Windows Scheduled Task disable fails for a non-access reason", async () => {
4364+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
4365+
mockPackageInstallStatus(createCaseDir("openclaw-update-task-non-access-failure"));
4366+
serviceReadCommand.mockResolvedValue({
4367+
programArguments: ["openclaw", "gateway", "run"],
4368+
environment: {
4369+
OPENCLAW_SERVICE_MARKER: "openclaw",
4370+
OPENCLAW_SERVICE_KIND: "gateway",
4371+
},
4372+
});
4373+
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
4374+
suspendScheduledTaskAutoStartForUpdate.mockRejectedValueOnce(
4375+
new Error("schtasks disable failed: schtasks timed out after 15000ms"),
4376+
);
4377+
4378+
await updateCommand({ yes: true, restart: false });
4379+
platformSpy.mockRestore();
4380+
4381+
// Non-access failures stay fatal: exit(1) is invoked, the package install
4382+
// never runs, and the resume path stays untouched.
4383+
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
4384+
expect(packageInstallCommandCall()).toBeUndefined();
4385+
expect(resumeScheduledTaskAutoStartAfterUpdate).not.toHaveBeenCalled();
4386+
const errorLog = vi
4387+
.mocked(defaultRuntime.error)
4388+
.mock.calls.map((call) => String(call[0]))
4389+
.find((line) => line.includes("Failed to stop managed gateway service before update"));
4390+
expect(errorLog).toBeDefined();
4391+
expect(errorLog).toContain("schtasks timed out after 15000ms");
4392+
});
4393+
43214394
it("stops a running managed gateway when git checkout rebuild starts", async () => {
43224395
const serviceEntrypoint = path.join(process.cwd(), "dist", "index.js");
43234396
serviceReadCommand.mockResolvedValue({

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,24 @@ async function abortWindowsTaskUpdateIfInterrupted(
429429
throw new UpdateCommandAbort();
430430
}
431431

432+
// Windows schtasks /Change /DISABLE surfaces access-denied failures with
433+
// locale-localized text. The patterns below cover the localizations observed
434+
// in issue reports and existing tests so we can degrade to a warn+continue path
435+
// instead of blocking the update with a hard exit. Add new locales here when a
436+
// real report surfaces them; do not speculatively expand the list.
437+
const WINDOWS_ACCESS_DENIED_PATTERNS: readonly RegExp[] = [
438+
/access(?:\s+is)?\s+denied/iu,
439+
/访/u,
440+
/zugriff\s+verweigert/iu,
441+
];
442+
443+
function isWindowsAccessDeniedError(err: unknown): boolean {
444+
if (!(err instanceof Error)) {
445+
return false;
446+
}
447+
return WINDOWS_ACCESS_DENIED_PATTERNS.some((pattern) => pattern.test(err.message));
448+
}
449+
432450
async function maybeSuspendWindowsTaskAutoStartForPackageUpdate(params: {
433451
updateInstallKind: "git" | "package";
434452
serviceEnv: NodeJS.ProcessEnv | undefined;
@@ -447,6 +465,20 @@ async function maybeSuspendWindowsTaskAutoStartForPackageUpdate(params: {
447465
} catch (err) {
448466
await recovery.restore().catch(() => undefined);
449467
recovery.complete();
468+
// A user-permission gap on /Change /DISABLE (task owned by another identity
469+
// or an elevated shell required) leaves the task in its prior enabled state.
470+
// Let the package update proceed; warn the user that the gateway may need a
471+
// manual restart from an elevated shell after the update. Other schtasks
472+
// failures stay fatal so real environmental problems are not masked.
473+
if (isWindowsAccessDeniedError(err)) {
474+
const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME);
475+
defaultRuntime.log(
476+
theme.warn(
477+
`Could not disable the Windows Scheduled Task before update (${String(err)}); leaving it enabled and continuing. Run \`${restartCommand}\` from an elevated shell if the gateway fails to restart after update.`,
478+
),
479+
);
480+
return undefined;
481+
}
450482
throw err;
451483
}
452484
await abortWindowsTaskUpdateIfInterrupted(recovery);

0 commit comments

Comments
 (0)