@indigoai-us/hq-cli 5.341.2 → 5.341.3
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 +9 -0
- package/assets/hqd/windows-pipe-host.ps1 +282 -0
- package/dist/commands/billing-sponsor.js +20 -1
- package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
- package/dist/commands/core-derive-trigger-facts-native.js +92 -25
- package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
- package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
- package/dist/commands/doctor.js +2 -2
- package/dist/lib/aws-sdk-node-warning-output.js +2 -3
- package/dist/lib/billing/plan-lock.js +19 -2
- package/dist/lib/bot/owner-context.d.ts +2 -0
- package/dist/lib/bot/owner-context.js +19 -1
- package/dist/lib/daemon/flag-monitor.d.ts +5 -0
- package/dist/lib/daemon/flag-monitor.js +14 -1
- package/dist/lib/daemon/protocol.d.ts +26 -3
- package/dist/lib/daemon/protocol.js +9 -5
- package/dist/lib/daemon/run.js +14 -16
- package/dist/lib/daemon/server.d.ts +13 -0
- package/dist/lib/daemon/server.js +283 -28
- package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
- package/dist/lib/daemon/windows-pipe-host.js +218 -0
- package/dist/lib/diagnostics.d.ts +9 -0
- package/dist/lib/diagnostics.js +12 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
- package/dist/lib/doctor/checks/anywhere.js +131 -13
- package/dist/lib/doctor/json-output.d.ts +2 -0
- package/dist/lib/doctor/json-output.js +1 -0
- package/dist/lib/doctor/registry.js +1 -3
- package/dist/lib/doctor/types.d.ts +4 -0
- package/dist/utils/run-bundled-script.d.ts +2 -0
- package/dist/utils/run-bundled-script.js +10 -1
- package/docs/anywhere-runtime.md +32 -5
- 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
|
|
169
|
-
|
|
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
|
-
|
|
207
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
542
|
-
if (
|
|
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
|
-
|
|
547
|
-
|
|
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 (
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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(
|
|
42
|
+
export declare function runAnywhereChecks(context: CheckContext, deps?: AnywhereDeps): Promise<CheckResult[]>;
|
|
38
43
|
//# sourceMappingURL=anywhere.d.ts.map
|