fix: harden windows gateway fallback launch

This commit is contained in:
Peter Steinberger
2026-03-13 04:58:20 +00:00
parent 6d0939d84e
commit 32d8ec9482
9 changed files with 138 additions and 30 deletions

View File

@@ -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. - 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. - 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/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. - 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. - 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. - 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.

View File

@@ -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` 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 - `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 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 - 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: If you want the native CLI only, without gateway service install, use one of these:

View File

@@ -236,7 +236,8 @@ describe("buildGatewayInstallPlan", () => {
describe("gatewayInstallErrorHint", () => { describe("gatewayInstallErrorHint", () => {
it("returns platform-specific hints", () => { 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( expect(gatewayInstallErrorHint("linux")).toMatch(
/(?:openclaw|openclaw)( --profile isolated)? gateway install/, /(?:openclaw|openclaw)( --profile isolated)? gateway install/,
); );

View File

@@ -69,6 +69,6 @@ export async function buildGatewayInstallPlan(params: {
export function gatewayInstallErrorHint(platform = process.platform): string { export function gatewayInstallErrorHint(platform = process.platform): string {
return platform === "win32" 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.`; : `Tip: rerun \`${formatCliCommand("openclaw gateway install")}\` after fixing the error.`;
} }

View File

@@ -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.", "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\`.`, `Fix: start \`${formatCliCommand("openclaw gateway run")}\`, re-run with \`--install-daemon\`, or use \`--skip-health\`.`,
process.platform === "win32" 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, : undefined,
] ]
.filter(Boolean) .filter(Boolean)

View File

@@ -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,
});
});
});

View File

@@ -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( export async function execSchtasks(
args: string[], args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> { ): 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,
};
} }

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { quoteCmdScriptArg } from "./cmd-argv.js";
const schtasksResponses = vi.hoisted( const schtasksResponses = vi.hoisted(
() => [] as Array<{ code: number; stdout: string; stderr: string }>, () => [] as Array<{ code: number; stdout: string; stderr: string }>,
@@ -10,7 +11,8 @@ const schtasksResponses = vi.hoisted(
const schtasksCalls = vi.hoisted(() => [] as string[][]); const schtasksCalls = vi.hoisted(() => [] as string[][]);
const inspectPortUsage = vi.hoisted(() => vi.fn()); const inspectPortUsage = vi.hoisted(() => vi.fn());
const killProcessTree = 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", () => ({ vi.mock("./schtasks-exec.js", () => ({
execSchtasks: async (argv: string[]) => { execSchtasks: async (argv: string[]) => {
@@ -27,8 +29,8 @@ vi.mock("../process/kill-tree.js", () => ({
killProcessTree: (...args: unknown[]) => killProcessTree(...args), killProcessTree: (...args: unknown[]) => killProcessTree(...args),
})); }));
vi.mock("../process/exec.js", () => ({ vi.mock("node:child_process", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeout(...args), spawn: (...args: unknown[]) => spawn(...args),
})); }));
const { const {
@@ -73,15 +75,8 @@ beforeEach(() => {
schtasksCalls.length = 0; schtasksCalls.length = 0;
inspectPortUsage.mockReset(); inspectPortUsage.mockReset();
killProcessTree.mockReset(); killProcessTree.mockReset();
runCommandWithTimeout.mockReset(); spawn.mockClear();
runCommandWithTimeout.mockResolvedValue({ childUnref.mockClear();
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
});
}); });
afterEach(() => { afterEach(() => {
@@ -114,14 +109,40 @@ describe("Windows startup fallback", () => {
expect(result.scriptPath).toBe(resolveTaskScriptPath(env)); expect(result.scriptPath).toBe(resolveTaskScriptPath(env));
expect(startupScript).toContain('start "" /min cmd.exe /d /c'); expect(startupScript).toContain('start "" /min cmd.exe /d /c');
expect(startupScript).toContain("gateway.cmd"); expect(startupScript).toContain("gateway.cmd");
expect(runCommandWithTimeout).toHaveBeenCalledWith( expect(spawn).toHaveBeenCalledWith(
["cmd.exe", "/d", "/s", "/c", startupEntryPath], "cmd.exe",
expect.objectContaining({ timeoutMs: 3000, windowsVerbatimArguments: true }), ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))],
expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }),
); );
expect(childUnref).toHaveBeenCalled();
expect(printed).toContain("Installed Windows login item"); 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 () => { it("treats an installed Startup-folder launcher as loaded", async () => {
await withWindowsEnv(async ({ env }) => { await withWindowsEnv(async ({ env }) => {
schtasksResponses.push( schtasksResponses.push(
@@ -179,7 +200,11 @@ describe("Windows startup fallback", () => {
outcome: "completed", outcome: "completed",
}); });
expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); 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 }),
);
}); });
}); });
}); });

