/** * The Windows adapter for `cotal daemon install`: a scheduled task that runs the daemon as the * signed-in user. * * Task Scheduler is the per-user supervisor Windows ships. The task's logon trigger names this * user's SID, so it starts at this user's logon only, and a standard user can register it without * elevation. It runs on the user's interactive token at least privilege, so no password is stored * and nothing runs as LocalSystem. The trigger repeats every minute with no end, and the task * ignores a start while its instance is running, so a daemon that exits is started again within a * minute. The battery and run-time limits are off, and it runs at normal priority. * * `schtasks /Create /XML` registers the definition from a file this install first creates in the * daemon's own state directory, as UTF-16 with a byte order mark, and records the digest of. The * task name carries the user's SID, so two users of one machine never collide. `uninstall` acts * only while that file is unchanged and the task registered under its name still runs that file's * exact command and arguments. That read uses `Get-ScheduledTask` through a fixed PowerShell script * that takes the task name from its environment, because `schtasks /Query /XML` does not reliably * state the encoding of what it prints. * * The desktop package uses its GUI-subsystem launcher to keep Node hidden. An unpackaged CLI * uses Node directly and can show a console window. The headless * profile, a Windows service under a dedicated service account, is not offered here. */ import { setTimeout as delay } from "node:timers/promises"; import { createHash } from "node:crypto"; import { lstatSync, rmSync } from "node:fs"; import { homedir, userInfo } from "node:os"; import { basename, join, win32 } from "node:path"; import { writeSecretFileCreateOnly } from "@cotal-ai/core"; import { CommandFailure, readLocalRecordSource } from "@cotal-ai/workspace"; import { xmlText } from "./install-darwin.js"; import type { NativePlan, NativeRun, NativeService, UnitReceipt } from "./install.js"; /** A System32 tool, resolved from the Windows directory and never from `PATH`. */ function system32(...segments: string[]): string { return join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", ...segments); } const SOURCE_LIMIT = 64 * 1024; /** `cotal-daemon-`, or `cotal-daemon-<16 hex>-` for an explicit daemon home, derived as * its socket directory is. */ export function scheduledTaskName(daemonHome: string | undefined, sid: string): string { if (!/^S-1-[0-9]+(?:-[0-9]+)+$/.test(sid)) throw new CommandFailure("refused", "TARGET_UNAVAILABLE", "the current Windows user has no readable SID; no task name was derived"); if (daemonHome === undefined) return `cotal-daemon-${sid}`; return `cotal-daemon-${createHash("sha256").update(daemonHome).digest("hex").slice(0, 16)}-${sid}`; } /** One argument as the Microsoft C runtime splits a command line: quoted when it holds a space or * tab, with the backslashes before the closing quote doubled. A double quote is refused, as no * Windows path holds one, and so is `%`, which Task Scheduler expands with no escape. */ export function windowsArgument(arg: string): string { if (!arg || arg.includes('"') || arg.includes("%")) throw new CommandFailure("refused", "UNSUPPORTED", "a task argument is empty or holds a double quote or %, which Task Scheduler would not pass through unchanged; no task was prepared"); if (!/[ \t]/.test(arg)) return arg; return `"${arg}${"\\".repeat(/\\*$/.exec(arg)![0].length)}"`; } export interface ScheduledTaskInput { readonly sid: string; readonly argv: readonly string[]; readonly workingDirectory: string; } /** The task definition this install registers. */ export function renderScheduledTask(input: ScheduledTaskInput): { text: string; bytes: Buffer; sha256: string; command: string; arguments: string } { const nativeArgv = [...input.argv]; const daemon = nativeArgv.indexOf("daemon"); if (daemon >= 2 && /cotal\.[cm]?js$/i.test(nativeArgv[daemon - 1]!)) nativeArgv[daemon - 1] = win32.join(win32.dirname(nativeArgv[daemon - 1]!), "daemon-native.js"); const [command, ...rest] = nativeArgv; // Windows path rules on every host, so the definition renders the same wherever it is checked. if (command === undefined || !win32.isAbsolute(command) || !win32.isAbsolute(input.workingDirectory) || command.includes("%") || input.workingDirectory.includes("%")) throw new CommandFailure("refused", "UNSUPPORTED", "the task needs absolute executable and working paths without %; no task was prepared"); const args = rest.map(windowsArgument).join(" "); const sid = xmlText(input.sid); const text = [ '', '', " Cotal machine daemon", " ", ` truePT1Mfalse${sid}`, " ", " ", ` ${sid}InteractiveTokenLeastPrivilege`, " ", " ", " IgnoreNew", " PT1M255", " false", " false", " PT0S", " 4", " ", ' ', ` ${xmlText(command)}${xmlText(args)}${xmlText(input.workingDirectory)}`, " ", "", "", ].join("\r\n"); const bytes = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, "utf16le")]); if (bytes.length > SOURCE_LIMIT) throw new CommandFailure("refused", "USAGE_INVALID", "the task definition exceeds its bounded source size"); return { text, bytes, sha256: createHash("sha256").update(bytes).digest("hex"), command, arguments: args }; } /** An element's text in a definition this install wrote, unescaped; `absent` when it is not there. */ function element(text: string, name: string): string { const raw = new RegExp(`<${name}>([^<]*)`).exec(text)?.[1]; return raw === undefined ? "absent" : raw.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("'", "'").replaceAll("&", "&"); } /** What Task Scheduler has registered under one task name in the root folder. */ interface RegisteredTask { readonly present: boolean; readonly state?: string; readonly enabled?: boolean; readonly instances?: number; readonly actions?: number; readonly execute?: string; readonly arguments?: string; readonly workingDirectory?: string; readonly userId?: string; readonly logonType?: number; readonly runLevel?: number; } /** Fixed: the task name arrives only through `COTAL_DAEMON_TASK`, never as script text. It lists * every task and filters, because a filtered `Get-ScheduledTask` that matches nothing is an error * rather than an empty answer. */ const READ_TASK = [ "$ErrorActionPreference = 'Stop'", "Import-Module ScheduledTasks", "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)", "$task = @(Get-ScheduledTask | Where-Object { $_.TaskPath -ceq '\\' -and $_.TaskName -ceq $env:COTAL_DAEMON_TASK })", "if ($task.Count -eq 0) { '{\"present\":false}'; exit 0 }", "$actions = @($task[0].Actions)", "$principal = $task[0].Principal", "$principalSid = [string]$principal.UserId", "if ($principalSid -notmatch '^S-1-') { $principalSid = ([System.Security.Principal.NTAccount]::new($principalSid)).Translate([System.Security.Principal.SecurityIdentifier]).Value }", "$enabled = $null; $instances = $null", "if ($env:COTAL_DAEMON_TASK_INSTANCES -eq '1') {", "$scheduler = New-Object -ComObject 'Schedule.Service'", "$scheduler.Connect()", "$registered = $scheduler.GetFolder('\\').GetTask($env:COTAL_DAEMON_TASK)", "$enabled = [bool]$registered.Enabled; $instances = [int]$registered.GetInstances(0).Count", "}", "ConvertTo-Json -Compress -InputObject @{ enabled = $enabled; instances = $instances; present = $true; state = [string]$task[0].State; actions = $actions.Count; execute = [string]$actions[0].Execute; arguments = [string]$actions[0].Arguments; workingDirectory = [string]$actions[0].WorkingDirectory; userId = $principalSid; logonType = [int]$principal.LogonType; runLevel = [int]$principal.RunLevel }", ].join("\n"); /** Windows refresh pauses the owned task without deleting its installation. */ export interface WindowsTaskService extends NativeService { pause(receipt: UnitReceipt): Promise; resume(receipt: UnitReceipt): void; inspect(receipt: UnitReceipt): { present: boolean; enabled?: boolean; instances?: number }; enable(receipt: UnitReceipt): void; readonly pendingEnd: boolean; } export function scheduledTaskService(run: NativeRun): WindowsTaskService { const sid = (): string => { const out = run(system32("whoami.exe"), ["/user", "/fo", "csv", "/nh"]); const value = out.status === 0 ? out.stdout.trim().split(",").pop()?.replace(/^"|"$/g, "").trim() : undefined; if (!value || !/^S-1-[0-9]+(?:-[0-9]+)+$/.test(value)) throw new CommandFailure("refused", "TARGET_UNAVAILABLE", `whoami did not name the current user's SID (${out.detail}); nothing was changed`); return value; }; const registered = (name: string, requireInstances = false): RegisteredTask => { const out = run(system32("WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(READ_TASK, "utf16le").toString("base64")], { ...process.env, COTAL_DAEMON_TASK: name, COTAL_DAEMON_TASK_INSTANCES: requireInstances ? "1" : "0" }); let o: unknown; try { o = out.status === 0 ? JSON.parse(out.stdout.replace(/^\uFEFF/, "").trim()) : undefined; } catch { o = undefined; } const r = o && typeof o === "object" ? o as Record : undefined; if (r?.present === false) return { present: false }; if (r?.present !== true || typeof r.state !== "string" || typeof r.actions !== "number" || typeof r.execute !== "string" || typeof r.arguments !== "string" || typeof r.workingDirectory !== "string" || typeof r.userId !== "string" || typeof r.logonType !== "number" || typeof r.runLevel !== "number") throw new CommandFailure("refused", "TARGET_UNAVAILABLE", `Task Scheduler did not answer for ${name} (${out.detail}); nothing was changed`); if (requireInstances && (typeof r.enabled !== "boolean" || !Number.isSafeInteger(r.instances) || (r.instances as number) < 0)) throw new CommandFailure("refused", "TARGET_UNAVAILABLE", "Task Scheduler did not confirm enabled state and running instances; nothing was inferred"); return { present: true, enabled: r.enabled as boolean | undefined, instances: r.instances as number | undefined, state: r.state, actions: r.actions, execute: r.execute, arguments: r.arguments, workingDirectory: r.workingDirectory, userId: r.userId, logonType: r.logonType, runLevel: r.runLevel }; }; const step = (args: readonly string[]): void => { const result = run(system32("schtasks.exe"), args); if (result.status !== 0) throw new CommandFailure("unknown", "OPERATION_UNKNOWN", `schtasks ${args.join(" ")} did not confirm completion (${result.detail}); it was not retried`, { remedy: "inspect the task in Task Scheduler, then remove it with \`cotal daemon uninstall\`" }); }; /** The registered task, refused unless it runs the recorded definition's exact command. */ const owned = (receipt: UnitReceipt, requireInstances = false): RegisteredTask => { // A receipt names only the task and definition this daemon home and user derive, so it can // never steer a delete at another task. if (receipt.unit !== scheduledTaskName(receipt.daemonHome, sid()) || basename(receipt.unitPath) !== "service-task.xml") throw new CommandFailure("refused", "CONFLICT", `the recorded task ${receipt.unit} is not this daemon's task; nothing was removed`); const bytes = readLocalRecordSource(receipt.unitPath, SOURCE_LIMIT, true); if (!bytes || createHash("sha256").update(bytes).digest("hex") !== receipt.unitSha256 || bytes[0] !== 0xff || bytes[1] !== 0xfe) throw new CommandFailure("refused", "CONFLICT", `${receipt.unitPath} is not the task definition this daemon installed; nothing was removed`); const text = bytes.subarray(2).toString("utf16le"); const task = registered(receipt.unit, requireInstances); if (task.present && (task.actions !== 1 || task.execute !== element(text, "Command") || task.arguments !== element(text, "Arguments") || task.workingDirectory !== element(text, "WorkingDirectory") || task.userId !== element(text, "UserId") || task.logonType !== 3 || task.runLevel !== 0)) throw new CommandFailure("refused", "CONFLICT", `the task registered as ${receipt.unit} no longer runs the command this daemon installed; nothing was removed`); return task; }; let pendingEnd = false; const enable = (receipt: UnitReceipt): void => { if (pendingEnd) throw new CommandFailure("unknown", "OPERATION_UNKNOWN", "native End completion is unresolved; the task was left disabled"); const task = owned(receipt, true); if (!task.present) throw new CommandFailure("refused", "TARGET_NOT_FOUND", "the owned task is absent; nothing was enabled"); if (!task.enabled) step(["/Change", "/TN", receipt.unit, "/ENABLE"]); const enabled = owned(receipt, true); if (!enabled.present || enabled.enabled !== true) throw new CommandFailure("unknown", "OPERATION_UNKNOWN", "the owned task did not confirm enabled state"); }; return { mechanism: "scheduled-task", get pendingEnd() { return pendingEnd; }, inspect(receipt) { const task = owned(receipt, true); if (task.present && task.instances === 0) pendingEnd = false; return task; }, enable, async pause(receipt) { const task = owned(receipt, true); if (!task.present || !task.enabled) throw new CommandFailure("refused", "CONFLICT", "the owned scheduled task is absent or disabled; refresh was refused"); step(["/Change", "/TN", receipt.unit, "/DISABLE"]); const disabled = owned(receipt, true); if (!disabled.present || disabled.enabled !== false) throw new CommandFailure("unknown", "OPERATION_UNKNOWN", "the owned task did not confirm disabled state"); if (disabled.instances! > 0) { owned(receipt, true); pendingEnd = true; step(["/End", "/TN", receipt.unit]); } for (let attempt = 0; attempt < 3; attempt++) { const ended = owned(receipt, true); if (!ended.present || ended.enabled !== false) break; if (ended.instances === 0) { pendingEnd = false; return; } if (attempt < 2) await delay(250); } throw new CommandFailure("unknown", "OPERATION_UNKNOWN", "the owned task did not confirm no running instances after End"); }, resume(receipt) { const task = owned(receipt, true); if (!task.present || task.enabled !== false || task.instances !== 0) throw new CommandFailure("refused", "CONFLICT", "the paused owned task changed before resume"); step(["/Change", "/TN", receipt.unit, "/ENABLE"]); const enabled = owned(receipt, true); if (!enabled.present || enabled.enabled !== true) throw new CommandFailure("unknown", "OPERATION_UNKNOWN", "the owned task did not confirm enabled state"); if (enabled.instances === 0) step(["/Run", "/TN", receipt.unit]); }, plan(paths, daemonHome, argv): NativePlan { const user = sid(); const unit = scheduledTaskName(daemonHome, user); const unitPath = join(paths.state, "service-task.xml"); let present = false; try { lstatSync(unitPath); present = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new CommandFailure("refused", "TARGET_UNAVAILABLE", `${unitPath} could not be inspected; no absent task definition was inferred`); } if (present) throw new CommandFailure("refused", "CONFLICT", `${unitPath} already exists and no install of this daemon recorded it; it was not written over`); if (registered(unit).present) throw new CommandFailure("refused", "CONFLICT", `a task named ${unit} is already registered and no install of this daemon recorded it; nothing was written`); const { text, bytes, sha256 } = renderScheduledTask({ sid: user, argv, workingDirectory: homedir() }); return { mechanism: "scheduled-task", unit, unitPath, bytes, sha256, user: userInfo().username, restart: `every ${element(text, "Interval")} unless running (${element(text, "MultipleInstancesPolicy")})`, wantedBy: `logon of ${element(text, "UserId")}` }; }, publish(plan) { writeSecretFileCreateOnly(plan.unitPath, plan.bytes); const written = readLocalRecordSource(plan.unitPath, SOURCE_LIMIT, true); if (!written || createHash("sha256").update(written).digest("hex") !== plan.sha256) throw new CommandFailure("unknown", "OPERATION_UNKNOWN", "the published task definition could not be verified against its exact recorded digest; no task was registered"); }, register(plan) { step(["/Create", "/TN", plan.unit, "/XML", plan.unitPath]); step(["/Run", "/TN", plan.unit]); }, check(receipt) { owned(receipt); }, start(receipt) { const task = owned(receipt, true); if (!task.present) throw new CommandFailure("refused", "TARGET_NOT_FOUND", "the recorded scheduled task is absent; no replacement task was created"); if (!task.enabled) { if (task.instances !== 0) throw new CommandFailure("refused", "CONFLICT", "the disabled owned task still has native instances; startup was refused"); enable(receipt); } if (task.instances === 0) step(["/Run", "/TN", receipt.unit]); }, // Production restarts must use install.ts restartInstalled, which pins the old // listener and confirms its death before resume. This legacy synchronous adapter // operation cannot establish daemon lifetime replacement. restart(receipt) { const task = owned(receipt); if (!task.present) throw new CommandFailure("refused", "TARGET_NOT_FOUND", "the recorded scheduled task is absent; no replacement task was created"); if (task.state === "Running") step(["/End", "/TN", receipt.unit]); step(["/Run", "/TN", receipt.unit]); }, remove(receipt, preserveSource = false) { const task = owned(receipt); if (task.present) { // Disabled first, so its one-minute repetition cannot start it between the end and the delete. step(["/Change", "/TN", receipt.unit, "/DISABLE"]); if (task.state === "Running") step(["/End", "/TN", receipt.unit]); step(["/Delete", "/TN", receipt.unit, "/F"]); } if (!preserveSource) rmSync(receipt.unitPath, { force: true }); }, }; }