@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.
- package/CHANGELOG.md +17 -0
- package/assets/hqd/windows-pipe-host.ps1 +282 -0
- package/dist/bin/hq-auth-refresh.d.ts +1 -1
- package/dist/bin/hq-auth-refresh.js +11 -2
- 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/index.d.ts +2 -1
- package/dist/index.js +17 -1
- 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 +23 -0
- package/dist/lib/daemon/flag-monitor.js +99 -0
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +10 -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 +23 -2
- package/dist/lib/daemon/server.js +397 -52
- 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/package-use-lease.d.ts +4 -0
- package/dist/utils/package-use-lease.js +28 -0
- package/dist/utils/run-bundled-script.d.ts +2 -0
- package/dist/utils/run-bundled-script.js +10 -1
- package/dist/utils/vault-api.js +12 -12
- package/docs/anywhere-runtime.md +49 -5
- 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 {
|
|
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
|
|
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
|
|
122
|
+
let currentRegistryStamp = "missing";
|
|
70
123
|
try {
|
|
71
124
|
const st = fs.statSync(registryPath);
|
|
72
|
-
|
|
125
|
+
currentRegistryStamp = `${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}:${st.mode}`;
|
|
73
126
|
}
|
|
74
127
|
catch (err) {
|
|
75
|
-
|
|
76
|
-
|
|
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 (
|
|
136
|
+
if (currentRegistryStamp !== registryStamp) {
|
|
79
137
|
cache.clear();
|
|
80
|
-
|
|
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
|
|
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
|
|
106
|
-
|
|
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
|
-
|
|
144
|
-
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);
|
|
145
381
|
if (await socketIsLive(socketPath))
|
|
146
382
|
throw new Error(`hqd is already listening on ${socketPath}`);
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
const expiresAt = machineTokenExpiresAt(
|
|
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
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
453
|
-
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 {
|
|
454
787
|
process.umask(prior);
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
if (!isPipe) {
|
|
792
|
+
try {
|
|
793
|
+
verifyUserOnlySocket(socketPath, lstatSocketPath, currentUid);
|
|
455
794
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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 (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|