@indigoai-us/hq-cli 5.341.1 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/assets/hqd/windows-pipe-host.ps1 +282 -0
  3. package/dist/bin/hq-auth-refresh.d.ts +1 -1
  4. package/dist/bin/hq-auth-refresh.js +11 -2
  5. package/dist/commands/billing-sponsor.js +20 -1
  6. package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
  7. package/dist/commands/core-derive-trigger-facts-native.js +92 -25
  8. package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
  9. package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
  10. package/dist/commands/doctor.js +2 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +17 -1
  13. package/dist/lib/aws-sdk-node-warning-output.js +2 -3
  14. package/dist/lib/billing/plan-lock.js +19 -2
  15. package/dist/lib/bot/owner-context.d.ts +2 -0
  16. package/dist/lib/bot/owner-context.js +19 -1
  17. package/dist/lib/daemon/flag-monitor.d.ts +23 -0
  18. package/dist/lib/daemon/flag-monitor.js +99 -0
  19. package/dist/lib/daemon/flag.d.ts +2 -0
  20. package/dist/lib/daemon/flag.js +10 -1
  21. package/dist/lib/daemon/protocol.d.ts +26 -3
  22. package/dist/lib/daemon/protocol.js +9 -5
  23. package/dist/lib/daemon/run.js +14 -16
  24. package/dist/lib/daemon/server.d.ts +23 -2
  25. package/dist/lib/daemon/server.js +397 -52
  26. package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
  27. package/dist/lib/daemon/windows-pipe-host.js +218 -0
  28. package/dist/lib/diagnostics.d.ts +9 -0
  29. package/dist/lib/diagnostics.js +12 -0
  30. package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
  31. package/dist/lib/doctor/checks/anywhere.js +131 -13
  32. package/dist/lib/doctor/json-output.d.ts +2 -0
  33. package/dist/lib/doctor/json-output.js +1 -0
  34. package/dist/lib/doctor/registry.js +1 -3
  35. package/dist/lib/doctor/types.d.ts +4 -0
  36. package/dist/utils/package-use-lease.d.ts +4 -0
  37. package/dist/utils/package-use-lease.js +28 -0
  38. package/dist/utils/run-bundled-script.d.ts +2 -0
  39. package/dist/utils/run-bundled-script.js +10 -1
  40. package/dist/utils/vault-api.js +12 -12
  41. package/docs/anywhere-runtime.md +49 -5
  42. package/package.json +2 -1
@@ -9,8 +9,10 @@
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
- import { canonicalDir, readRegistry, registryFile, resolveCompanyForDir } from "../registry/registry.js";
14
+ import { canonicalDir, emptyRegistry, readRegistry, registryFile, resolveCompanyForDir } from "../registry/registry.js";
15
+ import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
14
16
  import { createLineSplitter, encodeMessage, HQD_DEFAULT_REQUEST_TIMEOUT_MS, HQD_MAX_LINE_BYTES, parseRequestLine } from "./protocol.js";
15
17
  import { SessionRegistry } from "./session-registry.js";
16
18
  import { journalAppend, JournalOpError } from "./ops/journal.js";
@@ -20,7 +22,10 @@ import { PolicyIndex, PolicyOpError, policyCheck } from "./ops/policy.js";
20
22
  import { defaultMcpToken, HqMcpBridge, jsonResult, McpOpError, runWorker } from "./ops/mcp.js";
21
23
  import { authenticateMachineCredentials, machineTokenExpiresAt, MachineLoginError, parseStdinMachineCredentials } from "./machine-login.js";
22
24
  import { HQ_MCP_TOOL_NAMES, HQD_NATIVE_TOOL_NAMES } from "../mcp/tools.js";
