fix: fall back to a startup entry for windows gateway install

This commit is contained in:
Peter Steinberger 2026-03-13 03:18:17 +00:00
parent a60a4b4b5e
commit 433e65711f
No known key found for this signature in database
5 changed files with 400 additions and 16 deletions

View File

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

View File

@ -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:

View File

@ -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)

View File

@ -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<string, string>) {
return path.join(
env.APPDATA,
"Microsoft",
"Windows",
"Start Menu",
"Programs",
"Startup",
"OpenClaw Gateway.cmd",
);
}
async function withWindowsEnv(
run: (params: { tmpDir: string; env: Record<string, string> }) => Promise<void>,
) {
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();
});
});
});

View File

@ -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<boolean> {
try {
await fs.access(resolveStartupEntryPath(env));
return true;
} catch {
return false;
}
}
async function isRegisteredScheduledTask(env: GatewayServiceEnv): Promise<boolean> {
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<void> {
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<GatewayServiceRuntime> {
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<void> {
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<GatewayServiceRestartResult> {
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<void> {
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<void> {
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<GatewayServiceRestartResult> {
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<boolean> {
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 {