diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f0f5d0893..ddbbf6aca52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras. - Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write. - Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so `openclaw update` no longer dies early on missing `git` or `node-llama-cpp` download setup. +- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. - Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed `write` no longer reports success while creating empty files. (#43876) Thanks @glitch418x. - Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc. - Hooks/loader: fail closed when workspace hook paths cannot be resolved with `realpath`, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc. diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index e755a241375..e40d798604d 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -41,6 +41,7 @@ Current caveats: - `openclaw onboard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health` - `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first - if Scheduled Task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately +- if `schtasks` itself wedges or stops responding, OpenClaw now aborts that path quickly and falls back instead of hanging forever - Scheduled Tasks are still preferred when available because they provide better supervisor status If you want the native CLI only, without gateway service install, use one of these: diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 54c5ef7e704..704c193880c 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -236,7 +236,8 @@ describe("buildGatewayInstallPlan", () => { describe("gatewayInstallErrorHint", () => { it("returns platform-specific hints", () => { - expect(gatewayInstallErrorHint("win32")).toContain("Run as administrator"); + expect(gatewayInstallErrorHint("win32")).toContain("Startup-folder login item"); + expect(gatewayInstallErrorHint("win32")).toContain("elevated PowerShell"); expect(gatewayInstallErrorHint("linux")).toMatch( /(?:openclaw|openclaw)( --profile isolated)? gateway install/, ); diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 68b78630ffe..7a3bd42e2fc 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -69,6 +69,6 @@ export async function buildGatewayInstallPlan(params: { export function gatewayInstallErrorHint(platform = process.platform): string { return platform === "win32" - ? "Tip: rerun from an elevated PowerShell (Start → type PowerShell → right-click → Run as administrator) or skip service install." + ? "Tip: native Windows now falls back to a per-user Startup-folder login item when Scheduled Task creation is denied; if install still fails, rerun from an elevated PowerShell or skip service install." : `Tip: rerun \`${formatCliCommand("openclaw gateway install")}\` after fixing the error.`; } diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index d6292c015e8..03145ff8703 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -118,7 +118,7 @@ export async function runNonInteractiveOnboardingLocal(params: { "Non-interactive local onboarding only waits for an already-running gateway unless you pass --install-daemon.", `Fix: start \`${formatCliCommand("openclaw gateway run")}\`, re-run with \`--install-daemon\`, or use \`--skip-health\`.`, process.platform === "win32" - ? "Native Windows managed gateway install currently uses Scheduled Tasks and may require running PowerShell as Administrator." + ? "Native Windows managed gateway install tries Scheduled Tasks first and falls back to a per-user Startup-folder login item when task creation is denied." : undefined, ] .filter(Boolean) diff --git a/src/daemon/schtasks-exec.test.ts b/src/daemon/schtasks-exec.test.ts new file mode 100644 index 00000000000..52edb573ea7 --- /dev/null +++ b/src/daemon/schtasks-exec.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runCommandWithTimeout = vi.hoisted(() => vi.fn()); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeout(...args), +})); + +const { execSchtasks } = await import("./schtasks-exec.js"); + +beforeEach(() => { + runCommandWithTimeout.mockReset(); +}); + +describe("execSchtasks", () => { + it("runs schtasks with bounded timeouts", async () => { + runCommandWithTimeout.mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }); + + await expect(execSchtasks(["/Query"])).resolves.toEqual({ + stdout: "ok", + stderr: "", + code: 0, + }); + expect(runCommandWithTimeout).toHaveBeenCalledWith(["schtasks", "/Query"], { + timeoutMs: 15_000, + noOutputTimeoutMs: 5_000, + }); + }); + + it("maps a timeout into a non-zero schtasks result", async () => { + runCommandWithTimeout.mockResolvedValue({ + stdout: "", + stderr: "", + code: null, + signal: "SIGTERM", + killed: true, + termination: "timeout", + }); + + await expect(execSchtasks(["/Create"])).resolves.toEqual({ + stdout: "", + stderr: "schtasks timed out after 15000ms", + code: 124, + }); + }); +}); diff --git a/src/daemon/schtasks-exec.ts b/src/daemon/schtasks-exec.ts index e4344d3cd5d..cf27d927341 100644 --- a/src/daemon/schtasks-exec.ts +++ b/src/daemon/schtasks-exec.ts @@ -1,7 +1,24 @@ -import { execFileUtf8 } from "./exec-file.js"; +import { runCommandWithTimeout } from "../process/exec.js"; + +const SCHTASKS_TIMEOUT_MS = 15_000; +const SCHTASKS_NO_OUTPUT_TIMEOUT_MS = 5_000; export async function execSchtasks( args: string[], ): Promise<{ stdout: string; stderr: string; code: number }> { - return await execFileUtf8("schtasks", args, { windowsHide: true }); + const result = await runCommandWithTimeout(["schtasks", ...args], { + timeoutMs: SCHTASKS_TIMEOUT_MS, + noOutputTimeoutMs: SCHTASKS_NO_OUTPUT_TIMEOUT_MS, + }); + const timeoutDetail = + result.termination === "timeout" + ? `schtasks timed out after ${SCHTASKS_TIMEOUT_MS}ms` + : result.termination === "no-output-timeout" + ? `schtasks produced no output for ${SCHTASKS_NO_OUTPUT_TIMEOUT_MS}ms` + : ""; + return { + stdout: result.stdout, + stderr: result.stderr || timeoutDetail, + code: typeof result.code === "number" ? result.code : result.killed ? 124 : 1, + }; } diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index 0bf27dc1028..73b49b633d5 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { quoteCmdScriptArg } from "./cmd-argv.js"; const schtasksResponses = vi.hoisted( () => [] as Array<{ code: number; stdout: string; stderr: string }>, @@ -10,7 +11,8 @@ const schtasksResponses = vi.hoisted( const schtasksCalls = vi.hoisted(() => [] as string[][]); const inspectPortUsage = vi.hoisted(() => vi.fn()); const killProcessTree = vi.hoisted(() => vi.fn()); -const runCommandWithTimeout = vi.hoisted(() => vi.fn()); +const childUnref = vi.hoisted(() => vi.fn()); +const spawn = vi.hoisted(() => vi.fn(() => ({ unref: childUnref }))); vi.mock("./schtasks-exec.js", () => ({ execSchtasks: async (argv: string[]) => { @@ -27,8 +29,8 @@ vi.mock("../process/kill-tree.js", () => ({ killProcessTree: (...args: unknown[]) => killProcessTree(...args), })); -vi.mock("../process/exec.js", () => ({ - runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeout(...args), +vi.mock("node:child_process", () => ({ + spawn: (...args: unknown[]) => spawn(...args), })); const { @@ -73,15 +75,8 @@ beforeEach(() => { schtasksCalls.length = 0; inspectPortUsage.mockReset(); killProcessTree.mockReset(); - runCommandWithTimeout.mockReset(); - runCommandWithTimeout.mockResolvedValue({ - stdout: "", - stderr: "", - code: 0, - signal: null, - killed: false, - termination: "exit", - }); + spawn.mockClear(); + childUnref.mockClear(); }); afterEach(() => { @@ -114,14 +109,40 @@ describe("Windows startup fallback", () => { expect(result.scriptPath).toBe(resolveTaskScriptPath(env)); expect(startupScript).toContain('start "" /min cmd.exe /d /c'); expect(startupScript).toContain("gateway.cmd"); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - ["cmd.exe", "/d", "/s", "/c", startupEntryPath], - expect.objectContaining({ timeoutMs: 3000, windowsVerbatimArguments: true }), + expect(spawn).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], + expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), ); + expect(childUnref).toHaveBeenCalled(); expect(printed).toContain("Installed Windows login item"); }); }); + it("falls back to a Startup-folder launcher when schtasks create hangs", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 124, stdout: "", stderr: "schtasks timed out after 15000ms" }, + ); + + const stdout = new PassThrough(); + await installScheduledTask({ + env, + stdout, + programArguments: ["node", "gateway.js", "--port", "18789"], + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + + await expect(fs.access(resolveStartupEntryPath(env))).resolves.toBeUndefined(); + expect(spawn).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], + expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), + ); + }); + }); + it("treats an installed Startup-folder launcher as loaded", async () => { await withWindowsEnv(async ({ env }) => { schtasksResponses.push( @@ -179,7 +200,11 @@ describe("Windows startup fallback", () => { outcome: "completed", }); expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); - expect(runCommandWithTimeout).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], + expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), + ); }); }); }); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 8e5b60786c5..2c74cf26a61 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -1,7 +1,7 @@ +import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { inspectPortUsage } from "../infra/ports.js"; -import { runCommandWithTimeout } from "../process/exec.js"; import { killProcessTree } from "../process/kill-tree.js"; import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; import { assertNoCmdLineBreak, parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; @@ -30,6 +30,15 @@ function resolveTaskName(env: GatewayServiceEnv): string { return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE); } +function shouldFallbackToStartupEntry(params: { code: number; detail: string }): boolean { + return ( + /access is denied/i.test(params.detail) || + params.code === 124 || + /schtasks timed out/i.test(params.detail) || + /schtasks produced no output/i.test(params.detail) + ); +} + export function resolveTaskScriptPath(env: GatewayServiceEnv): string { const override = env.OPENCLAW_TASK_SCRIPT?.trim(); if (override) { @@ -284,12 +293,13 @@ async function isRegisteredScheduledTask(env: GatewayServiceEnv): Promise { - const startupEntryPath = resolveStartupEntryPath(env); - await runCommandWithTimeout(["cmd.exe", "/d", "/s", "/c", startupEntryPath], { - timeoutMs: 3000, - windowsVerbatimArguments: true, +function launchFallbackTaskScript(scriptPath: string): void { + const child = spawn("cmd.exe", ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)], { + detached: true, + stdio: "ignore", + windowsHide: true, }); + child.unref(); } function resolveConfiguredGatewayPort(env: GatewayServiceEnv): number | null { @@ -346,7 +356,7 @@ async function restartStartupEntry( if (typeof runtime.pid === "number" && runtime.pid > 0) { killProcessTree(runtime.pid, { graceMs: 300 }); } - await launchStartupEntry(env); + launchFallbackTaskScript(resolveTaskScriptPath(env)); stdout.write(`${formatLine("Restarted Windows login item", resolveTaskName(env))}\n`); return { outcome: "completed" }; } @@ -394,12 +404,12 @@ export async function installScheduledTask({ } if (create.code !== 0) { const detail = create.stderr || create.stdout; - if (/access is denied/i.test(detail)) { + if (shouldFallbackToStartupEntry({ code: create.code, detail })) { const startupEntryPath = resolveStartupEntryPath(env); await fs.mkdir(path.dirname(startupEntryPath), { recursive: true }); const launcher = buildStartupLauncherScript({ description: taskDescription, scriptPath }); await fs.writeFile(startupEntryPath, launcher, "utf8"); - await launchStartupEntry(env); + launchFallbackTaskScript(scriptPath); writeFormattedLines( stdout, [