23
- import { anywhereRuntimeEnabled, HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
25
+ import { HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
26
+ import { createBoundedDaemonFlagMonitor } from "./flag-monitor.js";
27
+ import { readDaemonFlag } from "./flag.js";
28
+ import { startWindowsPipeHost, validateWindowsPipeAcl, WINDOWS_PIPE_STARTUP_REFUSAL } from "./windows-pipe-host.js";
24
29
  // The allowlist is deliberately read-only. A newly added hosted MCP tool must
25
30
  // pass hq.policy_check until it is explicitly reviewed as read-only here.
26
31
  const MCP_READ_ONLY_TOOL_NAMES = new Set([
@@ -46,6 +51,47 @@ const MCP_READ_ONLY_TOOL_NAMES = new Set([
46
51
  "hq_version",
47
52
  "hq_whoami",
48
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
+ }
49
95
  class OpError extends Error {
50
96
  code;
51
97
  constructor(code, message) {
@@ -62,29 +108,89 @@ function stringArg(args, name, required = true) {
62
108
  throw new OpError("bad_args", `${name} must be a non-empty string`);
63
109
  }
64
110
  /** Resolve cwd → company through the registry, cached until registry.json changes. */
65
- export function createResolver(registryPath) {
111
+ export function createResolver(registryPath, options = {}) {
112
+ const loadRegistry = options.readRegistry ?? readRegistry;
113
+ const loadCompaniesManifest = options.readCompaniesManifest ?? readCompaniesManifestMap;
66
114
  const cache = new Map();
67
- let stamp = "";
115
+ let registryStamp = "";
116
+ let registry = emptyRegistry();
117
+ let registryLoadFailed = false;
118
+ let manifestStamp = "";
119
+ let companies = null;
120
+ let manifestLoadFailed = false;
68
121
  return (cwd) => {
69
- let current = "missing";
122
+ let currentRegistryStamp = "missing";
70
123
  try {
71
124
  const st = fs.statSync(registryPath);
72
- current = `${st.mtimeMs}:${st.size}:${st.ino}`;
125
+ currentRegistryStamp = `${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}:${st.mode}`;
73
126
  }
74
127
  catch (err) {
75
- if (err.code !== "ENOENT")
76
- throw err;
128
+ const code = err.code;
129
+ if (code !== "ENOENT") {
130
+ cache.clear();
131
+ const detail = err instanceof Error ? err.message : String(err);
132
+ options.log?.("warn", `hqd registry unavailable; using personal context: ${detail}`);
133
+ return { company: null, reason: "HQ registry is unavailable; using personal context." };
134
+ }
77
135
  }
78
- if (current !== stamp) {
136
+ if (currentRegistryStamp !== registryStamp) {
79
137
  cache.clear();
80
- stamp = current;
138
+ registryStamp = currentRegistryStamp;
139
+ registryLoadFailed = false;
140
+ if (currentRegistryStamp === "missing") {
141
+ registry = emptyRegistry();
142
+ }
143
+ else {
144
+ try {
145
+ registry = loadRegistry(registryPath);
146
+ }
147
+ catch (err) {
148
+ registryLoadFailed = true;
149
+ const detail = err instanceof Error ? err.message : String(err);
150
+ options.log?.("warn", `hqd registry unavailable; using personal context: ${detail}`);
151
+ }
152
+ }
153
+ }
154
+ if (registryLoadFailed) {
155
+ return { company: null, reason: "HQ registry is unavailable; using personal context." };
81
156
  }
82
157
  const key = canonicalDir(cwd);
83
- const hit = cache.get(key);
84
- if (hit)
85
- return hit;
86
- const result = resolveCompanyForDir(readRegistry(registryPath), key);
158
+ const result = cache.get(key) ?? resolveCompanyForDir(registry, key);
87
159
  cache.set(key, result);
160
+ if (result.company && options.hqRoot) {
161
+ const manifestPath = path.join(options.hqRoot, "companies", "manifest.yaml");
162
+ let currentManifestStamp;
163
+ try {
164
+ const st = fs.statSync(manifestPath);
165
+ currentManifestStamp = `${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}:${st.mode}`;
166
+ }
167
+ catch (err) {
168
+ const code = err.code;
169
+ currentManifestStamp = code === "ENOENT" ? "missing" : `unavailable:${code ?? "unknown"}`;
170
+ }
171
+ if (currentManifestStamp !== manifestStamp) {
172
+ manifestStamp = currentManifestStamp;
173
+ companies = null;
174
+ manifestLoadFailed = false;
175
+ if (currentManifestStamp === "missing" || currentManifestStamp.startsWith("unavailable:")) {
176
+ manifestLoadFailed = true;
177
+ }
178
+ else {
179
+ companies = loadCompaniesManifest(options.hqRoot);
180
+ manifestLoadFailed = companies === null;
181
+ }
182
+ if (manifestLoadFailed) {
183
+ options.log?.("warn", "hqd companies/manifest.yaml unavailable or malformed; using personal context");
184
+ }
185
+ }
186
+ if (manifestLoadFailed || !companies) {
187
+ return { company: null, reason: "HQ company manifest is unavailable; using personal context." };
188
+ }
189
+ if (!Object.hasOwn(companies, result.company)) {
190
+ options.log?.("warn", `hqd registry company ${result.company} is not present in companies/manifest.yaml; using personal context`);
191
+ return { company: null, reason: `Registry company ${result.company} is not present in companies/manifest.yaml; using personal context.` };
192
+ }
193
+ }
88
194
  return result;
89
195
  };
90
196
  }
@@ -101,17 +207,143 @@ function socketIsLive(socketPath) {
101
207
  setTimeout(() => done(false), 500).unref();
102
208
  });
103
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
+ }
104
330
  export async function startHqdServer(opts) {
105
- const { socketPath } = opts;
106
- 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();
107
338
  const startedAtMs = (opts.now ?? (() => new Date()))().getTime();
108
339
  const sessions = new SessionRegistry(opts.now);
109
- const resolve = createResolver(opts.registryPath ?? registryFile());
110
340
  const log = opts.log ?? (() => { });
341
+ const resolve = createResolver(opts.registryPath ?? registryFile(), { hqRoot: opts.hqRoot, log });
111
342
  const clock = opts.now ?? (() => new Date());
112
343
  const env = opts.env ?? process.env;
113
344
  const policyIndex = opts.hqRoot ? new PolicyIndex(opts.hqRoot, log) : null;
114
345
  let ephemeralMachineToken;
346
+ let flagRevocationGeneration = 0;
115
347
  const requireRoot = () => {
116
348
  if (!opts.hqRoot)
117
349
  throw new OpError("no_hq_root", "hqd has no HQ root configured (set hqRoot in daemon config or HQ_ROOT)");
@@ -138,15 +370,62 @@ export async function startHqdServer(opts) {
138
370
  return explicit ?? boundCompany;
139
371
  };
140
372
  let hqMcp = opts.mcpBridge;
141
- const mcpEnabled = opts.mcpEnabled ?? (() => anywhereRuntimeEnabled(env));
142
373
  if (!isPipe) {
143
- fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
144
- 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);
145
381
  if (await socketIsLive(socketPath))
146
382
  throw new Error(`hqd is already listening on ${socketPath}`);
147
- 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
+ }
148
396
  }
149
397
  }
398
+ const runtimeFlagMonitor = opts.mcpEnabled ? undefined : createBoundedDaemonFlagMonitor({
399
+ read: opts.runtimeFlagReader ?? (() => readDaemonFlag(HQ_ANYWHERE_RUNTIME_FLAG, {
400
+ env,
401
+ throwOnError: true,
402
+ })),
403
+ onChange: (enabled) => {
404
+ if (!enabled) {
405
+ flagRevocationGeneration += 1;
406
+ ephemeralMachineToken = undefined;
407
+ }
408
+ },
409
+ onReadError: (error) => {
410
+ log("warn", `hqd runtime flag refresh failed (${error instanceof Error ? error.name : "UnknownError"}); retaining the last decision only until its 60-second limit`);
411
+ },
412
+ });
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
+ };
150
429
  /** Synchronous ops answer in request order; policy.check and mcp.call may return a promise. */
151
430
  const handle = (req) => {
152
431
  const args = req.args ?? {};
@@ -159,10 +438,12 @@ export async function startHqdServer(opts) {
159
438
  pid: process.pid,
160
439
  sessions: sessions.size,
161
440
  };
441
+ case "health":
442
+ return health(stringArg(args, "cwd"));
162
443
  case "auth.status":
163
444
  return Promise.resolve(mcpEnabled()).then((enabled) => {
164
445
  if (!enabled)
165
- throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
446
+ ephemeralMachineToken = undefined;
166
447
  if (ephemeralMachineToken && ephemeralMachineToken.expiresAt <= clock().getTime())
167
448
  ephemeralMachineToken = undefined;
168
449
  return { signedIn: Boolean(ephemeralMachineToken), tokenSource: ephemeralMachineToken ? "stdin-memory" : null };
@@ -171,10 +452,12 @@ export async function startHqdServer(opts) {
171
452
  return Promise.resolve(mcpEnabled()).then(async (enabled) => {
172
453
  if (!enabled)
173
454
  throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
455
+ const loginGeneration = flagRevocationGeneration;
174
456
  const credentials = parseStdinMachineCredentials(JSON.stringify(args.credentials) ?? "");
175
457
  const controller = new AbortController();
176
458
  const timeoutMs = opts.machineAuthTimeoutMs ?? 25_000;
177
459
  let timer;
460
+ let authenticatedToken;
178
461
  try {
179
462
  const authentication = (opts.machineAuthenticator ?? ((input, signal) => authenticateMachineCredentials(input, { env, signal })))(credentials, controller.signal);
180
463
  const deadline = new Promise((_, reject) => {
@@ -183,11 +466,10 @@ export async function startHqdServer(opts) {
183
466
  reject(new MachineLoginError("auth_timeout", "hqd sign-in timed out; try again"));
184
467
  }, timeoutMs);
185
468
  });
186
- const token = await Promise.race([authentication, deadline]);
187
- const expiresAt = machineTokenExpiresAt(token);
469
+ authenticatedToken = await Promise.race([authentication, deadline]);
470
+ const expiresAt = machineTokenExpiresAt(authenticatedToken);
188
471
  if (expiresAt <= clock().getTime())
189
472
  throw new MachineLoginError("auth_failed", "hqd sign-in failed; the identity provider returned an expired token");
190
- ephemeralMachineToken = { value: token, expiresAt };
191
473
  }
192
474
  catch {
193
475
  throw new MachineLoginError("auth_failed", "hqd sign-in failed; check the machine credentials and try again");
@@ -196,6 +478,10 @@ export async function startHqdServer(opts) {
196
478
  if (timer)
197
479
  clearTimeout(timer);
198
480
  }
481
+ if (loginGeneration !== flagRevocationGeneration || !(await mcpEnabled())) {
482
+ throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
483
+ }
484
+ ephemeralMachineToken = { value: authenticatedToken, expiresAt: machineTokenExpiresAt(authenticatedToken) };
199
485
  return { signedIn: true, tokenSource: "stdin-memory" };
200
486
  });
201
487
  case "resolve": {
@@ -344,7 +630,7 @@ export async function startHqdServer(opts) {
344
630
  const deadline = requestStartedAt + timeoutMs;
345
631
  const forward = () => Promise.resolve(mcpEnabled()).then((enabled) => {
346
632
  if (!enabled) {
347
- throw new McpOpError("hq_mcp_unavailable", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
633
+ throw new OpError("anywhere_disabled", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
348
634
  }
349
635
  const remainingMs = Math.floor(deadline - Date.now());
350
636
  if (remainingMs <= 0) {
@@ -365,9 +651,14 @@ export async function startHqdServer(opts) {
365
651
  });
366
652
  return hqMcp.callTool(name, raw, remainingMs);
367
653
  });
368
- return !MCP_READ_ONLY_TOOL_NAMES.has(name)
369
- ? policyGuardedCall(forward)
370
- : forward();
654
+ return Promise.resolve(mcpEnabled()).then((enabled) => {
655
+ if (!enabled) {
656
+ throw new OpError("anywhere_disabled", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
657
+ }
658
+ return !MCP_READ_ONLY_TOOL_NAMES.has(name)
659
+ ? policyGuardedCall(forward)
660
+ : forward();
661
+ });
371
662
  }
372
663
  }
373
664
  }
@@ -382,7 +673,7 @@ export async function startHqdServer(opts) {
382
673
  socket.write(encodeMessage(msg));
383
674
  };
384
675
  const clients = new Set();
385
- const server = net.createServer((socket) => {
676
+ const accept = (socket) => {
386
677
  clients.add(socket);
387
678
  socket.on("close", () => clients.delete(socket));
388
679
  socket.on("error", (err) => log("warn", `hqd client error: ${err.message}`));
@@ -438,24 +729,76 @@ export async function startHqdServer(opts) {
438
729
  socket.end();
439
730
  }, HQD_MAX_LINE_BYTES);
440
731
  socket.on("data", push);
441
- });
442
- await new Promise((resolveListen, rejectListen) => {
443
- server.once("error", rejectListen);
444
- // Bind with a user-only umask so the socket is never briefly group/world accessible.
445
- 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;
446
746
  try {
447
- server.listen(socketPath, () => {
448
- server.off("error", rejectListen);
449
- 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?.();
450
757
  });
758
+ windowsPipeHost.on("rejectedRemoteClient", (message) => {
759
+ log("warn", `hqd rejected a remote Windows named-pipe client: ${message}`);
760
+ });
761
+ windowsPipeHost.startAccepting();
451
762
  }
452
- finally {
453
- 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 {
454
787
  process.umask(prior);
788
+ }
789
+ });
790
+ }
791
+ if (!isPipe) {
792
+ try {
793
+ verifyUserOnlySocket(socketPath, lstatSocketPath, currentUid);
455
794
  }
456
- });
457
- if (!isPipe)
458
- 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
+ }
459
802
  log("info", `hqd listening on ${socketPath}`);
460
803
  let closing = null;
461
804
  return {
@@ -463,20 +806,22 @@ export async function startHqdServer(opts) {
463
806
  sessions,
464
807
  close: () => (closing ??= new Promise((done) => {
465
808
  policyIndex?.close();
809
+ runtimeFlagMonitor?.close();
466
810
  hqMcp?.close();
467
- server.close(() => done());
468
811
  // Answered-but-idle clients would otherwise hold close() open.
469
812
  for (const client of clients)
470
813
  client.destroy();
471
- if (!isPipe) {
472
- try {
473
- fs.unlinkSync(socketPath);
474
- }
475
- catch (err) {
476
- if (err.code !== "ENOENT")
477
- log("warn", `could not remove ${socketPath}: ${err.message}`);
478
- }
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());
479
822
  }
823
+ if (!isPipe && currentUid !== undefined)
824
+ unlinkOwnedSocketIfPresent(socketPath, lstatSocketPath, currentUid, log);
480
825
  })),
481
826
  };
482
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