@indigoai-us/hq-cli 5.341.2 → 5.341.4

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/hqd/windows-pipe-host.ps1 +282 -0
  3. package/dist/commands/billing-sponsor.js +20 -1
  4. package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
  5. package/dist/commands/core-derive-trigger-facts-native.js +92 -25
  6. package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
  7. package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
  8. package/dist/commands/doctor.js +2 -2
  9. package/dist/lib/aws-sdk-node-warning-output.js +2 -3
  10. package/dist/lib/billing/plan-lock.js +19 -2
  11. package/dist/lib/bot/owner-context.d.ts +2 -0
  12. package/dist/lib/bot/owner-context.js +19 -1
  13. package/dist/lib/daemon/flag-monitor.d.ts +5 -0
  14. package/dist/lib/daemon/flag-monitor.js +14 -1
  15. package/dist/lib/daemon/protocol.d.ts +26 -3
  16. package/dist/lib/daemon/protocol.js +9 -5
  17. package/dist/lib/daemon/remote-control.d.ts +7 -0
  18. package/dist/lib/daemon/remote-control.js +135 -2
  19. package/dist/lib/daemon/run.js +14 -16
  20. package/dist/lib/daemon/server.d.ts +13 -0
  21. package/dist/lib/daemon/server.js +283 -28
  22. package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
  23. package/dist/lib/daemon/windows-pipe-host.js +218 -0
  24. package/dist/lib/diagnostics.d.ts +9 -0
  25. package/dist/lib/diagnostics.js +12 -0
  26. package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
  27. package/dist/lib/doctor/checks/anywhere.js +131 -13
  28. package/dist/lib/doctor/json-output.d.ts +2 -0
  29. package/dist/lib/doctor/json-output.js +1 -0
  30. package/dist/lib/doctor/registry.js +1 -3
  31. package/dist/lib/doctor/types.d.ts +4 -0
  32. package/dist/utils/run-bundled-script.d.ts +2 -0
  33. package/dist/utils/run-bundled-script.js +10 -1
  34. package/docs/anywhere-runtime.md +32 -5
  35. package/package.json +2 -1
@@ -9,6 +9,7 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as net from "node:net";
11
11
  import * as path from "node:path";
12
+ import { performance } from "node:perf_hooks";
12
13
  import { CLI_VERSION } from "../../cli-version.js";
13
14
  import { canonicalDir, emptyRegistry, readRegistry, registryFile, resolveCompanyForDir } from "../registry/registry.js";
14
15
  import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
