@huynle/hub 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +7 -4
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/wrapper.ts +2 -2
- package/packages/cli/dist/hub-cli.js +1126 -479
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
- package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
- package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
- package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
- package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
- package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
- package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
- package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
- package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
- package/packages/cli/src/bootstrap/release/assets.ts +4 -1
- package/packages/cli/src/node.ts +94 -12
- package/packages/cli/src/setup/commands.ts +27 -7
- package/packages/cli/src/setup/runtime.ts +31 -4
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/wrapper.ts +2 -2
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +49 -17
- package/packages/node/src/git.ts +16 -2
- package/packages/node/src/harnesses.ts +54 -9
- package/packages/node/src/host.ts +61 -15
- package/packages/node/src/opencode-service.ts +111 -0
- package/packages/node/src/opencode-warm.ts +11 -5
- package/packages/node/src/run.ts +50 -5
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +3 -1
- package/packages/protocol/src/launch.ts +39 -4
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/launch-spec.ts +4 -2
- package/src/central/agent/webhooks.ts +5 -2
- package/src/central/config.ts +24 -2
- package/src/central/http.ts +1 -0
- package/src/central/install/CONTRACT.md +4 -4
- package/src/central/install/install.ps1 +37 -5
- package/src/central/install/render.ts +13 -8
- package/src/central/push-target.ts +11 -2
- package/web/dist/assets/{ArtifactPage-4GoBqSpU.js → ArtifactPage-BKISN-Qj.js} +1 -1
- package/web/dist/assets/{ArtifactPane-Dx_0iWwH.js → ArtifactPane-CKOiAUNH.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-CuHgARMk.js → ArtifactWorkbench-d7R1o-rs.js} +1 -1
- package/web/dist/assets/{Diagram-CZM_R7MG.js → Diagram-azw2B_QH.js} +1 -1
- package/web/dist/assets/{Library-TwvrwqSk.js → Library-CcjZXsfF.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-rKNDpLjR.js → ProfilesDestination-DdzbzmeK.js} +1 -1
- package/web/dist/assets/{TeamsPage-DtJylLrt.js → TeamsPage-CDyjrK-I.js} +1 -1
- package/web/dist/assets/{Watchtower-Bmgdyfjb.js → Watchtower-DYwoUWME.js} +2 -2
- package/web/dist/assets/{index-D-xG9nrc.js → index-LNMZCqgE.js} +15 -15
- package/web/dist/assets/{live-7q72Lyj2.js → live-ycZ_aQZO.js} +1 -1
- package/web/dist/assets/{renderer2d-hfZxM6Xl.js → renderer2d-BIwbgRoX.js} +1 -1
- package/web/dist/assets/{renderer3d-Km9vipTM.js → renderer3d-C1a7eYGQ.js} +1 -1
- package/web/dist/assets/{safe-area-BAbgBiFk.js → safe-area-DHhPy61Q.js} +1 -1
- package/web/dist/index.html +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Phase 3: user services on Windows (launch-node), shared contract
|
|
2
|
+
|
|
3
|
+
Design: `docs/plans/2026-11-05-machine-setup-installer-design.md` §3 (Phase 3); Phase 2 contract
|
|
4
|
+
`PHASE2-CONTRACT.md` and `docs/bootstrap-contracts.md` §11 (v1.3) still hold. Branch `feat/ms3` from v0.8.7
|
|
5
|
+
(`ebe8f6a1`). Three workstreams build against this file in parallel; change it only through the integrator.
|
|
6
|
+
|
|
7
|
+
Goal: `launch-node` works on `windows-x64`. `hub node` runs as a per-user logon task (no admin, no password, no
|
|
8
|
+
visible window), the node keeps OpenCode's background service running so the machine stays online without OpenCode
|
|
9
|
+
open, and launches prepare worktrees under `C:/…` workspace roots. `full-harness` (Claude Code / Codex spokes) stays
|
|
10
|
+
refused on Windows (Phase 4).
|
|
11
|
+
|
|
12
|
+
## Paths in the protocol and the node (workstream A)
|
|
13
|
+
|
|
14
|
+
- The launch/node protocol (`packages/protocol/src/launch.ts` `isAbsolutePath`, `node.ts`) accepts canonical absolute
|
|
15
|
+
paths exactly as the bootstrap contracts do: POSIX `/a/b` or Windows `C:/a/b` (upper-case drive, `/`, no UNC or
|
|
16
|
+
device paths). Reuse or mirror `packages/cli/src/bootstrap/contracts/validate.ts` `isAbsolutePath` (one rule).
|
|
17
|
+
- Everything the node sends (hello `workspaceRoots`, prepared `directory`) is canonical; the node canonicalizes
|
|
18
|
+
native paths it reads (node.json, `realpathSync`, git output) at the edge (`normalizeAbs`-style). The hub compares
|
|
19
|
+
canonical strings; drive letters are upper-case, so case-insensitive Windows paths compare equal.
|
|
20
|
+
- node.json `workspaceRoots` on Windows may be written as `C:\Users\x\projects`, `C:/Users/x/projects` or
|
|
21
|
+
`~/projects`; the node canonicalizes them.
|
|
22
|
+
|
|
23
|
+
## The `task-scheduler` service manager (workstream B)
|
|
24
|
+
|
|
25
|
+
- `ServiceManager` gains `"task-scheduler"`; `windows-x64` has `serviceManager: "task-scheduler"` (was `"none"`).
|
|
26
|
+
Discovery reports `task-scheduler` on Windows when `%SystemRoot%\System32\schtasks.exe` exists, else `none`.
|
|
27
|
+
- New root `task-defs` = `state/hub/tasks` (canonical `C:/Users/x/.local/state/hub/tasks`): where hubctl writes the
|
|
28
|
+
rendered Task XML it registers. The XML is journaled like a unit file; Task Scheduler keeps its own copy.
|
|
29
|
+
- Embedded asset `service/task-scheduler/node` = `scripts/hub-node.task.xml`, kind `task-xml`. The rendered file is
|
|
30
|
+
written as **UTF-16LE with BOM** if `schtasks /XML` needs it (verify on Windows; UTF-8 if it accepts that).
|
|
31
|
+
- Task: name `hub-node-<user>` (user = `%USERNAME%`, `[A-Za-z0-9._-]` only, others `_`) in the root folder;
|
|
32
|
+
`LogonTrigger` for this user; principal = the user's SID, `InteractiveToken`, `LeastPrivilege` (no admin, no stored
|
|
33
|
+
password); `MultipleInstancesPolicy` `IgnoreNew`; no battery limits; `ExecutionTimeLimit` `PT0S`;
|
|
34
|
+
`RestartOnFailure` interval `PT1M`, count `999`. No visible window: the action must not open a console window at
|
|
35
|
+
logon (try `conhost.exe --headless <hubctl.exe> node run`; if that is not reliable, find another way, verify it on
|
|
36
|
+
Windows, and say what you chose). The command is `user-bin:hubctl.exe node run` (native path in the XML), never a
|
|
37
|
+
`.cmd`.
|
|
38
|
+
- No per-task environment exists: settings a systemd unit carries in `Environment=` are passed another way (flags, or
|
|
39
|
+
nothing when they are defaults). The script layout (`__RUNTIME__ __ENTRY__`) is refused on Windows: only the
|
|
40
|
+
standalone `hubctl.exe` layout.
|
|
41
|
+
- Control: absolute `%SystemRoot%\System32\schtasks.exe`, `shell:false`, `windowsHide:true`, a fixed allowlist:
|
|
42
|
+
`/Create /TN <name> /XML <abs task-defs file> /F`, `/Run /TN <name>`, `/End /TN <name>`, `/Delete /TN <name> /F`,
|
|
43
|
+
`/Query /TN <name> /XML` (state: registered / enabled) and a locale-independent running check (e.g. the task's
|
|
44
|
+
process: `hubctl.exe` with `node run` whose parent is the Task Scheduler engine, or `schtasks /Query /XML` plus a
|
|
45
|
+
CIM process query) with the same `state` / `activate` / `deactivate` / `restore` / `health` semantics and the
|
|
46
|
+
existing health window. `/End` is TerminateProcess: no graceful stop on Windows (accepted; the node is crash-safe).
|
|
47
|
+
- Exit 78 ("nothing to do") cannot be excluded from restarts in Task XML: on win32 the node exits **0** in that case
|
|
48
|
+
(no restart) and logs why; the task is started again at the next logon or by `hubctl setup apply`.
|
|
49
|
+
- The hand path `hub node install/uninstall` on Windows writes the XML and prints the `schtasks` commands (never runs
|
|
50
|
+
them), like launchd/systemd today.
|
|
51
|
+
- Profiles: `launch-node` plans on `windows-x64`; `full-harness` stays refused there with
|
|
52
|
+
"<service> needs a user service hubctl does not install on Windows yet" for the claude-code / codex spokes.
|
|
53
|
+
`--workspace-root` takes native or canonical Windows paths and `~/…`.
|
|
54
|
+
|
|
55
|
+
## The node on Windows (workstream A)
|
|
56
|
+
|
|
57
|
+
- **OpenCode background service.** On win32, when no background service is registered or its process is gone, the node
|
|
58
|
+
starts it (`<opencode> service start`, found like the warmer finds OpenCode, `windowsHide`, no shell), at most once
|
|
59
|
+
per tick with back-off, and logs it. Then the warmer warms it as on other OSes. Verify on Windows where OpenCode
|
|
60
|
+
2.x writes `service.json` and how `opencode service start` behaves; fix `run.ts`'s path if it differs.
|
|
61
|
+
- `opencode-warm.ts` `readServiceRegistration`: the POSIX mode check (`mode & 0o022`) does not apply on win32; use the
|
|
62
|
+
ACL check from `src/win-acl.ts` (owner, SYSTEM, Administrators only) or none for a non-secret file — say which.
|
|
63
|
+
- Process trees: `git.ts` (and `runtime-openshell/src/cli.ts` if it can run on Windows) stop with the shared
|
|
64
|
+
`packages/cli/src/bootstrap/adapters/process-tree.ts` tree kill on win32 instead of `process.kill(-pid)`.
|
|
65
|
+
- Harness lookup (`harnesses.ts`): PATHEXT and `.exe` on win32 (`opencode.exe`, `claude.exe`, `bin/codex.exe`).
|
|
66
|
+
- Every spawn that can run on Windows sets `windowsHide: true` (also `control.ts` `defaultSpawn`,
|
|
67
|
+
`claude-code/src/wrapper.ts`, `codex/src/wrapper.ts`).
|
|
68
|
+
|
|
69
|
+
## Installer and console (workstream C)
|
|
70
|
+
|
|
71
|
+
- `/install.ps1` accepts `profile=launch-node`, `root` and `activate=1` (still refuses `full-harness`). `root` on
|
|
72
|
+
Windows: `~/…` or a drive path `C:/…` (forward slashes in the URL; same character allowlist plus `:` after a single
|
|
73
|
+
drive letter); the server validates and the script passes it to `--workspace-root`; `activate=1` adds
|
|
74
|
+
`--confirm-activation`.
|
|
75
|
+
- The console dialog offers **+ launch node** on Windows (workspace folder default `~/projects`, Start services now);
|
|
76
|
+
**All harnesses** stays "Coming soon on Windows".
|
|
77
|
+
|
|
78
|
+
## Verification on winpixel (all workstreams)
|
|
79
|
+
|
|
80
|
+
`ssh winpixel` (user `dacra`, PowerShell 5.1 remote shell; send scripts as `powershell -NoProfile -EncodedCommand
|
|
81
|
+
<base64 UTF-16LE>`). Work only under `C:\Users\dacra\AppData\Local\Temp\<your-workstream>\`, with `USERPROFILE`,
|
|
82
|
+
`HOME`, `TEMP`, `TMP`, `XDG_*` pointed inside it. Scheduled tasks you create must be named `hub-test-<something>` and
|
|
83
|
+
be deleted before you finish (`schtasks /Delete /F`). Never touch the real profile, the real `hub-node-*` task,
|
|
84
|
+
the registry or PATH. Processes started over ssh die when the session ends; a logon task needs a logon to fire, so test
|
|
85
|
+
it with `/Run`. If winpixel is unreachable (it may be off), finish everything else with tests and list exactly what
|
|
86
|
+
still needs a Windows run.
|
|
87
|
+
|
|
88
|
+
## As built (integrator, 2026-11-06)
|
|
89
|
+
|
|
90
|
+
Verified on winpixel during the workstreams; these replace the contract text where they differ.
|
|
91
|
+
|
|
92
|
+
- **Encoding:** `schtasks /XML` refuses UTF-8 ("unable to switch the encoding"); the Task XML is written UTF-16LE
|
|
93
|
+
with a BOM.
|
|
94
|
+
- **No window:** the action is `conhost.exe --headless <hubctl.exe> node run` (checked with Comet KVM screenshots
|
|
95
|
+
and a window-process query; without `--headless` Windows Terminal opens).
|
|
96
|
+
- **Restart:** `conhost --headless` always exits 0, so `RestartOnFailure` never fires. The logon trigger repeats every
|
|
97
|
+
minute with `IgnoreNew`: an exited node is started again within a minute whatever its exit code.
|
|
98
|
+
- **Idle node:** because of that, a node with no workspace roots on win32 does not exit: it logs why and re-reads
|
|
99
|
+
node.json every 60 s (`IDLE_RECHECK_MS`) until it lists roots (replaces "exit 0").
|
|
100
|
+
- **Logs:** stderr is lost under the headless console, so on win32 `hub node run` also appends to
|
|
101
|
+
`<hub state dir>/node.log` (rotated to `node.log.1` past 1 MiB).
|
|
102
|
+
- **Stop:** `schtasks /End` ends only the console host; deactivate first runs `taskkill /T /F /PID <pid>` on the
|
|
103
|
+
task's process (allowlisted as exactly that), then `/End` and `/Delete`.
|
|
104
|
+
- **Running check:** a fixed PowerShell script reads the task through Task Scheduler's COM API (numeric state, so no
|
|
105
|
+
localized text, and the action's process ID).
|
|
106
|
+
- **Carried settings:** variables a systemd unit would carry (`XDG_CONFIG_HOME` …) are dropped on Windows and named in
|
|
107
|
+
a warning at preview and `hub node install`.
|
|
108
|
+
- **Spokes:** `full-harness` is refused per spoke ("claude-code needs a user service hubctl does not install on Windows
|
|
109
|
+
yet", likewise codex-spoke).
|
|
110
|
+
- **Node paths:** service.json is `%USERPROFILE%\.local\state\opencode\service.json` (as on POSIX); it is checked by ACL.
|
|
111
|
+
Launch placement compares canonical paths exactly (drive upper-case); a repo path differing only in case from a
|
|
112
|
+
node's root is not placed there.
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// systemctl --user show --property=ActiveState,SubState,NRestarts <hub unit>
|
|
8
8
|
// launchd launchctl bootstrap gui/<uid> <the hub agent's plist> | bootout gui/<uid>/<hub label>
|
|
9
9
|
// launchctl print gui/<uid>/<hub label>
|
|
10
|
+
// Task Scheduler schtasks /Create /TN <hub task> /XML <its task-defs file> /F | /Run | /End /TN <hub task>
|
|
11
|
+
// (Windows) schtasks /Delete /TN <hub task> /F | /Query /TN <hub task> /XML
|
|
12
|
+
// powershell -NoProfile -NonInteractive -Command <one fixed Schedule.Service query of the task>
|
|
13
|
+
// taskkill /T /F /PID <the task's running process>
|
|
10
14
|
// Every call is checked against that set (checkManagerCall) before it reaches the injected runner, so a bug cannot
|
|
11
15
|
// turn it into anything else; the real runner (spawnManagerRunner) checks again, spawns the absolute program
|
|
12
16
|
// without a shell and with an empty environment (only the user bus address is passed), and reports an exit code;
|
|
@@ -23,15 +27,23 @@
|
|
|
23
27
|
// Activation rolls back to the state the service had before: a service that was not loaded is unloaded (and
|
|
24
28
|
// disabled) again; one that was already loaded stays loaded (the engine then restores its previous unit file).
|
|
25
29
|
// After a crash the prior state is unknown, so recovery unloads it: no persistence remains without consent.
|
|
30
|
+
//
|
|
31
|
+
// Task Scheduler (PHASE3-CONTRACT.md): activation registers the task from the Task XML hubctl wrote (/Create /XML /F,
|
|
32
|
+
// replacing an older definition) and starts it (/Run); "loaded" means registered. The running check reads the task
|
|
33
|
+
// through the Schedule.Service COM object (a fixed PowerShell script: its State and LastTaskResult are numbers, so the
|
|
34
|
+
// answer does not depend on the display language, and each running instance's EnginePID is the process of its
|
|
35
|
+
// action). `schtasks /End` ends only that process, the hidden console host, and would leave `hubctl node run`
|
|
36
|
+
// running without it, so deactivation (and a re-activation) first ends the action's whole process tree with
|
|
37
|
+
// taskkill /T /F. There is no graceful stop on Windows; the node is crash-safe.
|
|
26
38
|
import { spawn as nodeSpawn } from "node:child_process"
|
|
27
39
|
import path from "node:path"
|
|
28
|
-
import { EMBEDDED_ASSETS, isAbsolutePath, isRecord, resolvePathRef, SERVICE_IDS, type ExecutionContext, type RootMapping, type ServiceID, type ServiceManagerID } from "../../contracts/index.ts"
|
|
40
|
+
import { EMBEDDED_ASSETS, isAbsolutePath, isRecord, resolvePathRef, SERVICE_IDS, toNativePath, type ExecutionContext, type RootMapping, type ServiceID, type ServiceManagerID } from "../../contracts/index.ts"
|
|
29
41
|
import type { ServiceControl } from "../../transaction/builtin.ts"
|
|
30
42
|
import type { ActionExecutor } from "../../transaction/executor.ts"
|
|
31
43
|
import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
|
|
32
|
-
import { assetIDFor, serviceName, UNIT_ROOT } from "./render.ts"
|
|
44
|
+
import { assetIDFor, serviceName, TASK_NAME, UNIT_ROOT } from "./render.ts"
|
|
33
45
|
|
|
34
|
-
export type ManagerProgram = "systemctl" | "launchctl"
|
|
46
|
+
export type ManagerProgram = "systemctl" | "launchctl" | "schtasks" | "powershell" | "taskkill"
|
|
35
47
|
|
|
36
48
|
/** One call to the user's service manager: the program and its words (never a shell string). */
|
|
37
49
|
export interface ManagerCall {
|
|
@@ -48,7 +60,7 @@ export const HEALTH_WINDOW = Object.freeze({ timeoutMs: 10_000, stableMs: 3_000,
|
|
|
48
60
|
/** What a state query says about a service. `detail` is the manager's own state words (never other output). */
|
|
49
61
|
export interface ServiceHealth {
|
|
50
62
|
readonly state: "running" | "starting" | "crash-looping" | "failed" | "stopped"
|
|
51
|
-
/** Restarts the manager counted (systemd NRestarts; launchd runs - 1). */
|
|
63
|
+
/** Restarts the manager counted (systemd NRestarts; launchd runs - 1; Task Scheduler counts none). */
|
|
52
64
|
readonly restarts: number
|
|
53
65
|
readonly detail: string
|
|
54
66
|
}
|
|
@@ -61,9 +73,9 @@ export interface Clock {
|
|
|
61
73
|
const realClock: Clock = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }
|
|
62
74
|
|
|
63
75
|
export interface ServiceRunState {
|
|
64
|
-
/** systemd: active; launchd: loaded in the user's GUI domain. */
|
|
76
|
+
/** systemd: active; launchd: loaded in the user's GUI domain; Task Scheduler: the task is registered. */
|
|
65
77
|
readonly loaded: boolean
|
|
66
|
-
/** systemd
|
|
78
|
+
/** systemd: enabled (started at login); Task Scheduler: the registered task is enabled. */
|
|
67
79
|
readonly enabled?: boolean
|
|
68
80
|
}
|
|
69
81
|
|
|
@@ -89,12 +101,50 @@ const labelOfTarget = (word: string | undefined) => {
|
|
|
89
101
|
return at > 0 && DOMAIN.test(word!.slice(0, at)) ? word!.slice(at + 1) : undefined
|
|
90
102
|
}
|
|
91
103
|
|
|
104
|
+
/** The Task XML files hubctl registers (task-defs file names). */
|
|
105
|
+
const TASK_FILES = new Set(Object.values(EMBEDDED_ASSETS).flatMap((a) => (a.manager === "task-scheduler" ? [a.installAs] : [])))
|
|
106
|
+
/** A native, drive-rooted Windows path to one of them. */
|
|
107
|
+
const TASK_FILE = /^[A-Z]:\\(?:[^\\/:*?"<>|]+\\)*([^\\/:*?"<>|]+)$/
|
|
108
|
+
|
|
109
|
+
/** The fixed PowerShell words of the state query of `name` (Schedule.Service: numeric state, the action's pids). */
|
|
110
|
+
export function taskQueryWords(name: string): readonly string[] {
|
|
111
|
+
if (!TASK_NAME.test(name)) throw new Error(`not a hub task name: ${JSON.stringify(name)}`)
|
|
112
|
+
const script =
|
|
113
|
+
"$ErrorActionPreference='Stop';$s=New-Object -ComObject Schedule.Service;$s.Connect();" +
|
|
114
|
+
`$t=$s.GetFolder('\\').GetTask('${name}');` +
|
|
115
|
+
"'state='+[int]$t.State;'enabled='+$t.Enabled;'result='+$t.LastTaskResult;" +
|
|
116
|
+
"foreach($r in $t.GetInstances(0)){'pid='+$r.EnginePID}"
|
|
117
|
+
return ["-NoProfile", "-NonInteractive", "-Command", script]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function checkWindowsCall(program: ManagerProgram, words: readonly string[]): boolean {
|
|
121
|
+
if (program === "taskkill") return words.length === 4 && words[0] === "/T" && words[1] === "/F" && words[2] === "/PID" && /^[1-9][0-9]{0,9}$/.test(words[3]!)
|
|
122
|
+
if (program === "powershell") {
|
|
123
|
+
const name = /GetTask\('([^']*)'\)/.exec(words[words.length - 1] ?? "")?.[1]
|
|
124
|
+
if (!name || !TASK_NAME.test(name)) return false
|
|
125
|
+
const expected = taskQueryWords(name)
|
|
126
|
+
return words.length === expected.length && words.every((w, i) => w === expected[i])
|
|
127
|
+
}
|
|
128
|
+
const [verb, tn, name] = words
|
|
129
|
+
if (tn !== "/TN" || !TASK_NAME.test(name ?? "")) return false
|
|
130
|
+
if (verb === "/Run" || verb === "/End") return words.length === 3
|
|
131
|
+
if (verb === "/Delete") return words.length === 4 && words[3] === "/F"
|
|
132
|
+
if (verb === "/Query") return words.length === 4 && words[3] === "/XML"
|
|
133
|
+
if (verb === "/Create") return words.length === 6 && words[3] === "/XML" && words[5] === "/F" && TASK_FILES.has(TASK_FILE.exec(words[4]!)?.[1] ?? "")
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const WINDOWS_PROGRAMS = new Set<string>(["schtasks", "powershell", "taskkill"])
|
|
138
|
+
|
|
92
139
|
/** Whether `call` is one of the fixed calls this client makes. */
|
|
93
140
|
export function checkManagerCall(call: unknown): call is ManagerCall {
|
|
94
|
-
if (!isRecord(call) || !Array.isArray(call.words)
|
|
141
|
+
if (!isRecord(call) || !Array.isArray(call.words)) return false
|
|
142
|
+
const windows = typeof call.program === "string" && WINDOWS_PROGRAMS.has(call.program)
|
|
143
|
+
if (call.words.length > (windows ? 6 : 4)) return false
|
|
95
144
|
const w = call.words as unknown[]
|
|
96
145
|
if (!w.every((x) => typeof x === "string" && x.length > 0 && x.length <= 4096 && !CONTROL.test(x))) return false
|
|
97
146
|
const words = w as string[]
|
|
147
|
+
if (windows) return checkWindowsCall(call.program as ManagerProgram, words)
|
|
98
148
|
if (call.program === "systemctl") {
|
|
99
149
|
if (words[0] !== "--user") return false
|
|
100
150
|
const verb = words[1]!
|
|
@@ -124,11 +174,36 @@ export function unitPathResolver(roots: readonly RootMapping[], context: Executi
|
|
|
124
174
|
}
|
|
125
175
|
}
|
|
126
176
|
|
|
177
|
+
/** The fields of the task state query (key=value lines). */
|
|
178
|
+
const taskFields = (output: string) => showFields(output.replaceAll("\r", ""))
|
|
179
|
+
|
|
180
|
+
/** The process ids of a task's running instances (the state query's pid= lines). */
|
|
181
|
+
const taskPids = (output: string): number[] =>
|
|
182
|
+
output
|
|
183
|
+
.replaceAll("\r", "")
|
|
184
|
+
.split("\n")
|
|
185
|
+
.flatMap((line) => (/^pid=([1-9][0-9]{0,9})$/.exec(line.trim()) ? [Number(line.trim().slice(4))] : []))
|
|
186
|
+
|
|
187
|
+
/** What the task state query says: TASK_STATE numbers (0 unknown, 1 disabled, 2 queued, 3 ready, 4 running). */
|
|
188
|
+
export function taskSchedulerHealth(output: string): ServiceHealth {
|
|
189
|
+
const fields = taskFields(output)
|
|
190
|
+
const state = /^[0-4]$/.test(fields.state ?? "") ? Number(fields.state) : undefined
|
|
191
|
+
const result = /^-?[0-9]{1,10}$/.test(fields.result ?? "") ? Number(fields.result) >>> 0 : undefined
|
|
192
|
+
const pid = taskPids(output)[0]
|
|
193
|
+
if (state === 4) return { state: "running", restarts: 0, detail: pid ? `running, pid ${pid}` : "running" }
|
|
194
|
+
if (state === 2) return { state: "starting", restarts: 0, detail: "queued" }
|
|
195
|
+
if (state === 1) return { state: "stopped", restarts: 0, detail: "disabled" }
|
|
196
|
+
if (state === 3) return { state: result === 0x41303 ? "starting" : "stopped", restarts: 0, detail: `ready, last result 0x${(result ?? 0).toString(16)}` }
|
|
197
|
+
return { state: "stopped", restarts: 0, detail: "unknown" }
|
|
198
|
+
}
|
|
199
|
+
|
|
127
200
|
export interface ServiceControlOptions {
|
|
128
201
|
readonly run: ManagerRunner
|
|
129
202
|
/** The user whose launchd GUI domain the agents load in (process.getuid()). */
|
|
130
203
|
readonly uid: number
|
|
131
204
|
readonly unitPath: (service: ServiceID, manager: ServiceManagerID) => string | undefined
|
|
205
|
+
/** Task Scheduler: the task name of each service for this user (taskNameFor); without it, tasks are refused. */
|
|
206
|
+
readonly taskName?: (service: ServiceID) => string | undefined
|
|
132
207
|
/** Time for the health window (injected in tests). */
|
|
133
208
|
readonly clock?: Clock
|
|
134
209
|
}
|
|
@@ -286,15 +361,73 @@ export function createServiceControl(options: ServiceControlOptions): NodeServic
|
|
|
286
361
|
},
|
|
287
362
|
}
|
|
288
363
|
|
|
364
|
+
const taskOf = (service: ServiceID): string | undefined => {
|
|
365
|
+
const name = options.taskName?.(service)
|
|
366
|
+
return name && TASK_NAME.test(name) ? name : undefined
|
|
367
|
+
}
|
|
368
|
+
const noTask = (service: ServiceID) => txFail("invalid", `this hubctl has no Task Scheduler task name for the ${service} service`)
|
|
369
|
+
const tasks = {
|
|
370
|
+
/** The state query: undefined when the task is not registered. */
|
|
371
|
+
async query(name: string): Promise<string | undefined> {
|
|
372
|
+
const r = await options.run(checked("powershell", ...taskQueryWords(name)))
|
|
373
|
+
return r.exitCode === 0 ? (r.output ?? "") : undefined
|
|
374
|
+
},
|
|
375
|
+
/** Ends each running instance's whole process tree (schtasks /End would end only the console host). */
|
|
376
|
+
async endTrees(name: string): Promise<void> {
|
|
377
|
+
for (const pid of taskPids((await tasks.query(name)) ?? "")) await call("taskkill", "/T", "/F", "/PID", String(pid))
|
|
378
|
+
},
|
|
379
|
+
async state(service: ServiceID): Promise<TxResult<ServiceRunState>> {
|
|
380
|
+
const name = taskOf(service)
|
|
381
|
+
if (!name) return noTask(service)
|
|
382
|
+
const r = await options.run(checked("schtasks", "/Query", "/TN", name, "/XML"))
|
|
383
|
+
if (r.exitCode !== 0) return txOk({ loaded: false, enabled: false })
|
|
384
|
+
const enabled = /<Settings>[\s\S]*?<Enabled>(true|false)<\/Enabled>/.exec(r.output ?? "")?.[1] !== "false"
|
|
385
|
+
return txOk({ loaded: true, enabled })
|
|
386
|
+
},
|
|
387
|
+
async activate(service: ServiceID): Promise<TxResult<void>> {
|
|
388
|
+
const name = taskOf(service)
|
|
389
|
+
if (!name) return noTask(service)
|
|
390
|
+
const file = options.unitPath(service, "task-scheduler")
|
|
391
|
+
if (!file) return txFail("invalid", `this host has no task-defs directory for ${name}`)
|
|
392
|
+
await tasks.endTrees(name) // a running node keeps the old definition and binary otherwise
|
|
393
|
+
const created = await call("schtasks", "/Create", "/TN", name, "/XML", toNativePath(file, "win32"), "/F")
|
|
394
|
+
if (created !== 0) return txFail("internal", `schtasks /Create of ${name} failed (exit ${created})`)
|
|
395
|
+
const run = await call("schtasks", "/Run", "/TN", name)
|
|
396
|
+
if (run !== 0) return txFail("internal", `schtasks /Run of ${name} failed (exit ${run})`)
|
|
397
|
+
return settle(() => tasks.health(service), name, `schtasks /Query /TN ${name} /V /FO LIST and the Task Scheduler history`)
|
|
398
|
+
},
|
|
399
|
+
async health(service: ServiceID): Promise<TxResult<ServiceHealth>> {
|
|
400
|
+
const name = taskOf(service)
|
|
401
|
+
if (!name) return noTask(service)
|
|
402
|
+
const output = await tasks.query(name)
|
|
403
|
+
return txOk(output === undefined ? { state: "stopped", restarts: 0, detail: "not registered" } : taskSchedulerHealth(output))
|
|
404
|
+
},
|
|
405
|
+
async deactivate(service: ServiceID): Promise<TxResult<void>> {
|
|
406
|
+
const name = taskOf(service)
|
|
407
|
+
if (!name) return noTask(service)
|
|
408
|
+
await tasks.endTrees(name)
|
|
409
|
+
await call("schtasks", "/End", "/TN", name) // not running is fine
|
|
410
|
+
await call("schtasks", "/Delete", "/TN", name, "/F") // not registered is fine
|
|
411
|
+
return (await call("schtasks", "/Query", "/TN", name, "/XML")) === 0 ? txFail("internal", `${name} is still registered after schtasks /Delete`) : ok
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Explicit dispatch: an unknown manager is an error, never another manager's calls. */
|
|
416
|
+
const by = <T>(manager: ServiceManagerID, calls: Readonly<Record<ServiceManagerID, () => Promise<TxResult<T>>>>): Promise<TxResult<T>> =>
|
|
417
|
+
guarded(() => {
|
|
418
|
+
const f = Object.hasOwn(calls, manager) ? calls[manager] : undefined
|
|
419
|
+
if (!f) throw new Error(`unknown service manager ${String(manager)}`)
|
|
420
|
+
return f()
|
|
421
|
+
})
|
|
289
422
|
const deactivate = (service: ServiceID, manager: ServiceManagerID) =>
|
|
290
|
-
|
|
423
|
+
by(manager, { launchd: () => launchd.deactivate(service), "systemd-user": () => systemd.restore(service, { loaded: false, enabled: false }), "task-scheduler": () => tasks.deactivate(service) })
|
|
291
424
|
return {
|
|
292
|
-
state: (service, manager) =>
|
|
293
|
-
activate: (service, manager) =>
|
|
425
|
+
state: (service, manager) => by(manager, { launchd: () => launchd.state(service), "systemd-user": () => systemd.state(service), "task-scheduler": () => tasks.state(service) }),
|
|
426
|
+
activate: (service, manager) => by(manager, { launchd: () => launchd.activate(service), "systemd-user": () => systemd.activate(service), "task-scheduler": () => tasks.activate(service) }),
|
|
294
427
|
deactivate,
|
|
295
428
|
restore: (service, manager, prior) =>
|
|
296
|
-
prior.loaded ?
|
|
297
|
-
health: (service, manager) =>
|
|
429
|
+
prior.loaded ? by(manager, { launchd: async () => ok, "systemd-user": () => systemd.restore(service, prior), "task-scheduler": async () => ok }) : deactivate(service, manager),
|
|
430
|
+
health: (service, manager) => by(manager, { launchd: () => launchd.health(service), "systemd-user": () => systemd.health(service), "task-scheduler": () => tasks.health(service) }),
|
|
298
431
|
}
|
|
299
432
|
}
|
|
300
433
|
|
|
@@ -308,12 +441,15 @@ export type Spawner = (
|
|
|
308
441
|
/** At most this much of a state query's output is kept. */
|
|
309
442
|
const MAX_OUTPUT = 64 * 1024
|
|
310
443
|
/** The calls whose output is read: the state queries. */
|
|
311
|
-
const capturesOutput = (call: ManagerCall) =>
|
|
444
|
+
const capturesOutput = (call: ManagerCall) =>
|
|
445
|
+
call.program === "systemctl" ? call.words[1] === "show" : call.program === "launchctl" ? call.words[0] === "print" : call.program === "powershell" || (call.program === "schtasks" && call.words[0] === "/Query")
|
|
312
446
|
|
|
313
447
|
export interface SpawnRunnerOptions {
|
|
314
|
-
/** Absolute paths of
|
|
315
|
-
|
|
316
|
-
|
|
448
|
+
/** Absolute paths of this host's programs (discovery's view of this context): systemctl and launchctl, or the
|
|
449
|
+
* Windows ones (schtasks.exe, powershell.exe, taskkill.exe under %SystemRoot%\System32). */
|
|
450
|
+
readonly programs: Readonly<Partial<Record<ManagerProgram, string>>>
|
|
451
|
+
/** The caller's environment; only the user bus address (systemctl --user) and, on Windows, the few variables the
|
|
452
|
+
* system programs need are passed on. */
|
|
317
453
|
readonly env?: Readonly<Record<string, string | undefined>>
|
|
318
454
|
readonly timeoutMs?: number
|
|
319
455
|
/** Injected in tests; the default spawns with node:child_process (never with a shell). */
|
|
@@ -321,10 +457,12 @@ export interface SpawnRunnerOptions {
|
|
|
321
457
|
}
|
|
322
458
|
|
|
323
459
|
const BUS_ENV = ["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] as const
|
|
460
|
+
/** What schtasks and PowerShell need to start (no PATH: every program is absolute). */
|
|
461
|
+
const WINDOWS_ENV = ["SystemRoot", "windir", "SystemDrive", "TEMP", "TMP", "USERPROFILE", "LOCALAPPDATA", "APPDATA", "USERNAME", "USERDOMAIN"] as const
|
|
324
462
|
|
|
325
463
|
const defaultSpawn: Spawner = (file, words, options) =>
|
|
326
464
|
new Promise((resolve) => {
|
|
327
|
-
const child = nodeSpawn(file, [...words], { shell: false, env: { ...options.env }, stdio: ["ignore", options.capture ? "pipe" : "ignore", "ignore"], timeout: options.timeoutMs, killSignal: "SIGKILL" })
|
|
465
|
+
const child = nodeSpawn(file, [...words], { shell: false, windowsHide: true, env: { ...options.env }, stdio: ["ignore", options.capture ? "pipe" : "ignore", "ignore"], timeout: options.timeoutMs, killSignal: "SIGKILL" })
|
|
328
466
|
let output = ""
|
|
329
467
|
child.stdout?.setEncoding("utf8")
|
|
330
468
|
child.stdout?.on("data", (chunk: string) => {
|
|
@@ -337,18 +475,23 @@ const defaultSpawn: Spawner = (file, words, options) =>
|
|
|
337
475
|
|
|
338
476
|
/** The real runner: checked calls only, absolute programs, no shell, no inherited environment. */
|
|
339
477
|
export function spawnManagerRunner(options: SpawnRunnerOptions): ManagerRunner {
|
|
340
|
-
|
|
478
|
+
const given = Object.keys(options.programs) as ManagerProgram[]
|
|
479
|
+
const windows = given.some((p) => WINDOWS_PROGRAMS.has(p))
|
|
480
|
+
for (const program of windows ? given : (["systemctl", "launchctl"] as const)) {
|
|
341
481
|
const file = options.programs[program]
|
|
342
|
-
|
|
482
|
+
const base = file === undefined ? "" : path.basename(file)
|
|
483
|
+
if (!isAbsolutePath(file) || (base !== program && base.toLowerCase() !== `${program}.exe`)) throw new Error(`${program} must be given as an absolute path to ${program}`)
|
|
343
484
|
}
|
|
344
485
|
const env: Record<string, string> = {}
|
|
345
|
-
for (const key of BUS_ENV) if (options.env?.[key]) env[key] = options.env[key]!
|
|
486
|
+
for (const key of windows ? WINDOWS_ENV : BUS_ENV) if (options.env?.[key]) env[key] = options.env[key]!
|
|
346
487
|
const spawn = options.spawn ?? defaultSpawn
|
|
347
488
|
const timeoutMs = options.timeoutMs ?? 30_000
|
|
348
489
|
return async (call: ManagerCall) => {
|
|
349
490
|
if (!checkManagerCall(call)) throw new Error(`refused a service manager call outside the fixed set (${String((call as { program?: unknown }).program)})`)
|
|
350
491
|
const capture = capturesOutput(call)
|
|
351
|
-
const
|
|
492
|
+
const file = options.programs[call.program]
|
|
493
|
+
if (!file) throw new Error(`this host's service manager does not use ${call.program}`)
|
|
494
|
+
const r = await spawn(file, [...call.words], { shell: false, env, timeoutMs, ...(capture ? { capture: true as const } : {}) })
|
|
352
495
|
if (typeof r === "number") return { exitCode: r }
|
|
353
496
|
return capture ? { exitCode: r.exitCode, output: r.output.slice(0, MAX_OUTPUT) } : { exitCode: r.exitCode }
|
|
354
497
|
}
|
|
@@ -21,9 +21,10 @@ import {
|
|
|
21
21
|
type ProgramLayout,
|
|
22
22
|
type Result,
|
|
23
23
|
type ServiceID,
|
|
24
|
+
type ServiceManagerID,
|
|
24
25
|
} from "../../contracts/index.ts"
|
|
25
26
|
import type { PlanContributor } from "../../preview/plan.ts"
|
|
26
|
-
import { renderServiceUnit, SERVICE_ENVIRONMENT, type RenderedUnit } from "./render.ts"
|
|
27
|
+
import { renderServiceUnit, SERVICE_ENVIRONMENT, type RenderedUnit, type TaskIdentity } from "./render.ts"
|
|
27
28
|
import { checkNodeSettings, inside, serviceContextOf, type NodeSettings, type ServiceContext } from "./validate.ts"
|
|
28
29
|
|
|
29
30
|
export interface NodeServicesOptions {
|
|
@@ -39,6 +40,8 @@ export interface NodeServicesOptions {
|
|
|
39
40
|
readonly environment?: Readonly<Record<string, string | undefined>>
|
|
40
41
|
/** One bounded verification launch, inside a workspace root (needs `activate` and a launch-enabled profile). */
|
|
41
42
|
readonly launch?: { readonly harness: HarnessID; readonly workspace: PathRef }
|
|
43
|
+
/** Task Scheduler (Windows): the node's task name and the user's SID (the runtime finds them; never a default). */
|
|
44
|
+
readonly task?: TaskIdentity
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
export interface NodeServicesPlan {
|
|
@@ -54,6 +57,7 @@ export interface NodeServicesPlan {
|
|
|
54
57
|
export const NODE_CONFIG_REF: PathRef = { root: "config", rel: "hub/node.json" }
|
|
55
58
|
const CONFIG_DIR: PathRef = { root: "config", rel: "hub" }
|
|
56
59
|
|
|
60
|
+
const UNIT_KIND: Readonly<Record<ServiceManagerID, string>> = { launchd: "launchd agent", "systemd-user": "systemd --user unit", "task-scheduler": "logon task" }
|
|
57
61
|
const LABEL: Readonly<Record<ServiceID, string>> = { node: "hub node", "claude-code": "Claude Code spoke", "codex-spoke": "Codex spoke" }
|
|
58
62
|
const SERVICE_HARNESS: Readonly<Partial<Record<ServiceID, HarnessID>>> = Object.fromEntries(Object.entries(HARNESS_SERVICE).map(([h, s]) => [s, h]))
|
|
59
63
|
const BIN_SETTING: Readonly<Partial<Record<HarnessID, string>>> = { "claude-code": "HUB_CLAUDE_BIN", codex: "HUB_CODEX_BIN" }
|
|
@@ -132,9 +136,19 @@ export function planNodeServices(input: PlanInput, options: NodeServicesOptions)
|
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
const units: RenderedUnit[] = []
|
|
139
|
+
const uncarried = new Set<string>()
|
|
135
140
|
for (const service of profile.services) {
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
let environment = environmentFor(service, servedBy(service, profile.harnesses), ctx, options.environment)
|
|
142
|
+
if (ctx.manager === "task-scheduler") {
|
|
143
|
+
// A task has no environment of its own: say what this shell sets that the node will not see.
|
|
144
|
+
for (const key of Object.keys(environment)) uncarried.add(key)
|
|
145
|
+
environment = {}
|
|
146
|
+
if (!options.task) {
|
|
147
|
+
issues.push({ path: `/services/${service}/task`, code: "missing_field", message: "a Windows logon task needs its name and your SID; hubctl could not find them (USERNAME, whoami)" })
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const unit = renderServiceUnit({ service, manager: ctx.manager, layout: options.layout, home: ctx.home, environment, ...(ctx.manager === "task-scheduler" ? { task: options.task! } : {}) })
|
|
138
152
|
if (!unit.ok) {
|
|
139
153
|
issues.push(...prefixed(`/services/${service}`, unit))
|
|
140
154
|
continue
|
|
@@ -144,7 +158,7 @@ export function planNodeServices(input: PlanInput, options: NodeServicesOptions)
|
|
|
144
158
|
id: `install-${service}-service`,
|
|
145
159
|
kind: "install-service",
|
|
146
160
|
escalation: "user",
|
|
147
|
-
summary: `Write the ${LABEL[service]} ${ctx.manager
|
|
161
|
+
summary: `Write the ${LABEL[service]} ${UNIT_KIND[ctx.manager]} (${unit.value.name})`,
|
|
148
162
|
service,
|
|
149
163
|
manager: ctx.manager,
|
|
150
164
|
path: unit.value.path,
|
|
@@ -175,7 +189,7 @@ export function planNodeServices(input: PlanInput, options: NodeServicesOptions)
|
|
|
175
189
|
if (!valid.ok) issues.push(...prefixed(`/actions/${action.id}`, valid))
|
|
176
190
|
}
|
|
177
191
|
if (issues.length > 0) return failed(issues)
|
|
178
|
-
return { ok: true, value: { actions, units, ...(settings ? { settings } : {}), context: ctx, notes: notesFor(options, ctx) } }
|
|
192
|
+
return { ok: true, value: { actions, units, ...(settings ? { settings } : {}), context: ctx, notes: notesFor(options, ctx, [...uncarried].sort()) } }
|
|
179
193
|
}
|
|
180
194
|
|
|
181
195
|
function checkLaunch(input: PlanInput, options: NodeServicesOptions, ctx: ServiceContext, settings: NodeSettings | undefined): Issue[] {
|
|
@@ -191,10 +205,13 @@ function checkLaunch(input: PlanInput, options: NodeServicesOptions, ctx: Servic
|
|
|
191
205
|
return []
|
|
192
206
|
}
|
|
193
207
|
|
|
194
|
-
function notesFor(options: NodeServicesOptions, ctx: ServiceContext): string[] {
|
|
208
|
+
function notesFor(options: NodeServicesOptions, ctx: ServiceContext, uncarried: readonly string[]): string[] {
|
|
195
209
|
const notes: string[] = []
|
|
196
210
|
if (!options.activate) notes.push("The services are installed but not loaded: pass --confirm-activation to load them (hubctl never loads a service without it).")
|
|
197
|
-
if (ctx.manager === "
|
|
211
|
+
if (ctx.manager === "task-scheduler") {
|
|
212
|
+
notes.push("The hub node runs as a logon task for you (no window, no administrator rights, no stored password): Windows starts it at each logon and again within a minute after it exits; it runs while you are logged in.")
|
|
213
|
+
if (uncarried.length > 0) notes.push(`The logon task does not see ${uncarried.join(", ")} from this shell (a task has no environment of its own); set them as user environment variables if the node needs them.`)
|
|
214
|
+
} else if (ctx.manager === "systemd-user")
|
|
198
215
|
notes.push(
|
|
199
216
|
options.linger
|
|
200
217
|
? "To keep the services running while you are logged out, run `loginctl enable-linger` yourself; hubctl does not change login settings."
|