From 433e65711f78492e961e5da678de91175b4f151b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 03:18:17 +0000 Subject: [PATCH] fix: fall back to a startup entry for windows gateway install --- CHANGELOG.md | 1 + docs/cli/onboard.md | 2 +- docs/platforms/windows.md | 14 +- src/daemon/schtasks.startup-fallback.test.ts | 185 ++++++++++++++++ src/daemon/schtasks.ts | 214 +++++++++++++++++-- 5 files changed, 400 insertions(+), 16 deletions(-) create mode 100644 src/daemon/schtasks.startup-fallback.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d84d3cd..2992b5a29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai - Windows/update: mirror the native installer environment during global npm updates, including portable Git fallback and Windows-safe npm shell settings, so `openclaw update` works again on native Windows installs. - Gateway/status: expose `runtimeVersion` in gateway status output so install/update smoke tests can verify the running version before and after updates. - Windows/onboarding: explain when non-interactive local onboarding is waiting for an already-running gateway, and surface native Scheduled Task admin requirements more clearly instead of failing with an opaque gateway timeout. +- Windows/gateway install: fall back from denied Scheduled Task creation to a per-user Startup-folder login item, so native `openclaw gateway install` and `--install-daemon` keep working without an elevated PowerShell shell. - Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<|...|>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern. - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 6eed344ee..4b30e0d52 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -100,7 +100,7 @@ Non-interactive local gateway health: - Unless you pass `--skip-health`, onboarding waits for a reachable local gateway before it exits successfully. - `--install-daemon` starts the managed gateway install path first. Without it, you must already have a local gateway running, for example `openclaw gateway run`. - If you only want config/workspace/bootstrap writes in automation, use `--skip-health`. -- On native Windows, `--install-daemon` currently uses Scheduled Tasks and may require running PowerShell as Administrator. +- On native Windows, `--install-daemon` tries Scheduled Tasks first and falls back to a per-user Startup-folder login item if task creation is denied. Interactive onboarding behavior with reference mode: diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index e6c46368f..e755a2413 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -39,8 +39,9 @@ openclaw agent --local --agent main --thinking low -m "Reply with exactly WINDOW 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` currently use Windows Scheduled Tasks -- on some native Windows setups, Scheduled Task install may require running PowerShell as Administrator +- `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 +- 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: @@ -49,6 +50,15 @@ openclaw onboard --non-interactive --skip-health openclaw gateway run ``` +If you do want managed startup on native Windows: + +```powershell +openclaw gateway install +openclaw gateway status --json +``` + +If Scheduled Task creation is blocked, the fallback service mode still auto-starts after login through the current user's Startup folder. + ## Gateway - [Gateway runbook](/gateway) diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts new file mode 100644 index 000000000..0bf27dc10 --- /dev/null +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -0,0 +1,185 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const schtasksResponses = vi.hoisted( + () => [] as Array<{ code: number; stdout: string; stderr: string }>, +); +const schtasksCalls = vi.hoisted(() => [] as string[][]); +const inspectPortUsage = vi.hoisted(() => vi.fn()); +const killProcessTree = vi.hoisted(() => vi.fn()); +const runCommandWithTimeout = vi.hoisted(() => vi.fn()); + +vi.mock("./schtasks-exec.js", () => ({ + execSchtasks: async (argv: string[]) => { + schtasksCalls.push(argv); + return schtasksResponses.shift() ?? { code: 0, stdout: "", stderr: "" }; + }, +})); + +vi.mock("../infra/ports.js", () => ({ + inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args), +})); + +vi.mock("../process/kill-tree.js", () => ({ + killProcessTree: (...args: unknown[]) => killProcessTree(...args), +})); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeout(...args), +})); + +const { + installScheduledTask, + isScheduledTaskInstalled, + readScheduledTaskRuntime, + restartScheduledTask, + resolveTaskScriptPath, +} = await import("./schtasks.js"); + +function resolveStartupEntryPath(env: Record) { + return path.join( + env.APPDATA, + "Microsoft", + "Windows", + "Start Menu", + "Programs", + "Startup", + "OpenClaw Gateway.cmd", + ); +} + +async function withWindowsEnv( + run: (params: { tmpDir: string; env: Record }) => Promise, +) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-win-startup-")); + const env = { + USERPROFILE: tmpDir, + APPDATA: path.join(tmpDir, "AppData", "Roaming"), + OPENCLAW_PROFILE: "default", + OPENCLAW_GATEWAY_PORT: "18789", + }; + try { + await run({ tmpDir, env }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +beforeEach(() => { + schtasksResponses.length = 0; + schtasksCalls.length = 0; + inspectPortUsage.mockReset(); + killProcessTree.mockReset(); + runCommandWithTimeout.mockReset(); + runCommandWithTimeout.mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Windows startup fallback", () => { + it("falls back to a Startup-folder launcher when schtasks create is denied", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 5, stdout: "", stderr: "ERROR: Access is denied." }, + ); + + const stdout = new PassThrough(); + let printed = ""; + stdout.on("data", (chunk) => { + printed += String(chunk); + }); + + const result = await installScheduledTask({ + env, + stdout, + programArguments: ["node", "gateway.js", "--port", "18789"], + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + + const startupEntryPath = resolveStartupEntryPath(env); + const startupScript = await fs.readFile(startupEntryPath, "utf8"); + 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(printed).toContain("Installed Windows login item"); + }); + }); + + it("treats an installed Startup-folder launcher as loaded", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + ); + await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); + await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + + await expect(isScheduledTaskInstalled({ env })).resolves.toBe(true); + }); + }); + + it("reports runtime from the gateway listener when using the Startup fallback", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + ); + await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); + await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [{ pid: 4242, command: "node.exe" }], + hints: [], + }); + + await expect(readScheduledTaskRuntime(env)).resolves.toMatchObject({ + status: "running", + pid: 4242, + }); + }); + }); + + it("restarts the Startup fallback by killing the current pid and relaunching the entry", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "not found" }, + ); + await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true }); + await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8"); + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [{ pid: 5151, command: "node.exe" }], + hints: [], + }); + + const stdout = new PassThrough(); + await expect(restartScheduledTask({ env, stdout })).resolves.toEqual({ + outcome: "completed", + }); + expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); + expect(runCommandWithTimeout).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index ddca704f6..8e5b60786 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -1,5 +1,8 @@ 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"; import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; @@ -37,6 +40,36 @@ export function resolveTaskScriptPath(env: GatewayServiceEnv): string { return path.join(stateDir, scriptName); } +function resolveWindowsStartupDir(env: GatewayServiceEnv): string { + const appData = env.APPDATA?.trim(); + if (appData) { + return path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup"); + } + const home = env.USERPROFILE?.trim() || env.HOME?.trim(); + if (!home) { + throw new Error("Windows startup folder unavailable: APPDATA/USERPROFILE not set"); + } + return path.join( + home, + "AppData", + "Roaming", + "Microsoft", + "Windows", + "Start Menu", + "Programs", + "Startup", + ); +} + +function sanitizeWindowsFilename(value: string): string { + return value.replace(/[<>:"/\\|?*]/g, "_").replace(/\p{Cc}/gu, "_"); +} + +function resolveStartupEntryPath(env: GatewayServiceEnv): string { + const taskName = resolveTaskName(env); + return path.join(resolveWindowsStartupDir(env), `${sanitizeWindowsFilename(taskName)}.cmd`); +} + // `/TR` is parsed by schtasks itself, while the generated `gateway.cmd` line is parsed by cmd.exe. // Keep their quoting strategies separate so each parser gets the encoding it expects. function quoteSchtasksArg(value: string): string { @@ -103,6 +136,7 @@ export async function readScheduledTaskCommand( programArguments: parseCmdScriptCommandLine(commandLine), ...(workingDirectory ? { workingDirectory } : {}), ...(Object.keys(environment).length > 0 ? { environment } : {}), + sourcePath: scriptPath, }; } catch { return null; @@ -211,6 +245,17 @@ function buildTaskScript({ return `${lines.join("\r\n")}\r\n`; } +function buildStartupLauncherScript(params: { description?: string; scriptPath: string }): string { + const lines = ["@echo off"]; + const trimmedDescription = params.description?.trim(); + if (trimmedDescription) { + assertNoCmdLineBreak(trimmedDescription, "Startup launcher description"); + lines.push(`rem ${trimmedDescription}`); + } + lines.push(`start "" /min cmd.exe /d /c ${quoteCmdScriptArg(params.scriptPath)}`); + return `${lines.join("\r\n")}\r\n`; +} + async function assertSchtasksAvailable() { const res = await execSchtasks(["/Query"]); if (res.code === 0) { @@ -220,6 +265,92 @@ async function assertSchtasksAvailable() { throw new Error(`schtasks unavailable: ${detail || "unknown error"}`.trim()); } +async function isStartupEntryInstalled(env: GatewayServiceEnv): Promise { + try { + await fs.access(resolveStartupEntryPath(env)); + return true; + } catch { + return false; + } +} + +async function isRegisteredScheduledTask(env: GatewayServiceEnv): Promise { + const taskName = resolveTaskName(env); + const res = await execSchtasks(["/Query", "/TN", taskName]).catch(() => ({ + code: 1, + stdout: "", + stderr: "", + })); + return res.code === 0; +} + +async function launchStartupEntry(env: GatewayServiceEnv): Promise { + const startupEntryPath = resolveStartupEntryPath(env); + await runCommandWithTimeout(["cmd.exe", "/d", "/s", "/c", startupEntryPath], { + timeoutMs: 3000, + windowsVerbatimArguments: true, + }); +} + +function resolveConfiguredGatewayPort(env: GatewayServiceEnv): number | null { + const raw = env.OPENCLAW_GATEWAY_PORT?.trim(); + if (!raw) { + return null; + } + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +async function resolveFallbackRuntime(env: GatewayServiceEnv): Promise { + const port = resolveConfiguredGatewayPort(env); + if (!port) { + return { + status: "unknown", + detail: "Startup-folder login item installed; gateway port unknown.", + }; + } + const diagnostics = await inspectPortUsage(port).catch(() => null); + if (!diagnostics) { + return { + status: "unknown", + detail: `Startup-folder login item installed; could not inspect port ${port}.`, + }; + } + const listener = diagnostics.listeners.find((item) => typeof item.pid === "number"); + return { + status: diagnostics.status === "busy" ? "running" : "stopped", + ...(listener?.pid ? { pid: listener.pid } : {}), + detail: + diagnostics.status === "busy" + ? `Startup-folder login item installed; listener detected on port ${port}.` + : `Startup-folder login item installed; no listener detected on port ${port}.`, + }; +} + +async function stopStartupEntry( + env: GatewayServiceEnv, + stdout: NodeJS.WritableStream, +): Promise { + const runtime = await resolveFallbackRuntime(env); + if (typeof runtime.pid === "number" && runtime.pid > 0) { + killProcessTree(runtime.pid, { graceMs: 300 }); + } + stdout.write(`${formatLine("Stopped Windows login item", resolveTaskName(env))}\n`); +} + +async function restartStartupEntry( + env: GatewayServiceEnv, + stdout: NodeJS.WritableStream, +): Promise { + const runtime = await resolveFallbackRuntime(env); + if (typeof runtime.pid === "number" && runtime.pid > 0) { + killProcessTree(runtime.pid, { graceMs: 300 }); + } + await launchStartupEntry(env); + stdout.write(`${formatLine("Restarted Windows login item", resolveTaskName(env))}\n`); + return { outcome: "completed" }; +} + export async function installScheduledTask({ env, stdout, @@ -263,10 +394,23 @@ export async function installScheduledTask({ } if (create.code !== 0) { const detail = create.stderr || create.stdout; - const hint = /access is denied/i.test(detail) - ? " Run PowerShell as Administrator or rerun without installing the daemon." - : ""; - throw new Error(`schtasks create failed: ${detail}${hint}`.trim()); + if (/access is denied/i.test(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); + writeFormattedLines( + stdout, + [ + { label: "Installed Windows login item", value: startupEntryPath }, + { label: "Task script", value: scriptPath }, + ], + { leadingBlankLine: true }, + ); + return { scriptPath }; + } + throw new Error(`schtasks create failed: ${detail}`.trim()); } await execSchtasks(["/Run", "/TN", taskName]); @@ -288,7 +432,16 @@ export async function uninstallScheduledTask({ }: GatewayServiceManageArgs): Promise { await assertSchtasksAvailable(); const taskName = resolveTaskName(env); - await execSchtasks(["/Delete", "/F", "/TN", taskName]); + const taskInstalled = await isRegisteredScheduledTask(env).catch(() => false); + if (taskInstalled) { + await execSchtasks(["/Delete", "/F", "/TN", taskName]); + } + + const startupEntryPath = resolveStartupEntryPath(env); + try { + await fs.unlink(startupEntryPath); + stdout.write(`${formatLine("Removed Windows login item", startupEntryPath)}\n`); + } catch {} const scriptPath = resolveTaskScriptPath(env); try { @@ -305,8 +458,23 @@ function isTaskNotRunning(res: { stdout: string; stderr: string; code: number }) } export async function stopScheduledTask({ stdout, env }: GatewayServiceControlArgs): Promise { - await assertSchtasksAvailable(); - const taskName = resolveTaskName(env ?? (process.env as GatewayServiceEnv)); + const effectiveEnv = env ?? (process.env as GatewayServiceEnv); + try { + await assertSchtasksAvailable(); + } catch (err) { + if (await isStartupEntryInstalled(effectiveEnv)) { + await stopStartupEntry(effectiveEnv, stdout); + return; + } + throw err; + } + if (!(await isRegisteredScheduledTask(effectiveEnv))) { + if (await isStartupEntryInstalled(effectiveEnv)) { + await stopStartupEntry(effectiveEnv, stdout); + return; + } + } + const taskName = resolveTaskName(effectiveEnv); const res = await execSchtasks(["/End", "/TN", taskName]); if (res.code !== 0 && !isTaskNotRunning(res)) { throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim()); @@ -318,8 +486,21 @@ export async function restartScheduledTask({ stdout, env, }: GatewayServiceControlArgs): Promise { - await assertSchtasksAvailable(); - const taskName = resolveTaskName(env ?? (process.env as GatewayServiceEnv)); + const effectiveEnv = env ?? (process.env as GatewayServiceEnv); + try { + await assertSchtasksAvailable(); + } catch (err) { + if (await isStartupEntryInstalled(effectiveEnv)) { + return await restartStartupEntry(effectiveEnv, stdout); + } + throw err; + } + if (!(await isRegisteredScheduledTask(effectiveEnv))) { + if (await isStartupEntryInstalled(effectiveEnv)) { + return await restartStartupEntry(effectiveEnv, stdout); + } + } + const taskName = resolveTaskName(effectiveEnv); await execSchtasks(["/End", "/TN", taskName]); const res = await execSchtasks(["/Run", "/TN", taskName]); if (res.code !== 0) { @@ -330,10 +511,11 @@ export async function restartScheduledTask({ } export async function isScheduledTaskInstalled(args: GatewayServiceEnvArgs): Promise { - await assertSchtasksAvailable(); - const taskName = resolveTaskName(args.env ?? (process.env as GatewayServiceEnv)); - const res = await execSchtasks(["/Query", "/TN", taskName]); - return res.code === 0; + const effectiveEnv = args.env ?? (process.env as GatewayServiceEnv); + if (await isRegisteredScheduledTask(effectiveEnv)) { + return true; + } + return await isStartupEntryInstalled(effectiveEnv); } export async function readScheduledTaskRuntime( @@ -342,6 +524,9 @@ export async function readScheduledTaskRuntime( try { await assertSchtasksAvailable(); } catch (err) { + if (await isStartupEntryInstalled(env)) { + return await resolveFallbackRuntime(env); + } return { status: "unknown", detail: String(err), @@ -350,6 +535,9 @@ export async function readScheduledTaskRuntime( const taskName = resolveTaskName(env); const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]); if (res.code !== 0) { + if (await isStartupEntryInstalled(env)) { + return await resolveFallbackRuntime(env); + } const detail = (res.stderr || res.stdout).trim(); const missing = detail.toLowerCase().includes("cannot find the file"); return {