mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 22:55:24 +00:00
fix: harden windows gateway fallback launch
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
53
src/daemon/schtasks-exec.test.ts
Normal file
53
src/daemon/schtasks-exec.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolea
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
async function launchStartupEntry(env: GatewayServiceEnv): Promise<void> {
|
||||
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,
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user