View File

@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { inspectPortUsage } from "../infra/ports.js"; import { inspectPortUsage } from "../infra/ports.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { killProcessTree } from "../process/kill-tree.js"; import { killProcessTree } from "../process/kill-tree.js";
import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js";
import { assertNoCmdLineBreak, parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; import { assertNoCmdLineBreak, parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js";
@@ -30,6 +30,15 @@ function resolveTaskName(env: GatewayServiceEnv): string {
return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE); 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 { export function resolveTaskScriptPath(env: GatewayServiceEnv): string {
const override = env.OPENCLAW_TASK_SCRIPT?.trim(); const override = env.OPENCLAW_TASK_SCRIPT?.trim();
if (override) { if (override) {
@@ -284,12 +293,13 @@ async function isRegisteredScheduledTask(env: GatewayServiceEnv): Promise<boolea
return res.code === 0; return res.code === 0;
} }
async function launchStartupEntry(env: GatewayServiceEnv): Promise<void> { function launchFallbackTaskScript(scriptPath: string): void {
const startupEntryPath = resolveStartupEntryPath(env); const child = spawn("cmd.exe", ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)], {
await runCommandWithTimeout(["cmd.exe", "/d", "/s", "/c", startupEntryPath], { detached: true,
timeoutMs: 3000, stdio: "ignore",
windowsVerbatimArguments: true, windowsHide: true,
}); });
child.unref();
} }
function resolveConfiguredGatewayPort(env: GatewayServiceEnv): number | null { function resolveConfiguredGatewayPort(env: GatewayServiceEnv): number | null {
@@ -346,7 +356,7 @@ async function restartStartupEntry(
if (typeof runtime.pid === "number" && runtime.pid > 0) { if (typeof runtime.pid === "number" && runtime.pid > 0) {
killProcessTree(runtime.pid, { graceMs: 300 }); killProcessTree(runtime.pid, { graceMs: 300 });
} }
await launchStartupEntry(env); launchFallbackTaskScript(resolveTaskScriptPath(env));
stdout.write(`${formatLine("Restarted Windows login item", resolveTaskName(env))}\n`); stdout.write(`${formatLine("Restarted Windows login item", resolveTaskName(env))}\n`);
return { outcome: "completed" }; return { outcome: "completed" };
} }
@@ -394,12 +404,12 @@ export async function installScheduledTask({
} }
if (create.code !== 0) { if (create.code !== 0) {
const detail = create.stderr || create.stdout; const detail = create.stderr || create.stdout;
if (/access is denied/i.test(detail)) { if (shouldFallbackToStartupEntry({ code: create.code, detail })) {
const startupEntryPath = resolveStartupEntryPath(env); const startupEntryPath = resolveStartupEntryPath(env);
await fs.mkdir(path.dirname(startupEntryPath), { recursive: true }); await fs.mkdir(path.dirname(startupEntryPath), { recursive: true });
const launcher = buildStartupLauncherScript({ description: taskDescription, scriptPath }); const launcher = buildStartupLauncherScript({ description: taskDescription, scriptPath });
await fs.writeFile(startupEntryPath, launcher, "utf8"); await fs.writeFile(startupEntryPath, launcher, "utf8");
await launchStartupEntry(env); launchFallbackTaskScript(scriptPath);
writeFormattedLines( writeFormattedLines(
stdout, stdout,
[ [