@@ -24,6 +25,7 @@ import { HQ_MCP_TOOL_NAMES, HQD_NATIVE_TOOL_NAMES } from "../mcp/tools.js";
24
25
  import { HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
25
26
  import { createBoundedDaemonFlagMonitor } from "./flag-monitor.js";
26
27
  import { readDaemonFlag } from "./flag.js";
28
+ import { startWindowsPipeHost, validateWindowsPipeAcl, WINDOWS_PIPE_STARTUP_REFUSAL } from "./windows-pipe-host.js";
27
29
  // The allowlist is deliberately read-only. A newly added hosted MCP tool must
28
30
  // pass hq.policy_check until it is explicitly reviewed as read-only here.
29
31
  const MCP_READ_ONLY_TOOL_NAMES = new Set([
@@ -49,6 +51,47 @@ const MCP_READ_ONLY_TOOL_NAMES = new Set([
49
51
  "hq_version",
50
52
  "hq_whoami",
51
53
  ]);
54
+ function healthResolution(result) {
55
+ if (result.company) {
56
+ // A manifest key is expected to be a single company slug. Do not echo
57
+ // arbitrary registry text through the diagnostics surface.
58
+ if (/^[a-z0-9][a-z0-9-]{0,62}$/.test(result.company)) {
59
+ return { scope: "company", company: result.company, fallbackReason: null };
60
+ }
61
+ return { scope: "personal", company: null, fallbackReason: "company_not_in_manifest" };
62
+ }
63
+ let fallbackReason = "no_match";
64
+ const reason = "reason" in result ? result.reason : "";
65
+ if (reason.startsWith("HQ registry is unavailable"))
66
+ fallbackReason = "registry_unavailable";
67
+ else if (reason.startsWith("HQ company manifest is unavailable"))
68
+ fallbackReason = "manifest_missing_or_malformed";
69
+ else if (reason.startsWith("Registry company "))
70
+ fallbackReason = "company_not_in_manifest";
71
+ return { scope: "personal", company: null, fallbackReason };
72
+ }
73
+ function healthSocket(socketPath, isPipe) {
74
+ if (isPipe) {
75
+ return { path: socketPath, transport: "windows_named_pipe", mode: null, ownerMatchesUser: null, userOnly: null };
76
+ }
77
+ try {
78
+ const stat = fs.lstatSync(socketPath);
79
+ const mode = stat.mode & 0o777;
80
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
81
+ const ownerMatchesUser = uid !== null && stat.uid === uid;
82
+ const userOnly = stat.isSocket() && ownerMatchesUser && (mode & 0o077) === 0;
83
+ return {
84
+ path: socketPath,
85
+ transport: "unix_socket",
86
+ mode: `0${mode.toString(8).padStart(3, "0")}`,
87
+ ownerMatchesUser,
88
+ userOnly,
89
+ };
90
+ }
91
+ catch {
92
+ return { path: socketPath, transport: "unix_socket", mode: null, ownerMatchesUser: null, userOnly: false };
93
+ }
94
+ }
52
95
  class OpError extends Error {
53
96
  code;
54
97
  constructor(code, message) {
@@ -164,9 +207,134 @@ function socketIsLive(socketPath) {
164
207
  setTimeout(() => done(false), 500).unref();
165
208
  });
166
209
  }
210
+ function socketLstatOrMissing(socketPath, lstat) {
211
+ try {
212
+ return lstat(socketPath);
213
+ }
214
+ catch (err) {
215
+ if (err.code === "ENOENT")
216
+ return null;
217
+ const detail = err instanceof Error ? err.message : String(err);
218
+ throw new Error(`${socketPath}: cannot inspect filesystem entry: ${detail}`);
219
+ }
220
+ }
221
+ function assertTrustedSocketPathComponents(directory, lstat, uid) {
222
+ const resolved = path.resolve(directory);
223
+ const { root } = path.parse(resolved);
224
+ let current = root;
225
+ for (const component of resolved.slice(root.length).split(path.sep).filter(Boolean)) {
226
+ current = path.join(current, component);
227
+ const stat = socketLstatOrMissing(current, lstat);
228
+ if (!stat)
229
+ return;
230
+ if (stat.isSymbolicLink())
231
+ throw new Error(`${current}: path component is a symlink`);
232
+ if (stat.uid !== uid && stat.uid !== 0 && (stat.mode & 0o022) !== 0) {
233
+ throw new Error(`${current}: path component is owned by another uid and writable by others`);
234
+ }
235
+ }
236
+ }
237
+ function assertPrivateSocketDirectory(directory, stat, uid) {
238
+ if (stat.isSymbolicLink())
239
+ throw new Error(`${directory}: socket directory must not be a symlink`);
240
+ if (!stat.isDirectory())
241
+ throw new Error(`${directory}: socket directory is not a directory`);
242
+ if (stat.uid !== uid)
243
+ throw new Error(`${directory}: socket directory is not owned by the current uid`);
244
+ if ((stat.mode & 0o077) !== 0)
245
+ throw new Error(`${directory}: socket directory has group or other permission bits`);
246
+ }
247
+ function assertOwnedUnixSocket(socketPath, stat, uid) {
248
+ if (stat.isSymbolicLink())
249
+ throw new Error(`${socketPath}: existing path is a symlink`);
250
+ if (stat.isDirectory())
251
+ throw new Error(`${socketPath}: existing path is a directory`);
252
+ if (!stat.isSocket())
253
+ throw new Error(`${socketPath}: existing path is not a Unix socket`);
254
+ if (stat.uid !== uid)
255
+ throw new Error(`${socketPath}: existing Unix socket is not owned by the current uid`);
256
+ }
257
+ function unlinkOwnedSocketIfPresent(socketPath, lstat, uid, log) {
258
+ try {
259
+ assertTrustedSocketPathComponents(path.dirname(socketPath), lstat, uid);
260
+ const stat = socketLstatOrMissing(socketPath, lstat);
261
+ if (!stat)
262
+ return;
263
+ assertOwnedUnixSocket(socketPath, stat, uid);
264
+ fs.unlinkSync(socketPath);
265
+ }
266
+ catch (err) {
267
+ log("warn", err instanceof Error ? err.message : `${socketPath}: could not safely remove socket path`);
268
+ }
269
+ }
270
+ function prepareSocketDirectory(directory, lstat, uid) {
271
+ assertTrustedSocketPathComponents(directory, lstat, uid);
272
+ let stat = socketLstatOrMissing(directory, lstat);
273
+ let created = false;
274
+ if (!stat) {
275
+ try {
276
+ created = fs.mkdirSync(directory, { recursive: true, mode: 0o700 }) !== undefined;
277
+ }
278
+ catch (err) {
279
+ const detail = err instanceof Error ? err.message : String(err);
280
+ throw new Error(`${directory}: cannot create socket directory: ${detail}`);
281
+ }
282
+ assertTrustedSocketPathComponents(directory, lstat, uid);
283
+ stat = socketLstatOrMissing(directory, lstat);
284
+ if (!stat)
285
+ throw new Error(`${directory}: socket directory was not created`);
286
+ }
287
+ assertPrivateSocketDirectory(directory, stat, uid);
288
+ if (!created)
289
+ return;
290
+ try {
291
+ fs.chmodSync(directory, 0o700);
292
+ }
293
+ catch (err) {
294
+ const detail = err instanceof Error ? err.message : String(err);
295
+ throw new Error(`${directory}: cannot set newly created socket directory mode to 0700: ${detail}`);
296
+ }
297
+ assertTrustedSocketPathComponents(directory, lstat, uid);
298
+ const verified = socketLstatOrMissing(directory, lstat);
299
+ if (!verified)
300
+ throw new Error(`${directory}: socket directory disappeared during permission verification`);
301
+ assertPrivateSocketDirectory(directory, verified, uid);
302
+ if ((verified.mode & 0o7777) !== 0o700) {
303
+ throw new Error(`${directory}: newly created socket directory mode is not 0700 after chmod`);
304
+ }
305
+ }
306
+ function verifyUserOnlySocket(socketPath, lstat, uid) {
307
+ assertTrustedSocketPathComponents(path.dirname(socketPath), lstat, uid);
308
+ const before = socketLstatOrMissing(socketPath, lstat);
309
+ if (!before)
310
+ throw new Error(`${socketPath}: Unix socket is missing after listen`);
311
+ assertOwnedUnixSocket(socketPath, before, uid);
312
+ if ((before.mode & 0o7777) !== 0o600) {
313
+ try {
314
+ fs.chmodSync(socketPath, 0o600);
315
+ }
316
+ catch (err) {
317
+ const detail = err instanceof Error ? err.message : String(err);
318
+ throw new Error(`${socketPath}: cannot set Unix socket mode to 0600: ${detail}`);
319
+ }
320
+ }
321
+ assertTrustedSocketPathComponents(path.dirname(socketPath), lstat, uid);
322
+ const after = socketLstatOrMissing(socketPath, lstat);
323
+ if (!after)
324
+ throw new Error(`${socketPath}: Unix socket disappeared during permission verification`);
325
+ assertOwnedUnixSocket(socketPath, after, uid);
326
+ if ((after.mode & 0o7777) !== 0o600) {
327
+ throw new Error(`${socketPath}: Unix socket mode is not 0600 after chmod`);
328
+ }
329
+ }
167
330
  export async function startHqdServer(opts) {
168
- const { socketPath } = opts;
169
- const isPipe = process.platform === "win32";
331
+ const isPipe = (opts.platform ?? process.platform) === "win32";
332
+ // Normalize before checking or binding so `..` cannot make the kernel traverse
333
+ // a symlink that the lexical component check has already removed.
334
+ const socketPath = isPipe ? opts.socketPath : path.resolve(opts.socketPath);
335
+ const lstatSocketPath = opts.socketLstat ?? ((candidate) => fs.lstatSync(candidate));
336
+ const currentUid = !isPipe ? process.getuid?.() : undefined;
337
+ const startedMonotonicMs = performance.now();
170
338
  const startedAtMs = (opts.now ?? (() => new Date()))().getTime();
171
339
  const sessions = new SessionRegistry(opts.now);
172
340
  const log = opts.log ?? (() => { });
@@ -203,11 +371,28 @@ export async function startHqdServer(opts) {
203
371
  };
204
372
  let hqMcp = opts.mcpBridge;
205
373
  if (!isPipe) {
206
- fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
207
- if (fs.existsSync(socketPath)) {
374
+ const directory = path.dirname(socketPath);
375
+ if (currentUid === undefined)
376
+ throw new Error(`${directory}: cannot verify current uid for Unix socket directory`);
377
+ prepareSocketDirectory(directory, lstatSocketPath, currentUid);
378
+ const existing = socketLstatOrMissing(socketPath, lstatSocketPath);
379
+ if (existing) {
380
+ assertOwnedUnixSocket(socketPath, existing, currentUid);
208
381
  if (await socketIsLive(socketPath))
209
382
  throw new Error(`hqd is already listening on ${socketPath}`);
210
- fs.unlinkSync(socketPath);
383
+ const beforeUnlink = socketLstatOrMissing(socketPath, lstatSocketPath);
384
+ if (beforeUnlink) {
385
+ assertOwnedUnixSocket(socketPath, beforeUnlink, currentUid);
386
+ if (await socketIsLive(socketPath))
387
+ throw new Error(`hqd is already listening on ${socketPath}`);
388
+ try {
389
+ fs.unlinkSync(socketPath);
390
+ }
391
+ catch (err) {
392
+ const detail = err instanceof Error ? err.message : String(err);
393
+ throw new Error(`${socketPath}: cannot remove stale owned Unix socket: ${detail}`);
394
+ }
395
+ }
211
396
  }
212
397
  }
213
398
  const runtimeFlagMonitor = opts.mcpEnabled ? undefined : createBoundedDaemonFlagMonitor({
@@ -226,6 +411,21 @@ export async function startHqdServer(opts) {
226
411
  },
227
412
  });
228
413
  const mcpEnabled = opts.mcpEnabled ?? (() => runtimeFlagMonitor.enabled());
414
+ const health = async (cwd) => {
415
+ const flag = runtimeFlagMonitor?.status() ?? {
416
+ enabled: await mcpEnabled(),
417
+ lastSuccessfulReadAgeMs: null,
418
+ heldThroughReadError: false,
419
+ };
420
+ return {
421
+ reachable: true,
422
+ version: opts.version ?? CLI_VERSION,
423
+ uptimeMs: Math.max(0, Math.round(performance.now() - startedMonotonicMs)),
424
+ flag,
425
+ resolution: healthResolution(resolve(cwd)),
426
+ socket: healthSocket(socketPath, isPipe),
427
+ };
428
+ };
229
429
  /** Synchronous ops answer in request order; policy.check and mcp.call may return a promise. */
230
430
  const handle = (req) => {
231
431
  const args = req.args ?? {};
@@ -238,6 +438,8 @@ export async function startHqdServer(opts) {
238
438
  pid: process.pid,
239
439
  sessions: sessions.size,
240
440
  };
441
+ case "health":
442
+ return health(stringArg(args, "cwd"));
241
443
  case "auth.status":
242
444
  return Promise.resolve(mcpEnabled()).then((enabled) => {
243
445
  if (!enabled)
@@ -471,7 +673,7 @@ export async function startHqdServer(opts) {
471
673
  socket.write(encodeMessage(msg));
472
674
  };
473
675
  const clients = new Set();
474
- const server = net.createServer((socket) => {
676
+ const accept = (socket) => {
475
677
  clients.add(socket);
476
678
  socket.on("close", () => clients.delete(socket));
477
679
  socket.on("error", (err) => log("warn", `hqd client error: ${err.message}`));
@@ -527,24 +729,76 @@ export async function startHqdServer(opts) {
527
729
  socket.end();
528
730
  }, HQD_MAX_LINE_BYTES);
529
731
  socket.on("data", push);
530
- });
531
- await new Promise((resolveListen, rejectListen) => {
532
- server.once("error", rejectListen);
533
- // Bind with a user-only umask so the socket is never briefly group/world accessible.
534
- const prior = isPipe ? null : process.umask(0o177);
732
+ };
733
+ if (!isPipe) {
734
+ if (currentUid === undefined)
735
+ throw new Error(`${path.dirname(socketPath)}: cannot verify current uid for Unix socket directory`);
736
+ assertTrustedSocketPathComponents(path.dirname(socketPath), lstatSocketPath, currentUid);
737
+ const directoryStat = socketLstatOrMissing(path.dirname(socketPath), lstatSocketPath);
738
+ if (!directoryStat)
739
+ throw new Error(`${path.dirname(socketPath)}: socket directory is missing before listen`);
740
+ assertPrivateSocketDirectory(path.dirname(socketPath), directoryStat, currentUid);
741
+ }
742
+ let server = null;
743
+ let windowsPipeHost = null;
744
+ if (isPipe) {
745
+ let started;
535
746
  try {
536
- server.listen(socketPath, () => {
537
- server.off("error", rejectListen);
538
- resolveListen();
747
+ const startHost = opts.windowsPipeHostFactory ?? startWindowsPipeHost;
748
+ started = await startHost(socketPath);
749
+ (opts.windowsPipeAclCheck ?? validateWindowsPipeAcl)(started.acl);
750
+ windowsPipeHost = started.host;
751
+ windowsPipeHost.on("connection", accept);
752
+ windowsPipeHost.on("failure", (err) => {
753
+ log("error", `hqd Windows named-pipe helper failed: ${err.message}`);
754
+ for (const client of clients)
755
+ client.destroy(err);
756
+ opts.onShutdown?.();
757
+ });
758
+ windowsPipeHost.on("rejectedRemoteClient", (message) => {
759
+ log("warn", `hqd rejected a remote Windows named-pipe client: ${message}`);
539
760
  });
761
+ windowsPipeHost.startAccepting();
540
762
  }
541
- finally {
542
- if (prior !== null)
763
+ catch (err) {
764
+ if (started)
765
+ await started.host.close().catch((closeErr) => {
766
+ log("warn", `hqd Windows named-pipe helper did not close after security setup failed: ${closeErr.message}`);
767
+ });
768
+ policyIndex?.close();
769
+ hqMcp?.close();
770
+ log("error", `hqd Windows named-pipe security setup failed: ${err.message}`);
771
+ throw new Error(WINDOWS_PIPE_STARTUP_REFUSAL);
772
+ }
773
+ }
774
+ else {
775
+ server = net.createServer(accept);
776
+ await new Promise((resolveListen, rejectListen) => {
777
+ server.once("error", rejectListen);
778
+ // Bind with a user-only umask so the socket is never briefly group/world accessible.
779
+ const prior = process.umask(0o177);
780
+ try {
781
+ server.listen(socketPath, () => {
782
+ server.off("error", rejectListen);
783
+ resolveListen();
784
+ });
785
+ }
786
+ finally {
543
787
  process.umask(prior);
788
+ }
789
+ });
790
+ }
791
+ if (!isPipe) {
792
+ try {
793
+ verifyUserOnlySocket(socketPath, lstatSocketPath, currentUid);
544
794
  }
545
- });
546
- if (!isPipe)
547
- fs.chmodSync(socketPath, 0o600);
795
+ catch (err) {
796
+ await new Promise((resolveClose) => server.close(() => resolveClose()));
797
+ if (currentUid !== undefined)
798
+ unlinkOwnedSocketIfPresent(socketPath, lstatSocketPath, currentUid, log);
799
+ throw err;
800
+ }
801
+ }
548
802
  log("info", `hqd listening on ${socketPath}`);
549
803
  let closing = null;
550
804
  return {
@@ -554,19 +808,20 @@ export async function startHqdServer(opts) {
554
808
  policyIndex?.close();
555
809
  runtimeFlagMonitor?.close();
556
810
  hqMcp?.close();
557
- server.close(() => done());
558
811
  // Answered-but-idle clients would otherwise hold close() open.
559
812
  for (const client of clients)
560
813
  client.destroy();
561
- if (!isPipe) {
562
- try {
563
- fs.unlinkSync(socketPath);
564
- }
565
- catch (err) {
566
- if (err.code !== "ENOENT")
567
- log("warn", `could not remove ${socketPath}: ${err.message}`);
568
- }
814
+ if (windowsPipeHost) {
815
+ void windowsPipeHost.close().then(done, (err) => {
816
+ log("warn", `hqd Windows named-pipe helper did not close cleanly: ${err.message}`);
817
+ done();
818
+ });
819
+ }
820
+ else {
821
+ server.close(() => done());
569
822
  }
823
+ if (!isPipe && currentUid !== undefined)
824
+ unlinkOwnedSocketIfPresent(socketPath, lstatSocketPath, currentUid, log);
570
825
  })),
571
826
  };
572
827
  }
@@ -0,0 +1,17 @@
1
+ export declare const WINDOWS_PIPE_STARTUP_REFUSAL = "hqd is disabled on Windows because the default named-pipe ACL grants read access to Everyone and Anonymous accounts.";
2
+ export interface WindowsPipeAclReport {
3
+ userSid: string;
4
+ sddl: string;
5
+ }
6
+ export interface WindowsPipeHost extends NodeJS.EventEmitter {
7
+ startAccepting(): void;
8
+ write(connectionId: number, data: Buffer, callback: (err?: Error | null) => void): void;
9
+ closeConnection(connectionId: number): void;
10
+ close(): Promise<void>;
11
+ }
12
+ export declare function validateWindowsPipeAcl(report: WindowsPipeAclReport): WindowsPipeAclReport;
13
+ export declare function startWindowsPipeHost(pipePath: string): Promise<{
14
+ host: WindowsPipeHost;
15
+ acl: WindowsPipeAclReport;
16
+ }>;
17
+ //# sourceMappingURL=windows-pipe-host.d.ts.map
@@ -0,0 +1,218 @@
1
+ import { spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { createInterface } from "node:readline";
4
+ import { Duplex } from "node:stream";
5
+ import { fileURLToPath } from "node:url";
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { parseSddlDacl, sddlAccountToSid } from "../work-context/windows-acl.js";
9
+ export const WINDOWS_PIPE_STARTUP_REFUSAL = "hqd is disabled on Windows because the default named-pipe ACL grants read access to Everyone and Anonymous accounts.";
10
+ export function validateWindowsPipeAcl(report) {
11
+ if (!/^S-1-\d+(?:-\d+)+$/i.test(report.userSid))
12
+ throw new Error("Windows pipe ACL did not report a valid current user SID");
13
+ const aces = parseSddlDacl(report.sddl);
14
+ if (!aces || aces.length !== 1)
15
+ throw new Error("Windows pipe DACL is not user-only");
16
+ const [ace] = aces;
17
+ // Windows uses the LA SDDL alias for the local Administrator account. Its
18
+ // SID is machine-specific, so match it only when the OS-reported current
19
+ // user is that account (RID 500).
20
+ const sid = ace.account.toUpperCase() === "LA" && /^(?:S-1-5-21-\d+-\d+-\d+-)500$/i.test(report.userSid)
21
+ ? report.userSid.toUpperCase()
22
+ : sddlAccountToSid(ace.account);
23
+ const rights = ace.rights.toUpperCase();
24
+ const fullPipeControl = rights === "GA" || rights === "0X1F019F" || rights === "0X001F019F";
25
+ if (ace.type !== "A" || ace.flags !== "" || sid?.toUpperCase() !== report.userSid.toUpperCase() || !fullPipeControl) {
26
+ throw new Error("Windows pipe DACL is not user-only");
27
+ }
28
+ const daclPrefix = report.sddl.match(/(?:^|:)D:([A-Z_]*)\(/i)?.[1] ?? "";
29
+ if (!daclPrefix.includes("P"))
30
+ throw new Error("Windows pipe DACL is not protected from inherited ACEs");
31
+ return report;
32
+ }
33
+ function helperPath() {
34
+ return fileURLToPath(new URL("../../../assets/hqd/windows-pipe-host.ps1", import.meta.url));
35
+ }
36
+ function powershellPath() {
37
+ const root = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
38
+ const windowsPowerShell = path.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
39
+ if (fs.existsSync(windowsPowerShell))
40
+ return windowsPowerShell;
41
+ const programFiles = process.env.ProgramW6432 ?? process.env.ProgramFiles ?? "C:\\Program Files";
42
+ const pwsh7 = path.join(programFiles, "PowerShell", "7", "pwsh.exe");
43
+ if (fs.existsSync(pwsh7))
44
+ return pwsh7;
45
+ throw new Error("Windows PowerShell 5.1 or PowerShell 7 is unavailable");
46
+ }
47
+ class WindowsPipeClient extends Duplex {
48
+ host;
49
+ connectionId;
50
+ constructor(host, connectionId) {
51
+ super({ decodeStrings: true, allowHalfOpen: false });
52
+ this.host = host;
53
+ this.connectionId = connectionId;
54
+ }
55
+ _read() { }
56
+ _write(chunk, _encoding, callback) {
57
+ this.host.write(this.connectionId, chunk, callback);
58
+ }
59
+ _final(callback) {
60
+ this.host.closeConnection(this.connectionId);
61
+ callback();
62
+ }
63
+ _destroy(err, callback) {
64
+ this.host.closeConnection(this.connectionId);
65
+ callback(err);
66
+ }
67
+ receive(data) {
68
+ this.push(data);
69
+ }
70
+ endFromHost() {
71
+ this.push(null);
72
+ }
73
+ }
74
+ class SpawnedWindowsPipeHost extends EventEmitter {
75
+ child;
76
+ clients = new Map();
77
+ lineReader;
78
+ closed = false;
79
+ closing = false;
80
+ constructor(pipePath) {
81
+ super();
82
+ const script = helperPath();
83
+ if (!fs.existsSync(script))
84
+ throw new Error(`Windows pipe helper is missing at ${script}`);
85
+ this.child = spawn(powershellPath(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script], {
86
+ windowsHide: true,
87
+ stdio: ["pipe", "pipe", "pipe"],
88
+ env: { ...process.env, HQD_PIPE_NAME: pipePath },
89
+ });
90
+ let stderr = "";
91
+ this.child.stderr?.setEncoding("utf8");
92
+ this.child.stderr?.on("data", (chunk) => {
93
+ stderr = `${stderr}${chunk}`.slice(-8_192);
94
+ });
95
+ this.lineReader = createInterface({ input: this.child.stdout, crlfDelay: Infinity });
96
+ this.lineReader.on("line", (line) => this.receiveLine(line));
97
+ this.child.once("error", (err) => this.emit("failure", err));
98
+ this.child.once("exit", (code, signal) => {
99
+ this.closed = true;
100
+ if (this.closing)
101
+ return;
102
+ const err = new Error(`Windows pipe helper exited (${signal ?? code})${stderr.trim() ? `: ${stderr.trim()}` : ""}`);
103
+ this.emit("failure", err);
104
+ for (const client of this.clients.values())
105
+ client.destroy(err);
106
+ this.clients.clear();
107
+ });
108
+ }
109
+ static async start(pipePath, timeoutMs = 15_000) {
110
+ const host = new SpawnedWindowsPipeHost(pipePath);
111
+ try {
112
+ const acl = await new Promise((resolve, reject) => {
113
+ const timeout = setTimeout(() => reject(new Error("Windows pipe helper did not verify its ACL before timeout")), timeoutMs);
114
+ const onReady = (report) => {
115
+ clearTimeout(timeout);
116
+ host.off("failure", onFailure);
117
+ resolve(report);
118
+ };
119
+ const onFailure = (err) => {
120
+ clearTimeout(timeout);
121
+ host.off("ready", onReady);
122
+ reject(err);
123
+ };
124
+ host.once("ready", onReady);
125
+ host.once("failure", onFailure);
126
+ });
127
+ try {
128
+ return { host, acl: validateWindowsPipeAcl(acl) };
129
+ }
130
+ catch (err) {
131
+ throw new Error(`Windows pipe helper reported DACL ${JSON.stringify(acl.sddl)} for ${acl.userSid}: ${err.message}`);
132
+ }
133
+ }
134
+ catch (err) {
135
+ await host.close();
136
+ throw err;
137
+ }
138
+ }
139
+ receiveLine(line) {
140
+ const [kind, rawId, rawData] = line.split("\t");
141
+ if (kind === "READY") {
142
+ try {
143
+ this.emit("ready", { userSid: rawId, sddl: Buffer.from(rawData ?? "", "base64").toString("utf8") });
144
+ }
145
+ catch (err) {
146
+ this.emit("failure", err instanceof Error ? err : new Error(String(err)));
147
+ }
148
+ return;
149
+ }
150
+ if (kind === "FATAL") {
151
+ this.emit("failure", new Error(Buffer.from(rawId ?? "", "base64").toString("utf8") || "Windows pipe helper failed"));
152
+ return;
153
+ }
154
+ if (kind === "REJECT") {
155
+ this.emit("rejectedRemoteClient", Buffer.from(rawId ?? "", "base64").toString("utf8"));
156
+ return;
157
+ }
158
+ const connectionId = Number(rawId);
159
+ if (!Number.isSafeInteger(connectionId))
160
+ return;
161
+ if (kind === "OPEN") {
162
+ const client = new WindowsPipeClient(this, connectionId);
163
+ this.clients.set(connectionId, client);
164
+ this.emit("connection", client);
165
+ return;
166
+ }
167
+ const client = this.clients.get(connectionId);
168
+ if (!client)
169
+ return;
170
+ if (kind === "DATA")
171
+ client.receive(Buffer.from(rawData ?? "", "base64"));
172
+ else if (kind === "CLOSE") {
173
+ client.endFromHost();
174
+ this.clients.delete(connectionId);
175
+ }
176
+ }
177
+ write(connectionId, data, callback) {
178
+ if (this.closed || !this.child.stdin?.writable) {
179
+ callback(new Error("Windows pipe helper is closed"));
180
+ return;
181
+ }
182
+ this.child.stdin.write(`WRITE\t${connectionId}\t${data.toString("base64")}\n`, "utf8", callback);
183
+ }
184
+ closeConnection(connectionId) {
185
+ if (!this.closed && this.child.stdin?.writable)
186
+ this.child.stdin.write(`CLOSE\t${connectionId}\n`);
187
+ this.clients.delete(connectionId);
188
+ }
189
+ startAccepting() {
190
+ if (!this.closed && this.child.stdin?.writable)
191
+ this.child.stdin.write("START\n");
192
+ }
193
+ async close() {
194
+ if (this.closed)
195
+ return;
196
+ this.closing = true;
197
+ this.child.stdin?.end();
198
+ await new Promise((resolve) => {
199
+ const timer = setTimeout(() => {
200
+ this.child.kill();
201
+ resolve();
202
+ }, 5_000);
203
+ this.child.once("exit", () => {
204
+ clearTimeout(timer);
205
+ resolve();
206
+ });
207
+ });
208
+ }
209
+ }
210
+ export async function startWindowsPipeHost(pipePath) {
211
+ const prefix = "\\\\.\\pipe\\";
212
+ const pipeName = pipePath.toLowerCase().startsWith(prefix.toLowerCase()) ? pipePath.slice(prefix.length) : pipePath;
213
+ if (!pipeName || pipeName.includes("\\") || pipeName.includes("/")) {
214
+ throw new Error("Windows hqd pipe path is not a local named-pipe name");
215
+ }
216
+ return SpawnedWindowsPipeHost.start(pipeName);
217
+ }
218
+ //# sourceMappingURL=windows-pipe-host.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * True when the person asked for diagnostics.
3
+ *
4
+ * Global `--verbose`, or `HQ_DEBUG` set to `1`, `true`, or `yes` (any case,
5
+ * surrounding spaces ignored). Callers pass `options.env` and `options.argv`
6
+ * when a command injects them; otherwise the process values are used.
7
+ */
8
+ export declare function diagnosticsEnabled(env?: NodeJS.ProcessEnv, argv?: readonly string[]): boolean;
9
+ //# sourceMappingURL=diagnostics.d.ts.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * True when the person asked for diagnostics.
3
+ *
4
+ * Global `--verbose`, or `HQ_DEBUG` set to `1`, `true`, or `yes` (any case,
5
+ * surrounding spaces ignored). Callers pass `options.env` and `options.argv`
6
+ * when a command injects them; otherwise the process values are used.
7
+ */
8
+ export function diagnosticsEnabled(env = process.env, argv = process.argv) {
9
+ const debug = env.HQ_DEBUG?.trim() ?? "";
10
+ return argv.includes("--verbose") || /^(1|true|yes)$/i.test(debug);
11
+ }
12
+ //# sourceMappingURL=diagnostics.js.map
@@ -8,6 +8,7 @@
8
8
  * WARN or FAIL with a remediation command. When anywhere was never installed
9
9
  * on this machine the family reports a single NA line.
10
10
  */
11
+ import { type HqdHealthResult } from "../../daemon/protocol.js";
11
12
  import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
12
13
  export declare const ANYWHERE_FAMILY_ID = "anywhere";
13
14
  export declare const REMEDIATION: {
@@ -27,6 +28,10 @@ export interface AnywhereDeps {
27
28
  env?: NodeJS.ProcessEnv;
28
29
  /** Version hqd reports; rejects when the socket is unreachable. */
29
30
  ping?: () => Promise<string>;
31
+ /** Read-only hqd health op. Test seam; production queries the daemon socket. */
32
+ health?: (cwd: string) => Promise<HqdHealthResult>;
33
+ /** Test seam for the local runtime gate used to keep other checks out of a disabled runtime. */
34
+ runtimeEnabled?: () => boolean;
30
35
  /** The hq-cli version installed on this machine. Default: this CLI's version. */
31
36
  cliVersion?: string;
32
37
  serviceState?: () => DaemonServiceState;
@@ -34,5 +39,5 @@ export interface AnywhereDeps {
34
39
  }
35
40
  export declare function createAnywhereFamily(deps?: AnywhereDeps): CheckFamily;
36
41
  export declare const anywhereFamily: CheckFamily;
37
- export declare function runAnywhereChecks(_context: CheckContext, deps?: AnywhereDeps): Promise<CheckResult[]>;
42
+ export declare function runAnywhereChecks(context: CheckContext, deps?: AnywhereDeps): Promise<CheckResult[]>;
38
43
  //# sourceMappingURL=anywhere.d.ts.map