@indigoai-us/hq-cli 5.125.0 → 5.126.0
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 +118 -0
- package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
- package/assets/bot-workers/setup/skills/your-business.md +270 -0
- package/assets/bot-workers/setup/worker.yaml +189 -58
- package/dist/command-catalog.generated.d.ts +407 -4
- package/dist/command-catalog.generated.js +522 -4
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +6 -0
- package/dist/commands/lanes.d.ts +75 -0
- package/dist/commands/lanes.js +2032 -0
- package/dist/commands/mesh.js +37 -9
- package/dist/commands/onboard-create-identity.d.ts +26 -0
- package/dist/commands/onboard-create-identity.js +23 -0
- package/dist/commands/onboard.js +13 -4
- package/dist/lib/bot/progress.d.ts +6 -0
- package/dist/lib/bot/progress.js +16 -0
- package/dist/lib/bot/runtime/grok.d.ts +30 -7
- package/dist/lib/bot/runtime/grok.js +86 -8
- package/dist/lib/bot/runtime/index.js +15 -8
- package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
- package/dist/lib/bot/runtime/messages-stream.js +49 -3
- package/dist/lib/lanes/accounts.d.ts +43 -0
- package/dist/lib/lanes/accounts.js +128 -0
- package/dist/lib/lanes/config.d.ts +56 -0
- package/dist/lib/lanes/config.js +351 -0
- package/dist/lib/lanes/errors.d.ts +39 -0
- package/dist/lib/lanes/errors.js +72 -0
- package/dist/lib/lanes/index.d.ts +19 -0
- package/dist/lib/lanes/index.js +12 -0
- package/dist/lib/lanes/liveness.d.ts +89 -0
- package/dist/lib/lanes/liveness.js +273 -0
- package/dist/lib/lanes/registry.d.ts +103 -0
- package/dist/lib/lanes/registry.js +247 -0
- package/dist/lib/lanes/store.d.ts +64 -0
- package/dist/lib/lanes/store.js +627 -0
- package/dist/lib/lanes/throttle.d.ts +89 -0
- package/dist/lib/lanes/throttle.js +297 -0
- package/dist/lib/lanes/types.d.ts +193 -0
- package/dist/lib/lanes/types.js +217 -0
- package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/config.js +75 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
- package/dist/lib/mesh/live/daemon/index.js +3 -3
- package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
- package/dist/lib/mesh/live/daemon/install.js +444 -2
- package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/paths.js +2 -0
- package/dist/lib/scan-packages/index.js +17 -12
- package/dist/lib/work-context/config.d.ts +20 -0
- package/dist/lib/work-context/config.js +26 -5
- package/dist/lib/work-context/reconcile.js +28 -7
- package/dist/lib/work-context/windows-acl.d.ts +123 -0
- package/dist/lib/work-context/windows-acl.js +425 -0
- package/dist/utils/pack-contributions.js +5 -4
- package/package.json +1 -1
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* - macOS: LaunchAgent ai.getindigo.hq-mesh-daemon
|
|
5
5
|
* - Linux: systemd user unit hq-mesh-daemon.service
|
|
6
6
|
* (documented fallback: root-owned unit running as ec2-user)
|
|
7
|
+
* - Windows: per-user Scheduled Task ai.getindigo.hq-mesh-daemon registered
|
|
8
|
+
* from a rendered task XML via schtasks.exe (no native dependencies)
|
|
7
9
|
*
|
|
8
10
|
* The unit embeds the hq binary resolved at install time. Logs go to
|
|
9
11
|
* ~/.hq/work-mesh/daemon/log (rotation at 5MB handled by the daemon).
|
|
@@ -14,8 +16,39 @@ import * as os from "node:os";
|
|
|
14
16
|
import * as path from "node:path";
|
|
15
17
|
import { spawnSync } from "node:child_process";
|
|
16
18
|
import { resolveDaemonAssetDir } from "./log.js";
|
|
17
|
-
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, daemonDir, daemonLogPath, } from "./paths.js";
|
|
18
|
-
import { registerTrustedHqRoot } from "./config.js";
|
|
19
|
+
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
|
|
20
|
+
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot } from "./config.js";
|
|
21
|
+
import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
22
|
+
/**
|
|
23
|
+
* `schtasks /End` terminates the task's root process only. The daemon is
|
|
24
|
+
* launched through `conhost.exe --headless`, so ending the task kills conhost
|
|
25
|
+
* and leaves node running as an orphan that still holds the pid lock. Stop
|
|
26
|
+
* the recorded daemon pid explicitly. Returns the pid that was signalled, or
|
|
27
|
+
* null when no live daemon pid was recorded.
|
|
28
|
+
*/
|
|
29
|
+
export function stopWindowsDaemonProcess(dir, deps = {}) {
|
|
30
|
+
const record = readPidLock(daemonPidPath(dir), {
|
|
31
|
+
readFileSync: deps.readFileSync,
|
|
32
|
+
existsSync: deps.existsSync,
|
|
33
|
+
});
|
|
34
|
+
if (!record || record.pid === (deps.selfPid ?? process.pid))
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
(deps.kill ?? ((pid) => process.kill(pid)))(record.pid);
|
|
38
|
+
return record.pid;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export const defaultSchtasks = (args) => {
|
|
45
|
+
const r = spawnSync("schtasks.exe", args, { encoding: "utf8", windowsHide: true });
|
|
46
|
+
return {
|
|
47
|
+
status: r.status,
|
|
48
|
+
stdout: r.stdout ?? "",
|
|
49
|
+
stderr: r.error ? r.error.message : (r.stderr ?? ""),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
19
52
|
/**
|
|
20
53
|
* Restart the installed user daemon after a CLI upgrade. This is deliberately
|
|
21
54
|
* best-effort: an update must remain successful if a user has no service,
|
|
@@ -53,6 +86,16 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
|
|
|
53
86
|
}
|
|
54
87
|
return { attempted: false, restarted: false };
|
|
55
88
|
}
|
|
89
|
+
if (platform === "win32") {
|
|
90
|
+
const dir = daemonDir(undefined, home);
|
|
91
|
+
const xml = path.join(dir, `${WINDOWS_TASK_NAME}.xml`);
|
|
92
|
+
if (!existsSync(xml))
|
|
93
|
+
return { attempted: false, restarted: false };
|
|
94
|
+
run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME]);
|
|
95
|
+
stopWindowsDaemonProcess(dir, { existsSync, kill: opts.kill });
|
|
96
|
+
const result = run("schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME]);
|
|
97
|
+
return { attempted: true, restarted: result.status === 0 };
|
|
98
|
+
}
|
|
56
99
|
}
|
|
57
100
|
catch {
|
|
58
101
|
// The caller has already completed the update; service restart failure is
|
|
@@ -65,6 +108,8 @@ export function detectPlatform(platform = process.platform) {
|
|
|
65
108
|
return "darwin";
|
|
66
109
|
if (platform === "linux")
|
|
67
110
|
return "linux";
|
|
111
|
+
if (platform === "win32")
|
|
112
|
+
return "win32";
|
|
68
113
|
return "other";
|
|
69
114
|
}
|
|
70
115
|
/**
|
|
@@ -128,10 +173,71 @@ export function buildInstallPaths(opts) {
|
|
|
128
173
|
plistPath: path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`),
|
|
129
174
|
systemdUserDir: path.join(home, ".config", "systemd", "user"),
|
|
130
175
|
systemdUnitPath: path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME),
|
|
176
|
+
windowsTaskXmlPath: path.join(ddir, `${WINDOWS_TASK_NAME}.xml`),
|
|
177
|
+
windowsUser: opts.windowsUser ?? resolveWindowsUser(env),
|
|
178
|
+
windowsHeadlessLauncher: opts.windowsHeadlessLauncher === undefined
|
|
179
|
+
? resolveWindowsHeadlessLauncher(env)
|
|
180
|
+
: (opts.windowsHeadlessLauncher ?? undefined),
|
|
131
181
|
systemUser: opts.systemUser,
|
|
132
182
|
hqRoot: opts.hqRoot,
|
|
183
|
+
hqRootSource: opts.hqRootSource,
|
|
133
184
|
};
|
|
134
185
|
}
|
|
186
|
+
/** `DOMAIN\user` for the current account, as Task Scheduler expects. */
|
|
187
|
+
export function resolveWindowsUser(env = process.env) {
|
|
188
|
+
const user = env.USERNAME?.trim() || safeUsername();
|
|
189
|
+
const domain = env.USERDOMAIN?.trim();
|
|
190
|
+
return domain ? `${domain}\\${user}` : user;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* `conhost.exe --headless` is undocumented. It shipped with the ConPTY
|
|
194
|
+
* conhost in Windows 10 1809 (build 17763); older conhost builds reject the
|
|
195
|
+
* flag and the task would fail on every logon. Gate on the kernel build
|
|
196
|
+
* reported by os.release() ("10.0.<build>") plus a presence check.
|
|
197
|
+
*/
|
|
198
|
+
export const WINDOWS_CONHOST_HEADLESS_MIN_BUILD = 17763;
|
|
199
|
+
/** Build number from an os.release() string such as "10.0.26200"; null if unparsable. */
|
|
200
|
+
export function parseWindowsBuild(release) {
|
|
201
|
+
const m = /^\d+\.\d+\.(\d+)/.exec(release.trim());
|
|
202
|
+
if (!m)
|
|
203
|
+
return null;
|
|
204
|
+
const build = Number(m[1]);
|
|
205
|
+
return Number.isFinite(build) ? build : null;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* `conhost.exe --headless` (Windows 10 1809+) runs a console app with no
|
|
209
|
+
* window. Returns the launcher path only when conhost.exe exists under
|
|
210
|
+
* SystemRoot and the host build is new enough to honour `--headless`.
|
|
211
|
+
*/
|
|
212
|
+
export function resolveWindowsHeadlessLauncher(env = process.env, existsSync = fs.existsSync, release = os.release()) {
|
|
213
|
+
const build = parseWindowsBuild(release);
|
|
214
|
+
if (build === null || build < WINDOWS_CONHOST_HEADLESS_MIN_BUILD)
|
|
215
|
+
return undefined;
|
|
216
|
+
const systemRoot = env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows";
|
|
217
|
+
const candidate = path.join(systemRoot, "System32", "conhost.exe");
|
|
218
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
219
|
+
}
|
|
220
|
+
/** Which launch wrapper the rendered task uses (recorded in the install message). */
|
|
221
|
+
export function windowsLaunchMode(paths) {
|
|
222
|
+
return paths.windowsHeadlessLauncher ? "conhost-headless" : "direct";
|
|
223
|
+
}
|
|
224
|
+
/** The task uses the interactive token, so it exists only inside the user's logon session. */
|
|
225
|
+
export function windowsLogonNotice(paths) {
|
|
226
|
+
return `runs at logon of ${paths.windowsUser}; not running while logged out`;
|
|
227
|
+
}
|
|
228
|
+
function describeWindowsLaunchMode(paths) {
|
|
229
|
+
return windowsLaunchMode(paths) === "conhost-headless"
|
|
230
|
+
? `mode: conhost --headless via ${paths.windowsHeadlessLauncher}`
|
|
231
|
+
: "mode: direct node (conhost --headless unavailable on this host; a console window may appear at logon)";
|
|
232
|
+
}
|
|
233
|
+
function safeUsername() {
|
|
234
|
+
try {
|
|
235
|
+
return os.userInfo().username;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return "";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
135
241
|
export function renderLaunchdPlist(paths) {
|
|
136
242
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
137
243
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -224,6 +330,224 @@ StandardError=append:/home/${user}/.hq/work-mesh/daemon/log
|
|
|
224
330
|
WantedBy=multi-user.target
|
|
225
331
|
`;
|
|
226
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Windows Task Scheduler definition (schtasks /Create /XML). Runs at logon of
|
|
335
|
+
* the registering user, hidden, no execution time limit, one instance, and
|
|
336
|
+
* restarts every minute on failure. HOME/USERPROFILE come from the user's own
|
|
337
|
+
* session. Task Scheduler XML has no environment block, so the HQ root the
|
|
338
|
+
* install was run for is passed explicitly as `mesh daemon run --hq-root
|
|
339
|
+
* <path>` (the launchd/systemd equivalent of Environment=HQ_ROOT) and is also
|
|
340
|
+
* persisted in the daemon config as a trusted root.
|
|
341
|
+
*
|
|
342
|
+
* LogonType is InteractiveToken: S4U ("run whether user is logged on or not")
|
|
343
|
+
* needs elevation to register, and the interactive token is what an
|
|
344
|
+
* unprivileged `hq mesh daemon install` can create. As a consequence the task
|
|
345
|
+
* runs only while the registering user is logged on. The console window that
|
|
346
|
+
* an interactive-token task would otherwise open is avoided by launching
|
|
347
|
+
* through `conhost.exe --headless` when the host supports it (see
|
|
348
|
+
* resolveWindowsHeadlessLauncher); otherwise node is run directly.
|
|
349
|
+
*/
|
|
350
|
+
export function renderWindowsTaskXml(paths) {
|
|
351
|
+
const user = escapeXml(paths.windowsUser);
|
|
352
|
+
const nodeArg = windowsArgQuote(paths.nodeBinary);
|
|
353
|
+
const hqArg = windowsArgQuote(paths.hqBinary);
|
|
354
|
+
const command = paths.windowsHeadlessLauncher ?? paths.nodeBinary;
|
|
355
|
+
const rootArgs = paths.hqRoot ? ` --hq-root ${windowsArgQuote(paths.hqRoot)}` : "";
|
|
356
|
+
const args = paths.windowsHeadlessLauncher
|
|
357
|
+
? `--headless ${nodeArg} ${hqArg} mesh daemon run${rootArgs}`
|
|
358
|
+
: `${hqArg} mesh daemon run${rootArgs}`;
|
|
359
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
360
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
361
|
+
<RegistrationInfo>
|
|
362
|
+
<URI>\\${WINDOWS_TASK_NAME}</URI>
|
|
363
|
+
<Description>HQ Work Mesh Live daemon (spool drainer + MQTT presence)</Description>
|
|
364
|
+
</RegistrationInfo>
|
|
365
|
+
<Triggers>
|
|
366
|
+
<LogonTrigger>
|
|
367
|
+
<Enabled>true</Enabled>
|
|
368
|
+
<UserId>${user}</UserId>
|
|
369
|
+
</LogonTrigger>
|
|
370
|
+
</Triggers>
|
|
371
|
+
<Principals>
|
|
372
|
+
<Principal id="Author">
|
|
373
|
+
<UserId>${user}</UserId>
|
|
374
|
+
<LogonType>${WINDOWS_TASK_LOGON_TYPE}</LogonType>
|
|
375
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
376
|
+
</Principal>
|
|
377
|
+
</Principals>
|
|
378
|
+
<Settings>
|
|
379
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
380
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
381
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
382
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
383
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
384
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
385
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
386
|
+
<Enabled>true</Enabled>
|
|
387
|
+
<Hidden>true</Hidden>
|
|
388
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
389
|
+
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
|
|
390
|
+
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
|
|
391
|
+
<WakeToRun>false</WakeToRun>
|
|
392
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
393
|
+
<Priority>7</Priority>
|
|
394
|
+
<RestartOnFailure>
|
|
395
|
+
<Interval>PT1M</Interval>
|
|
396
|
+
<Count>999</Count>
|
|
397
|
+
</RestartOnFailure>
|
|
398
|
+
</Settings>
|
|
399
|
+
<Actions Context="Author">
|
|
400
|
+
<Exec>
|
|
401
|
+
<Command>${escapeXml(command)}</Command>
|
|
402
|
+
<Arguments>${escapeXml(args)}</Arguments>
|
|
403
|
+
<WorkingDirectory>${escapeXml(paths.daemonDir)}</WorkingDirectory>
|
|
404
|
+
</Exec>
|
|
405
|
+
</Actions>
|
|
406
|
+
</Task>
|
|
407
|
+
`;
|
|
408
|
+
}
|
|
409
|
+
const WINDOWS_TASK_LOGON_TYPE = "InteractiveToken";
|
|
410
|
+
/** schtasks stores task XML as UTF-16 LE with BOM; match it so /XML parses. */
|
|
411
|
+
export function encodeWindowsTaskXml(rendered) {
|
|
412
|
+
return Buffer.from(`\uFEFF${rendered}`, "utf16le");
|
|
413
|
+
}
|
|
414
|
+
function windowsArgQuote(value) {
|
|
415
|
+
if (/^[A-Za-z0-9_.:\\/-]+$/.test(value))
|
|
416
|
+
return value;
|
|
417
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
418
|
+
}
|
|
419
|
+
/** Status column of `schtasks /Query /FO CSV /NH`: "TaskName","Next Run Time","Status". */
|
|
420
|
+
export function parseSchtasksCsvStatus(stdout) {
|
|
421
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
422
|
+
const cells = line.match(/"([^"]*)"/g);
|
|
423
|
+
if (!cells || cells.length < 3)
|
|
424
|
+
continue;
|
|
425
|
+
return cells[2].slice(1, -1).trim();
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
/** Split one CSV line as schtasks emits it (all cells quoted, `""` escapes a quote). */
|
|
430
|
+
function splitCsvLine(line) {
|
|
431
|
+
const cells = [];
|
|
432
|
+
let cur = "";
|
|
433
|
+
let inQuotes = false;
|
|
434
|
+
for (let i = 0; i < line.length; i++) {
|
|
435
|
+
const ch = line[i];
|
|
436
|
+
if (inQuotes) {
|
|
437
|
+
if (ch === '"') {
|
|
438
|
+
if (line[i + 1] === '"') {
|
|
439
|
+
cur += '"';
|
|
440
|
+
i++;
|
|
441
|
+
}
|
|
442
|
+
else
|
|
443
|
+
inQuotes = false;
|
|
444
|
+
}
|
|
445
|
+
else
|
|
446
|
+
cur += ch;
|
|
447
|
+
}
|
|
448
|
+
else if (ch === '"')
|
|
449
|
+
inQuotes = true;
|
|
450
|
+
else if (ch === ",") {
|
|
451
|
+
cells.push(cur);
|
|
452
|
+
cur = "";
|
|
453
|
+
}
|
|
454
|
+
else
|
|
455
|
+
cur += ch;
|
|
456
|
+
}
|
|
457
|
+
cells.push(cur);
|
|
458
|
+
return cells;
|
|
459
|
+
}
|
|
460
|
+
/** Column index of "Last Result" in `schtasks /Query /V /FO CSV` (fixed order, header localized). */
|
|
461
|
+
const SCHTASKS_LAST_RESULT_COLUMN = 6;
|
|
462
|
+
/**
|
|
463
|
+
* "Last Result" of `schtasks /Query /TN <name> /V /FO CSV`, normalized to an
|
|
464
|
+
* unsigned 32-bit code (schtasks prints HRESULT/NTSTATUS values as signed
|
|
465
|
+
* decimals, e.g. -2147024894 for 0x80070002). Prefers the English header
|
|
466
|
+
* when present; otherwise uses the fixed column position. Null when the
|
|
467
|
+
* output carries no task row or the cell is not numeric.
|
|
468
|
+
*/
|
|
469
|
+
export function parseSchtasksLastResult(stdout) {
|
|
470
|
+
const lines = stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
471
|
+
if (lines.length === 0)
|
|
472
|
+
return null;
|
|
473
|
+
let column = SCHTASKS_LAST_RESULT_COLUMN;
|
|
474
|
+
let rows = lines;
|
|
475
|
+
const header = splitCsvLine(lines[0]);
|
|
476
|
+
const headerIdx = header.findIndex((h) => /^last result$/i.test(h.trim()));
|
|
477
|
+
if (headerIdx >= 0 || /^hostname$/i.test(header[0]?.trim() ?? "")) {
|
|
478
|
+
if (headerIdx >= 0)
|
|
479
|
+
column = headerIdx;
|
|
480
|
+
rows = lines.slice(1);
|
|
481
|
+
}
|
|
482
|
+
for (const line of rows) {
|
|
483
|
+
const cells = splitCsvLine(line);
|
|
484
|
+
if (cells.length <= column)
|
|
485
|
+
continue;
|
|
486
|
+
const raw = cells[column].trim();
|
|
487
|
+
if (!/^-?\d+$/.test(raw))
|
|
488
|
+
continue;
|
|
489
|
+
return Number(raw) >>> 0;
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Task Scheduler informational results mean "no failed start": 0 is success,
|
|
495
|
+
* and every SCHED_S_* code (ready, running, not yet run, terminated, queued,
|
|
496
|
+
* ...) is a success-severity HRESULT (bit 31 clear) in FACILITY_ITF (0x4)
|
|
497
|
+
* with code 0x1300-0x13FF, i.e. 0x00041300-0x000413FF. Everything else
|
|
498
|
+
* (0x8007xxxx, 0xC000xxxx, process exit codes such as 1) is a failed start.
|
|
499
|
+
*/
|
|
500
|
+
export function schtasksLastResultFailed(code) {
|
|
501
|
+
if (code === null || code === 0)
|
|
502
|
+
return false;
|
|
503
|
+
const severityFailed = (code & 0x80000000) !== 0;
|
|
504
|
+
const facility = (code >>> 16) & 0x7ff;
|
|
505
|
+
const lowCode = code & 0xffff;
|
|
506
|
+
if (!severityFailed && facility === 0x4 && lowCode >= 0x1300 && lowCode <= 0x13ff)
|
|
507
|
+
return false;
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
/** `--hq-root <path>` from a rendered task's <Arguments> (XML-escaped, optionally quoted). */
|
|
511
|
+
export function parseWindowsTaskHqRoot(xml) {
|
|
512
|
+
const m = /<Arguments>([^<]*)<\/Arguments>/.exec(xml);
|
|
513
|
+
if (!m)
|
|
514
|
+
return null;
|
|
515
|
+
const args = unescapeXml(m[1]);
|
|
516
|
+
const quoted = /--hq-root "((?:[^"\\]|\\.)*)"/.exec(args);
|
|
517
|
+
if (quoted)
|
|
518
|
+
return quoted[1].replace(/\\"/g, '"');
|
|
519
|
+
const bare = /--hq-root (\S+)/.exec(args);
|
|
520
|
+
return bare ? bare[1] : null;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* The HQ root the installed Windows task actually carries: the task XML on
|
|
524
|
+
* disk first, else the first persisted trusted root. Reads state only; never
|
|
525
|
+
* resolves from cwd, so status reports what install recorded.
|
|
526
|
+
*/
|
|
527
|
+
export function readRecordedWindowsHqRoot(paths, deps = {}) {
|
|
528
|
+
const existsSync = deps.existsSync ?? fs.existsSync;
|
|
529
|
+
const readFileSync = deps.readFileSync ?? fs.readFileSync;
|
|
530
|
+
try {
|
|
531
|
+
if (existsSync(paths.windowsTaskXmlPath)) {
|
|
532
|
+
const raw = readFileSync(paths.windowsTaskXmlPath);
|
|
533
|
+
const text = Buffer.isBuffer(raw) ? raw.toString("utf16le").replace(/^\uFEFF/, "") : String(raw);
|
|
534
|
+
const fromXml = parseWindowsTaskHqRoot(text);
|
|
535
|
+
if (fromXml)
|
|
536
|
+
return fromXml;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
// fall through to config
|
|
541
|
+
}
|
|
542
|
+
return readDaemonConfig(paths.daemonDir).trustedHqRoots[0] ?? null;
|
|
543
|
+
}
|
|
544
|
+
function unescapeXml(value) {
|
|
545
|
+
return value
|
|
546
|
+
.replace(/"/g, '"')
|
|
547
|
+
.replace(/</g, "<")
|
|
548
|
+
.replace(/>/g, ">")
|
|
549
|
+
.replace(/&/g, "&");
|
|
550
|
+
}
|
|
227
551
|
function escapeXml(value) {
|
|
228
552
|
return value
|
|
229
553
|
.replace(/&/g, "&")
|
|
@@ -292,6 +616,41 @@ export function installDaemonService(opts) {
|
|
|
292
616
|
message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}`,
|
|
293
617
|
};
|
|
294
618
|
}
|
|
619
|
+
if (platform === "win32") {
|
|
620
|
+
const schtasks = opts.schtasks ?? defaultSchtasks;
|
|
621
|
+
const rendered = renderWindowsTaskXml(paths);
|
|
622
|
+
writeFileSync(paths.windowsTaskXmlPath, encodeWindowsTaskXml(rendered), { mode: 0o600 });
|
|
623
|
+
const created = schtasks([
|
|
624
|
+
"/Create", "/TN", WINDOWS_TASK_NAME, "/XML", paths.windowsTaskXmlPath, "/F",
|
|
625
|
+
]);
|
|
626
|
+
if (created.status !== 0) {
|
|
627
|
+
throw new Error(`schtasks /Create failed for ${WINDOWS_TASK_NAME} (exit ${created.status ?? "signal"}): ` +
|
|
628
|
+
`${(created.stderr || created.stdout).trim()}`);
|
|
629
|
+
}
|
|
630
|
+
// /F replaced any previous registration without ending its instance; stop
|
|
631
|
+
// the recorded daemon so the fresh task instance can take the pid lock.
|
|
632
|
+
schtasks(["/End", "/TN", WINDOWS_TASK_NAME]);
|
|
633
|
+
stopWindowsDaemonProcess(paths.daemonDir, { kill: opts.kill });
|
|
634
|
+
const started = schtasks(["/Run", "/TN", WINDOWS_TASK_NAME]);
|
|
635
|
+
const running = started.status === 0;
|
|
636
|
+
const mode = describeWindowsLaunchMode(paths);
|
|
637
|
+
const rootLine = paths.hqRoot
|
|
638
|
+
? `HQ root: ${paths.hqRoot}${paths.hqRootSource ? ` (from ${paths.hqRootSource})` : ""}\n`
|
|
639
|
+
: "";
|
|
640
|
+
return {
|
|
641
|
+
platform,
|
|
642
|
+
action: "install",
|
|
643
|
+
paths,
|
|
644
|
+
rendered,
|
|
645
|
+
dest: paths.windowsTaskXmlPath,
|
|
646
|
+
running,
|
|
647
|
+
message: running
|
|
648
|
+
? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})`
|
|
649
|
+
: `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (${mode}); ` +
|
|
650
|
+
`start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon.`,
|
|
651
|
+
notice: windowsLogonNotice(paths),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
295
654
|
return {
|
|
296
655
|
platform,
|
|
297
656
|
action: "install",
|
|
@@ -304,6 +663,30 @@ export function uninstallDaemonService(opts) {
|
|
|
304
663
|
const paths = opts.paths ?? buildInstallPaths({});
|
|
305
664
|
const unlinkSync = opts.unlinkSync ?? fs.unlinkSync;
|
|
306
665
|
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
666
|
+
if (platform === "win32") {
|
|
667
|
+
const schtasks = opts.schtasks ?? defaultSchtasks;
|
|
668
|
+
const registered = schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]).status === 0;
|
|
669
|
+
if (registered) {
|
|
670
|
+
schtasks(["/End", "/TN", WINDOWS_TASK_NAME]);
|
|
671
|
+
stopWindowsDaemonProcess(paths.daemonDir, { existsSync, kill: opts.kill });
|
|
672
|
+
const deleted = schtasks(["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"]);
|
|
673
|
+
if (deleted.status !== 0) {
|
|
674
|
+
throw new Error(`schtasks /Delete failed for ${WINDOWS_TASK_NAME} (exit ${deleted.status ?? "signal"}): ` +
|
|
675
|
+
`${(deleted.stderr || deleted.stdout).trim()}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (existsSync(paths.windowsTaskXmlPath))
|
|
679
|
+
unlinkSync(paths.windowsTaskXmlPath);
|
|
680
|
+
return {
|
|
681
|
+
platform,
|
|
682
|
+
action: "uninstall",
|
|
683
|
+
paths,
|
|
684
|
+
dest: paths.windowsTaskXmlPath,
|
|
685
|
+
message: registered
|
|
686
|
+
? `Removed Scheduled Task ${WINDOWS_TASK_NAME}`
|
|
687
|
+
: `Scheduled Task ${WINDOWS_TASK_NAME} was not registered; removed local task XML if present`,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
307
690
|
if (platform === "darwin") {
|
|
308
691
|
if (existsSync(paths.plistPath)) {
|
|
309
692
|
opts.deactivate?.(platform, paths.plistPath);
|
|
@@ -345,6 +728,65 @@ export function daemonServiceStatus(opts) {
|
|
|
345
728
|
const platform = opts.platform ?? detectPlatform();
|
|
346
729
|
const paths = opts.paths ?? buildInstallPaths({});
|
|
347
730
|
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
731
|
+
if (platform === "win32") {
|
|
732
|
+
const schtasks = opts.schtasks ?? defaultSchtasks;
|
|
733
|
+
// schtasks only tells us whether the task exists. Its Status column is
|
|
734
|
+
// localized ("Running" / "Wird ausgeführt" / ...), so the daemon pid lock
|
|
735
|
+
// is the source of truth for "running"; the column is shown when it
|
|
736
|
+
// happens to carry a value, as extra context only.
|
|
737
|
+
const query = schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]);
|
|
738
|
+
const installed = query.status === 0;
|
|
739
|
+
const taskState = installed ? parseSchtasksCsvStatus(query.stdout) : null;
|
|
740
|
+
// The verbose query carries "Last Result": a non-zero, non-informational
|
|
741
|
+
// code means the last start failed (for example conhost rejecting
|
|
742
|
+
// --headless), which the pid lock alone would report as merely "not
|
|
743
|
+
// running". Surface it so a failed start never looks like a clean idle.
|
|
744
|
+
const verbose = installed
|
|
745
|
+
? schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/V", "/FO", "CSV"])
|
|
746
|
+
: null;
|
|
747
|
+
const lastResult = verbose && verbose.status === 0 ? parseSchtasksLastResult(verbose.stdout) : null;
|
|
748
|
+
const lastRunFailed = schtasksLastResultFailed(lastResult);
|
|
749
|
+
const lock = pidLockStatus(daemonPidPath(paths.daemonDir), {
|
|
750
|
+
isProcessAlive: opts.isProcessAlive ?? defaultPidLockDeps().isProcessAlive,
|
|
751
|
+
readFileSync: opts.readFileSync,
|
|
752
|
+
existsSync,
|
|
753
|
+
});
|
|
754
|
+
const running = lock === "held";
|
|
755
|
+
// Read the root the task actually carries and re-check it: a moved,
|
|
756
|
+
// renamed, or deleted HQ tree leaves the task pointing at nothing.
|
|
757
|
+
const recordedHqRoot = installed
|
|
758
|
+
? readRecordedWindowsHqRoot(paths, { existsSync, readFileSync: opts.readFileSync })
|
|
759
|
+
: null;
|
|
760
|
+
const rootValid = recordedHqRoot ? isInstallableHqRoot(recordedHqRoot) : undefined;
|
|
761
|
+
const detail = (taskState ? ` [task: ${taskState}]` : "") +
|
|
762
|
+
(lastRunFailed ? ` [last run failed: result ${lastResult}]` : "") +
|
|
763
|
+
(rootValid === false ? ` [recorded root no longer qualifies: ${recordedHqRoot}]` : "");
|
|
764
|
+
let message;
|
|
765
|
+
if (!installed) {
|
|
766
|
+
message = running
|
|
767
|
+
? `Scheduled Task ${WINDOWS_TASK_NAME}: not installed (daemon running outside the task)`
|
|
768
|
+
: `Scheduled Task ${WINDOWS_TASK_NAME}: not installed`;
|
|
769
|
+
}
|
|
770
|
+
else if (running) {
|
|
771
|
+
message = `Scheduled Task ${WINDOWS_TASK_NAME}: running${detail}`;
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
message = `Scheduled Task ${WINDOWS_TASK_NAME}: installed (daemon not running${lock === "stale" ? ", stale pid lock" : ""})${detail}`;
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
platform,
|
|
778
|
+
action: "status",
|
|
779
|
+
paths,
|
|
780
|
+
dest: paths.windowsTaskXmlPath,
|
|
781
|
+
running,
|
|
782
|
+
message,
|
|
783
|
+
lastResult,
|
|
784
|
+
lastRunFailed,
|
|
785
|
+
notice: installed ? windowsLogonNotice(paths) : undefined,
|
|
786
|
+
recordedHqRoot,
|
|
787
|
+
rootValid,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
348
790
|
if (platform === "darwin") {
|
|
349
791
|
const installed = existsSync(paths.plistPath);
|
|
350
792
|
const running = installed
|
|
@@ -9,6 +9,8 @@ export declare const DAEMON_LOG_NAME = "log";
|
|
|
9
9
|
export declare const DAEMON_LOG_MAX_BYTES: number;
|
|
10
10
|
export declare const LAUNCHD_LABEL = "ai.getindigo.hq-mesh-daemon";
|
|
11
11
|
export declare const SYSTEMD_UNIT_NAME = "hq-mesh-daemon.service";
|
|
12
|
+
/** Windows per-user Scheduled Task name (same label as the LaunchAgent). */
|
|
13
|
+
export declare const WINDOWS_TASK_NAME = "ai.getindigo.hq-mesh-daemon";
|
|
12
14
|
export declare function daemonDir(meshRoot?: string, home?: string, env?: NodeJS.ProcessEnv): string;
|
|
13
15
|
export declare function daemonPidPath(dir?: string): string;
|
|
14
16
|
export declare function daemonStatePath(dir?: string): string;
|
|
@@ -12,6 +12,8 @@ export const DAEMON_LOG_NAME = "log";
|
|
|
12
12
|
export const DAEMON_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
13
13
|
export const LAUNCHD_LABEL = "ai.getindigo.hq-mesh-daemon";
|
|
14
14
|
export const SYSTEMD_UNIT_NAME = "hq-mesh-daemon.service";
|
|
15
|
+
/** Windows per-user Scheduled Task name (same label as the LaunchAgent). */
|
|
16
|
+
export const WINDOWS_TASK_NAME = "ai.getindigo.hq-mesh-daemon";
|
|
15
17
|
export function daemonDir(meshRoot, home = os.homedir(), env = process.env) {
|
|
16
18
|
return path.join(meshRoot ?? workMeshRoot(home, env), DAEMON_DIRNAME);
|
|
17
19
|
}
|
|
@@ -163,34 +163,39 @@ function ensureSymlink(link, info, warn) {
|
|
|
163
163
|
warn(`payload missing: ${link.src} (declared but not shipped)`);
|
|
164
164
|
return;
|
|
165
165
|
}
|
|
166
|
+
const relativeSrc = path.relative(path.dirname(link.dst), link.src);
|
|
166
167
|
fs.mkdirSync(path.dirname(link.dst), { recursive: true });
|
|
167
168
|
try {
|
|
168
169
|
const stat = fs.lstatSync(link.dst);
|
|
169
170
|
if (stat.isSymbolicLink()) {
|
|
170
171
|
const existing = fs.readlinkSync(link.dst);
|
|
171
|
-
|
|
172
|
+
// Correct form is relative to the host directory so a tree can move
|
|
173
|
+
// between machines. An absolute match (this host, pre-relative installer)
|
|
174
|
+
// is ours and converges. A dangling leftover from another HQ root is
|
|
175
|
+
// not host content and is rewritten the same way.
|
|
176
|
+
if (existing === relativeSrc)
|
|
172
177
|
return;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
if (existing === link.src) {
|
|
179
|
+
fs.rmSync(link.dst, { force: true });
|
|
180
|
+
fs.symlinkSync(relativeSrc, link.dst);
|
|
181
|
+
info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing})`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
179
184
|
// Only a lookup that specifically says "not there" earns a relink.
|
|
180
185
|
// fs.existsSync also answers false for EACCES, which would read content
|
|
181
186
|
// this user merely cannot traverse as absent and delete the link to it.
|
|
182
187
|
const lookup = targetLookup(link.dst);
|
|
183
188
|
if (lookup === 'absent') {
|
|
184
189
|
fs.rmSync(link.dst, { force: true });
|
|
185
|
-
fs.symlinkSync(
|
|
186
|
-
info(`relinked ${link.dst} -> ${
|
|
190
|
+
fs.symlinkSync(relativeSrc, link.dst);
|
|
191
|
+
info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing}, which does not exist here)`);
|
|
187
192
|
return;
|
|
188
193
|
}
|
|
189
194
|
if (lookup !== 'present') {
|
|
190
195
|
warn(`collision: ${link.dst} points at ${existing}, which could not be checked (${lookup}) — leaving it alone`);
|
|
191
196
|
return;
|
|
192
197
|
}
|
|
193
|
-
warn(`collision: ${link.dst} already points at ${existing} (wanted ${
|
|
198
|
+
warn(`collision: ${link.dst} already points at ${existing} (wanted ${relativeSrc}) — skipping`);
|
|
194
199
|
return;
|
|
195
200
|
}
|
|
196
201
|
warn(`collision: ${link.dst} exists as regular file/dir — host content wins, skipping`);
|
|
@@ -200,8 +205,8 @@ function ensureSymlink(link, info, warn) {
|
|
|
200
205
|
if (error.code !== 'ENOENT')
|
|
201
206
|
throw error;
|
|
202
207
|
}
|
|
203
|
-
fs.symlinkSync(
|
|
204
|
-
info(`linked ${link.dst} -> ${
|
|
208
|
+
fs.symlinkSync(relativeSrc, link.dst);
|
|
209
|
+
info(`linked ${link.dst} -> ${relativeSrc}`);
|
|
205
210
|
}
|
|
206
211
|
/**
|
|
207
212
|
* The link text, when `entry` is a symlink whose target is definitively not
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Device-local default company preference (~/.hq/work-context/config.json).
|
|
3
3
|
* Preference only — never authority. activeCompany is never treated as default.
|
|
4
4
|
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import { type WindowsAclCheckOptions } from "./windows-acl.js";
|
|
5
7
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
6
8
|
export declare const DEVICE_CONFIG_SCHEMA_VERSION: 1;
|
|
7
9
|
export interface DeviceDefaultCompany {
|
|
@@ -81,6 +83,24 @@ export declare function acquireDeviceConfigLock(root: string, options?: {
|
|
|
81
83
|
timeoutMs?: number;
|
|
82
84
|
staleMs?: number;
|
|
83
85
|
}): () => void;
|
|
86
|
+
/** Injection points for assertSafeConfigPath (tests only). */
|
|
87
|
+
export interface SafeConfigPathIo {
|
|
88
|
+
platform?: NodeJS.Platform;
|
|
89
|
+
existsSync?: (p: string) => boolean;
|
|
90
|
+
lstatSync?: (p: string) => Pick<fs.Stats, "mode" | "isSymbolicLink">;
|
|
91
|
+
windowsAcl?: WindowsAclCheckOptions;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Refuse a config path that is a symlink (every platform) or writable by
|
|
95
|
+
* anyone other than the owner.
|
|
96
|
+
*
|
|
97
|
+
* - POSIX (darwin/linux/...): `mode & 0o022` must be clear. Unchanged.
|
|
98
|
+
* - win32: NTFS has no POSIX mode bits; Node reports 0666 for every regular
|
|
99
|
+
* file, so the mode test would reject every config (US-018). We read the
|
|
100
|
+
* real DACL instead (see windows-acl.ts). Unreadable DACL → documented
|
|
101
|
+
* reduced-guarantee path: one diagnostic line, no throw.
|
|
102
|
+
*/
|
|
103
|
+
export declare function assertSafeConfigPath(configPath: string, io?: SafeConfigPathIo): void;
|
|
84
104
|
export declare function readDeviceConfig(deps: Pick<DeviceConfigDeps, "root">): WorkContextDeviceConfig;
|
|
85
105
|
export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">): DeviceDefaultCompany | null;
|
|
86
106
|
/** A clear is a durable opt-out, not an invitation for automatic reactivation. */
|