import fs from "node:fs/promises"; import path from "node:path"; import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; import { assertNoCmdLineBreak, parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; import { resolveGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; import { formatLine, writeFormattedLines } from "./output.js"; import { resolveGatewayStateDir } from "./paths.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; import { execSchtasks } from "./schtasks-exec.js"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import type { GatewayServiceCommandConfig, GatewayServiceControlArgs, GatewayServiceEnv, GatewayServiceEnvArgs, GatewayServiceInstallArgs, GatewayServiceManageArgs, GatewayServiceRenderArgs, } from "./service-types.js"; function resolveTaskName(env: GatewayServiceEnv): string { const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim(); if (override) { return override; } return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE); } export function resolveTaskScriptPath(env: GatewayServiceEnv): string { const override = env.OPENCLAW_TASK_SCRIPT?.trim(); if (override) { return override; } const scriptName = env.OPENCLAW_TASK_SCRIPT_NAME?.trim() || "gateway.cmd"; const stateDir = resolveGatewayStateDir(env); return path.join(stateDir, scriptName); } // `/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 { if (!/[ \t"]/g.test(value)) { return value; } return `"${value.replace(/"/g, '\\"')}"`; } function resolveTaskUser(env: GatewayServiceEnv): string | null { const username = env.USERNAME || env.USER || env.LOGNAME; if (!username) { return null; } if (username.includes("\\")) { return username; } const domain = env.USERDOMAIN; if (domain) { return `${domain}\\${username}`; } return username; } export async function readScheduledTaskCommand( env: GatewayServiceEnv, ): Promise { const scriptPath = resolveTaskScriptPath(env); try { const content = await fs.readFile(scriptPath, "utf8"); let workingDirectory = ""; let commandLine = ""; const environment: Record = {}; for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); if (!line) { continue; } const lower = line.toLowerCase(); if (line.startsWith("@echo")) { continue; } if (lower.startsWith("rem ")) { continue; } if (lower.startsWith("set ")) { const assignment = parseCmdSetAssignment(line.slice(4)); if (assignment) { environment[assignment.key] = assignment.value; } continue; } if (lower.startsWith("cd /d ")) { workingDirectory = line.slice("cd /d ".length).trim().replace(/^"|"$/g, ""); continue; } commandLine = line; break; } if (!commandLine) { return null; } return { programArguments: parseCmdScriptCommandLine(commandLine), ...(workingDirectory ? { workingDirectory } : {}), ...(Object.keys(environment).length > 0 ? { environment } : {}), }; } catch { return null; } } export type ScheduledTaskInfo = { status?: string; lastRunTime?: string; lastRunResult?: string; }; export function parseSchtasksQuery(output: string): ScheduledTaskInfo { const entries = parseKeyValueOutput(output, ":"); const info: ScheduledTaskInfo = {}; const status = entries.status; if (status) { info.status = status; } const lastRunTime = entries["last run time"]; if (lastRunTime) { info.lastRunTime = lastRunTime; } const lastRunResult = entries["last run result"]; if (lastRunResult) { info.lastRunResult = lastRunResult; } return info; } function normalizeTaskResultCode(value?: string): string | null { if (!value) { return null; } const raw = value.trim().toLowerCase(); if (!raw) { return null; } if (/^0x[0-9a-f]+$/.test(raw)) { return `0x${raw.slice(2).replace(/^0+/, "") || "0"}`; } if (/^\d+$/.test(raw)) { const numeric = Number.parseInt(raw, 10); if (Number.isFinite(numeric)) { return `0x${numeric.toString(16)}`; } } return raw; } export function deriveScheduledTaskRuntimeStatus(parsed: ScheduledTaskInfo): { status: GatewayServiceRuntime["status"]; detail?: string; } { const statusRaw = parsed.status?.trim().toLowerCase(); if (!statusRaw) { return { status: "unknown" }; } const normalizedResult = normalizeTaskResultCode(parsed.lastRunResult); const runningCodes = new Set(["0x41301"]); const isRunningByCode = normalizedResult != null && runningCodes.has(normalizedResult); const isRunningByStatus = statusRaw === "running"; // schtasks.exe localizes its Status field ("Running" in English, // "Wird ausgeführt" in German, "En cours" in French, etc.). // Prefer the locale-invariant Last Run Result code 0x41301 // ("task is currently running") over string matching. (#39057) if (!isRunningByStatus && !isRunningByCode) { return { status: "stopped" }; } // Cross-check: if the English status says "running" but the result // code disagrees, the runtime state is likely stale. if (isRunningByStatus && normalizedResult && !isRunningByCode) { return { status: "stopped", detail: `Task reports Running but Last Run Result=${parsed.lastRunResult}; treating as stale runtime state.`, }; } return { status: "running" }; } function buildTaskScript({ description, programArguments, workingDirectory, environment, }: GatewayServiceRenderArgs): string { const lines: string[] = ["@echo off"]; const trimmedDescription = description?.trim(); if (trimmedDescription) { assertNoCmdLineBreak(trimmedDescription, "Task description"); lines.push(`rem ${trimmedDescription}`); } if (workingDirectory) { lines.push(`cd /d ${quoteCmdScriptArg(workingDirectory)}`); } if (environment) { for (const [key, value] of Object.entries(environment)) { if (!value) { continue; } if (key.toUpperCase() === "PATH") { continue; } lines.push(renderCmdSetAssignment(key, value)); } } const command = programArguments.map(quoteCmdScriptArg).join(" "); lines.push(command); return `${lines.join("\r\n")}\r\n`; } async function assertSchtasksAvailable() { const res = await execSchtasks(["/Query"]); if (res.code === 0) { return; } const detail = res.stderr || res.stdout; throw new Error(`schtasks unavailable: ${detail || "unknown error"}`.trim()); } export async function installScheduledTask({ env, stdout, programArguments, workingDirectory, environment, description, }: GatewayServiceInstallArgs): Promise<{ scriptPath: string }> { await assertSchtasksAvailable(); const scriptPath = resolveTaskScriptPath(env); await fs.mkdir(path.dirname(scriptPath), { recursive: true }); const taskDescription = resolveGatewayServiceDescription({ env, environment, description }); const script = buildTaskScript({ description: taskDescription, programArguments, workingDirectory, environment, }); await fs.writeFile(scriptPath, script, "utf8"); const taskName = resolveTaskName(env); const quotedScript = quoteSchtasksArg(scriptPath); const baseArgs = [ "/Create", "/F", "/SC", "ONLOGON", "/RL", "LIMITED", "/TN", taskName, "/TR", quotedScript, ]; const taskUser = resolveTaskUser(env); let create = await execSchtasks( taskUser ? [...baseArgs, "/RU", taskUser, "/NP", "/IT"] : baseArgs, ); if (create.code !== 0 && taskUser) { create = await execSchtasks(baseArgs); } 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()); } await execSchtasks(["/Run", "/TN", taskName]); // Ensure we don't end up writing to a clack spinner line (wizards show progress without a newline). writeFormattedLines( stdout, [ { label: "Installed Scheduled Task", value: taskName }, { label: "Task script", value: scriptPath }, ], { leadingBlankLine: true }, ); return { scriptPath }; } export async function uninstallScheduledTask({ env, stdout, }: GatewayServiceManageArgs): Promise { await assertSchtasksAvailable(); const taskName = resolveTaskName(env); await execSchtasks(["/Delete", "/F", "/TN", taskName]); const scriptPath = resolveTaskScriptPath(env); try { await fs.unlink(scriptPath); stdout.write(`${formatLine("Removed task script", scriptPath)}\n`); } catch { stdout.write(`Task script not found at ${scriptPath}\n`); } } function isTaskNotRunning(res: { stdout: string; stderr: string; code: number }): boolean { const detail = (res.stderr || res.stdout).toLowerCase(); return detail.includes("not running"); } export async function stopScheduledTask({ stdout, env }: GatewayServiceControlArgs): Promise { await assertSchtasksAvailable(); const taskName = resolveTaskName(env ?? (process.env as GatewayServiceEnv)); const res = await execSchtasks(["/End", "/TN", taskName]); if (res.code !== 0 && !isTaskNotRunning(res)) { throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim()); } stdout.write(`${formatLine("Stopped Scheduled Task", taskName)}\n`); } export async function restartScheduledTask({ stdout, env, }: GatewayServiceControlArgs): Promise { await assertSchtasksAvailable(); const taskName = resolveTaskName(env ?? (process.env as GatewayServiceEnv)); await execSchtasks(["/End", "/TN", taskName]); const res = await execSchtasks(["/Run", "/TN", taskName]); if (res.code !== 0) { throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim()); } stdout.write(`${formatLine("Restarted Scheduled Task", taskName)}\n`); } 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; } export async function readScheduledTaskRuntime( env: GatewayServiceEnv = process.env as GatewayServiceEnv, ): Promise { try { await assertSchtasksAvailable(); } catch (err) { return { status: "unknown", detail: String(err), }; } const taskName = resolveTaskName(env); const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]); if (res.code !== 0) { const detail = (res.stderr || res.stdout).trim(); const missing = detail.toLowerCase().includes("cannot find the file"); return { status: missing ? "stopped" : "unknown", detail: detail || undefined, missingUnit: missing, }; } const parsed = parseSchtasksQuery(res.stdout || ""); const derived = deriveScheduledTaskRuntimeStatus(parsed); return { status: derived.status, state: parsed.status, lastRunTime: parsed.lastRunTime, lastRunResult: parsed.lastRunResult, ...(derived.detail ? { detail: derived.detail } : {}), }; }