mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 02:38:07 +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.
|
- 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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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/,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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(
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user