@indigoai-us/hq-cli 5.341.0 → 5.341.2
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 +12 -0
- package/assets/scaffold/core/scripts/policy-age-report.sh +1 -1
- package/dist/bin/hq-auth-refresh.d.ts +1 -1
- package/dist/bin/hq-auth-refresh.js +11 -2
- package/dist/commands/core-hq-session-native.js +3 -2
- package/dist/commands/core-policy-frontmatter.js +4 -1
- package/dist/commands/core-policy.js +23 -12
- package/dist/commands/core-session-journal-native.js +14 -7
- package/dist/index.d.ts +2 -1
- package/dist/index.js +17 -1
- package/dist/lib/daemon/flag-monitor.d.ts +18 -0
- package/dist/lib/daemon/flag-monitor.js +86 -0
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +10 -1
- package/dist/lib/daemon/server.d.ts +10 -2
- package/dist/lib/daemon/server.js +114 -24
- package/dist/utils/package-use-lease.d.ts +4 -0
- package/dist/utils/package-use-lease.js +28 -0
- package/dist/utils/vault-api.js +12 -12
- package/docs/anywhere-runtime.md +17 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.341.2] — 2026-10-03
|
|
6
|
+
|
|
7
|
+
- Preserve the original integrations response read error instead of returning a consumed response.
|
|
8
|
+
|
|
9
|
+
- The CLI no longer starts while a confirmed Desktop update is in progress after a lease I/O error.
|
|
10
|
+
- hqd refreshes `hq-anywhere-runtime` every 30 seconds, fails closed when the last successful read is older than 60 seconds, and clears its in-memory stdin login when the flag turns off.
|
|
11
|
+
- Hardened hqd company resolution so unreadable registries and registry links to companies missing from the configured HQ manifest fall back to personal context. Added adversarial coverage for symlinks, nested repositories, clones, path normalization, worktrees, and session bindings.
|
|
12
|
+
|
|
13
|
+
## [5.341.1] — 2026-10-03
|
|
14
|
+
- Match native policy frontmatter and report bytes to the bundled shell on Windows, including paths, diagnostics, and JSON newlines.
|
|
15
|
+
- Keep session-journal printable paths byte-compatible with the shell on Windows.
|
|
16
|
+
|
|
5
17
|
## [5.341.0] — 2026-10-03
|
|
6
18
|
- Keep-alive reconciliation polls every 15 seconds and admits due lane resumes within the same sweep.
|
|
7
19
|
- Add flag-gated `hq daemon login --stdin` for machine sign-in on disposable hosts. Credentials are piped over the local daemon socket, and the resulting token stays in hqd memory until restart or token expiry. Login is deadline-bounded, and the generic argv-based `hq daemon call` command cannot submit credentials.
|
|
@@ -183,7 +183,7 @@ done > "$TMP/rows.tsv"
|
|
|
183
183
|
if [ "$JSON" = 1 ]; then
|
|
184
184
|
jq -R -s -c --argjson cand "$CAND" '
|
|
185
185
|
split("\n") | map(select(length>0) | split("\t") | {id:.[0], path:.[1], enforcement:.[2], age_days:(.[3]|tonumber), fired:(.[4]|tonumber), last_fired:(if .[5]=="-" then null else .[5] end), stale_refs:(if .[7]=="" then [] else (.[7]|split(",")) end), single_incident:(.[8]=="1"), candidate_classes:(if .[9]=="" then [] else (.[9]|split(",")) end), retrieved:((.[10] // "0")|tonumber), refs_judged:((.[11] // "0")|tonumber), renamed_refs:(if (.[12] // "")=="" then [] else (.[12]|split(",")) end)})
|
|
186
|
-
| if $cand == 1 then map(select(.candidate_classes|length>0)) else . end' "$TMP/rows.tsv"
|
|
186
|
+
| if $cand == 1 then map(select(.candidate_classes|length>0)) else . end' "$TMP/rows.tsv" | tr -d '\r'
|
|
187
187
|
exit 0
|
|
188
188
|
fi
|
|
189
189
|
total="$(wc -l < "$TMP/rows.tsv" | tr -d ' ')"
|
|
@@ -18,6 +18,6 @@ type RefreshSession = (options?: {
|
|
|
18
18
|
minValiditySeconds?: number;
|
|
19
19
|
}) => ReturnType<typeof refreshCachedSession>;
|
|
20
20
|
export declare function runAuthRefresh(refreshSession?: RefreshSession): Promise<number>;
|
|
21
|
-
export declare function startAuthRefresh(acquireLease?: typeof acquireCurrentPackageUseLease): Promise<number>;
|
|
21
|
+
export declare function startAuthRefresh(acquireLease?: typeof acquireCurrentPackageUseLease, updateRequestPending?: () => boolean): Promise<number>;
|
|
22
22
|
export {};
|
|
23
23
|
//# sourceMappingURL=hq-auth-refresh.d.ts.map
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// MUST be first: guard the Node version before any dependency that needs a
|
|
14
14
|
// Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
|
|
15
15
|
import "../node-preflight.js";
|
|
16
|
-
import { acquireCurrentPackageUseLease } from "../utils/package-use-lease.js";
|
|
16
|
+
import { acquireCurrentPackageUseLease, currentPackageUseUpdatePending, } from "../utils/package-use-lease.js";
|
|
17
17
|
import "../node-network-compat.js";
|
|
18
18
|
import { isMainModule } from "../utils/is-main-module.js";
|
|
19
19
|
const MIN_VALIDITY_FLAG = "--min-validity-seconds";
|
|
@@ -93,7 +93,7 @@ function packageUseErrorClass(error) {
|
|
|
93
93
|
}
|
|
94
94
|
return typeof value.name === "string" ? value.name : "unknown";
|
|
95
95
|
}
|
|
96
|
-
export async function startAuthRefresh(acquireLease = acquireCurrentPackageUseLease) {
|
|
96
|
+
export async function startAuthRefresh(acquireLease = acquireCurrentPackageUseLease, updateRequestPending = currentPackageUseUpdatePending) {
|
|
97
97
|
try {
|
|
98
98
|
acquireLease();
|
|
99
99
|
}
|
|
@@ -102,6 +102,15 @@ export async function startAuthRefresh(acquireLease = acquireCurrentPackageUseLe
|
|
|
102
102
|
process.stderr.write(`hq-auth-refresh: ${error instanceof Error ? error.message : "The HQ CLI package is being updated"}\n`);
|
|
103
103
|
return 1;
|
|
104
104
|
}
|
|
105
|
+
try {
|
|
106
|
+
if (updateRequestPending()) {
|
|
107
|
+
process.stderr.write("hq-auth-refresh: The HQ CLI package is being updated; retry after the update finishes\n");
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// A failed pending-request probe preserves the existing fail-open behavior.
|
|
113
|
+
}
|
|
105
114
|
process.stderr.write(`hq-auth-refresh: package-use lease unavailable (${packageUseErrorClass(error)}); continuing without lease\n`);
|
|
106
115
|
}
|
|
107
116
|
const { initSentry } = await import("../sentry.js");
|
|
@@ -145,10 +145,11 @@ function resolveSessionId(root, deps) {
|
|
|
145
145
|
}
|
|
146
146
|
function findMetaValue(contents, key) {
|
|
147
147
|
for (const line of contents.split("\n")) {
|
|
148
|
-
const
|
|
148
|
+
const record = process.platform === "win32" ? line.replace(/\r$/, "") : line;
|
|
149
|
+
const first = record.match(/^\s*(\S+)/)?.[1] ?? "";
|
|
149
150
|
if (first !== `${key}:`)
|
|
150
151
|
continue;
|
|
151
|
-
return { found: true, value:
|
|
152
|
+
return { found: true, value: record.replace(/^[^:]+:[\t ]*/, "").replace(/^"|"$/g, "") };
|
|
152
153
|
}
|
|
153
154
|
return { found: false, value: "" };
|
|
154
155
|
}
|
|
@@ -39,7 +39,10 @@ export function runPolicyFrontmatter(args, cwd, pathValue = process.env.PATH, pl
|
|
|
39
39
|
while (start < source.length) {
|
|
40
40
|
const end = source.indexOf(0x0a, start);
|
|
41
41
|
const lineEnd = end < 0 ? source.length : end;
|
|
42
|
-
const
|
|
42
|
+
const rawLine = source.subarray(start, lineEnd);
|
|
43
|
+
const line = platform === 'win32' && rawLine.at(-1) === 0x0d
|
|
44
|
+
? rawLine.subarray(0, rawLine.length - 1)
|
|
45
|
+
: rawLine;
|
|
43
46
|
if (line.length === 3 && line[0] === 0x2d && line[1] === 0x2d && line[2] === 0x2d) {
|
|
44
47
|
markers += 1;
|
|
45
48
|
}
|
|
@@ -378,12 +378,12 @@ function bashInteger(value) {
|
|
|
378
378
|
const parsed = BigInt(value);
|
|
379
379
|
return parsed < BASH_INTEGER_MIN || parsed > BASH_INTEGER_MAX ? undefined : parsed;
|
|
380
380
|
}
|
|
381
|
-
function bashTestGreaterOrEqual(left, right, scriptPath, line) {
|
|
381
|
+
function bashTestGreaterOrEqual(left, right, scriptPath, line, platform) {
|
|
382
382
|
const parsed = bashInteger(right);
|
|
383
383
|
if (parsed === undefined) {
|
|
384
384
|
return {
|
|
385
385
|
value: false,
|
|
386
|
-
stderr: `${scriptPath}: line ${line}: [: ${right}: integer expression expected\n`,
|
|
386
|
+
stderr: `${scriptPath}: line ${line}: [: ${right}: ${platform === 'win32' ? 'integer expected' : 'integer expression expected'}\n`,
|
|
387
387
|
};
|
|
388
388
|
}
|
|
389
389
|
return { value: BigInt(left) >= parsed, stderr: '' };
|
|
@@ -448,13 +448,13 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
|
|
|
448
448
|
const singleIncident = ['user-correction', 'session-learning', 'back-pressure-failure'].includes(source) && (version || '1') === '1';
|
|
449
449
|
const classes = [];
|
|
450
450
|
if (fired === 0) {
|
|
451
|
-
const comparison = bashTestGreaterOrEqual(ageDays, parsed.minAge, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 173);
|
|
451
|
+
const comparison = bashTestGreaterOrEqual(ageDays, parsed.minAge, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 173, platform);
|
|
452
452
|
stderr += comparison.stderr;
|
|
453
453
|
if (comparison.value && usage.anyFiles)
|
|
454
454
|
classes.push('never-fired');
|
|
455
455
|
}
|
|
456
456
|
if (fired > 0) {
|
|
457
|
-
const comparison = bashTestGreaterOrEqual(lastAge, parsed.dormantDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 174);
|
|
457
|
+
const comparison = bashTestGreaterOrEqual(lastAge, parsed.dormantDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 174, platform);
|
|
458
458
|
stderr += comparison.stderr;
|
|
459
459
|
if (comparison.value)
|
|
460
460
|
classes.push('dormant');
|
|
@@ -464,14 +464,12 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
|
|
|
464
464
|
if (refs.renamed.length > 0)
|
|
465
465
|
classes.push('renamed-refs');
|
|
466
466
|
if (singleIncident) {
|
|
467
|
-
const comparison = bashTestGreaterOrEqual(ageDays, parsed.incidentDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 178);
|
|
467
|
+
const comparison = bashTestGreaterOrEqual(ageDays, parsed.incidentDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 178, platform);
|
|
468
468
|
stderr += comparison.stderr;
|
|
469
469
|
if (comparison.value && fired <= 1)
|
|
470
470
|
classes.push('single-incident-old');
|
|
471
471
|
}
|
|
472
|
-
|
|
473
|
-
if (!path.isAbsolute(file) && !file.startsWith(`${root}/`))
|
|
474
|
-
displayPath = file;
|
|
472
|
+
const displayPath = displayPolicyPath(file, root);
|
|
475
473
|
rows.push({
|
|
476
474
|
id,
|
|
477
475
|
path: displayPath,
|
|
@@ -648,7 +646,7 @@ function setPolicyStatus(filePath, status, reason, by, env, now, cwd = process.c
|
|
|
648
646
|
const trimmedDiagnostic = diagnostic.replace(/\n+$/, '');
|
|
649
647
|
return {
|
|
650
648
|
ok: false,
|
|
651
|
-
error: `policy-retire: refusing to write ${filePath}; canonical policy validation failed:\n${trimmedDiagnostic}\n`,
|
|
649
|
+
error: `policy-retire: refusing to write ${shellPolicyPath(filePath, env.HQ_ROOT || cwd)}; canonical policy validation failed:\n${trimmedDiagnostic}\n`,
|
|
652
650
|
};
|
|
653
651
|
}
|
|
654
652
|
const tempPath = `${resolvedPath}.tmp`;
|
|
@@ -657,7 +655,20 @@ function setPolicyStatus(filePath, status, reason, by, env, now, cwd = process.c
|
|
|
657
655
|
return { ok: true };
|
|
658
656
|
}
|
|
659
657
|
function displayPolicyPath(filePath, root) {
|
|
660
|
-
|
|
658
|
+
if (!path.isAbsolute(filePath))
|
|
659
|
+
return filePath;
|
|
660
|
+
const relative = path.relative(root, filePath);
|
|
661
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
662
|
+
return filePath;
|
|
663
|
+
return relative.split(path.sep).join('/');
|
|
664
|
+
}
|
|
665
|
+
function shellPolicyPath(filePath, root) {
|
|
666
|
+
if (process.platform !== 'win32' || !path.isAbsolute(filePath))
|
|
667
|
+
return filePath;
|
|
668
|
+
const relative = path.relative(root, filePath);
|
|
669
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
670
|
+
return filePath;
|
|
671
|
+
return `${root}/${relative.split(path.sep).join('/')}`;
|
|
661
672
|
}
|
|
662
673
|
function resolvePolicyTarget(target, root, cwd) {
|
|
663
674
|
let found = isFile(path.isAbsolute(target) ? target : path.resolve(cwd, target))
|
|
@@ -817,7 +828,7 @@ function autoRetire(options, root, cwd, env, now) {
|
|
|
817
828
|
wouldRetire += 1;
|
|
818
829
|
continue;
|
|
819
830
|
}
|
|
820
|
-
const updated = setPolicyStatus(resolvedForRead, 'retired', `auto: ${why}`, options.by, env, now);
|
|
831
|
+
const updated = setPolicyStatus(resolvedForRead, 'retired', `auto: ${why}`, options.by, env, now, cwd);
|
|
821
832
|
if (updated.ok) {
|
|
822
833
|
stdout += `retired: ${candidate.id} — ${why}\n`;
|
|
823
834
|
reportText += `- **${candidate.id}** (${candidate.path}): ${why}\n`;
|
|
@@ -875,7 +886,7 @@ function fromReport(options, root, cwd, env, now) {
|
|
|
875
886
|
let selected = 0;
|
|
876
887
|
let stdout = '';
|
|
877
888
|
let stderr = report === null
|
|
878
|
-
? `jq: error (at ${reportPath}:0): Cannot iterate over null (null)\n`
|
|
889
|
+
? `jq: error (at ${reportPath}:0): Cannot iterate over null (null)${process.platform === 'win32' ? '\r\n' : '\n'}`
|
|
879
890
|
: '';
|
|
880
891
|
for (const value of rows) {
|
|
881
892
|
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -55,6 +55,10 @@ function yamlDoubleQuoted(value) {
|
|
|
55
55
|
}
|
|
56
56
|
const warning = (message) => { process.stderr.write(`session-journal: ${message}\n`); };
|
|
57
57
|
const journalDir = (root, date) => path.join(root, "workspace/threads/journal", date);
|
|
58
|
+
// The shell companion builds printable paths by appending slash-delimited
|
|
59
|
+
// suffixes to HQ_ROOT. Keep those bytes on Windows while filesystem access
|
|
60
|
+
// continues to use the host path API.
|
|
61
|
+
const printableJournalDir = (root, date) => path.posix.join(root, "workspace/threads/journal", date);
|
|
58
62
|
const sessionJournalAsset = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/scaffold/core/scripts/session-journal.sh");
|
|
59
63
|
const hasFile = (file) => {
|
|
60
64
|
try {
|
|
@@ -182,7 +186,9 @@ function options(args, valueFlags) {
|
|
|
182
186
|
function runToolCounter(root, action, sessionArg, env, now, writers) {
|
|
183
187
|
const rawKey = sessionArg || env.HQ_JOURNAL_SESSION || env.HQ_HOOK_SESSION_ID || "";
|
|
184
188
|
const key = counterKey(rawKey);
|
|
185
|
-
const
|
|
189
|
+
const date = now().toISOString().slice(0, 10);
|
|
190
|
+
const dir = journalDir(root, date);
|
|
191
|
+
const printableCounter = `${printableJournalDir(root, date)}/.tool-count-${key}`;
|
|
186
192
|
try {
|
|
187
193
|
fs.mkdirSync(dir, { recursive: true });
|
|
188
194
|
}
|
|
@@ -196,7 +202,7 @@ function runToolCounter(root, action, sessionArg, env, now, writers) {
|
|
|
196
202
|
throw new Error("Bundled asset is missing from the installed package: assets/scaffold/core/scripts/session-journal.sh\n" +
|
|
197
203
|
"This is a packaging fault — the file exists in source but was not published.");
|
|
198
204
|
}
|
|
199
|
-
writers.stderr(`${sessionJournalAsset}: line ${line}: ${
|
|
205
|
+
writers.stderr(`${sessionJournalAsset}: line ${line}: ${printableCounter}: Is a directory\n`);
|
|
200
206
|
};
|
|
201
207
|
if (action === "increment") {
|
|
202
208
|
withCounterLock(lock, () => {
|
|
@@ -270,7 +276,7 @@ function executeSessionJournal(args, cwd, hqRoot, dependencies, writers) {
|
|
|
270
276
|
fs.mkdirSync(dir, { recursive: true });
|
|
271
277
|
}
|
|
272
278
|
catch {
|
|
273
|
-
warning(`write: mkdir failed: ${
|
|
279
|
+
warning(`write: mkdir failed: ${printableJournalDir(root, date)}`);
|
|
274
280
|
return 0;
|
|
275
281
|
}
|
|
276
282
|
const slug = slugify(title) || "entry";
|
|
@@ -289,7 +295,8 @@ function executeSessionJournal(args, cwd, hqRoot, dependencies, writers) {
|
|
|
289
295
|
try {
|
|
290
296
|
for (let attempt = 0; attempt < 10_000; attempt += 1) {
|
|
291
297
|
seq = nextSequence(dir);
|
|
292
|
-
output =
|
|
298
|
+
output = `${printableJournalDir(root, date)}/${seq}-${slug}.md`;
|
|
299
|
+
const outputFile = path.join(dir, `${seq}-${slug}.md`);
|
|
293
300
|
const lines = ["---\n", `ts: ${nowIso()}\n`, `title: ${yamlDoubleQuoted(title)}\n`, `slug: ${yamlSlug}\n`];
|
|
294
301
|
if (files) {
|
|
295
302
|
lines.push("files:\n");
|
|
@@ -299,7 +306,7 @@ function executeSessionJournal(args, cwd, hqRoot, dependencies, writers) {
|
|
|
299
306
|
}
|
|
300
307
|
lines.push("status: closed\n", "---\n\n", `# ${seq} — ${title}\n\n`);
|
|
301
308
|
try {
|
|
302
|
-
fs.writeFileSync(
|
|
309
|
+
fs.writeFileSync(outputFile, lines.join("") + body, { flag: "wx" });
|
|
303
310
|
created = true;
|
|
304
311
|
break;
|
|
305
312
|
}
|
|
@@ -341,11 +348,11 @@ function executeSessionJournal(args, cwd, hqRoot, dependencies, writers) {
|
|
|
341
348
|
const date = options(afterSequence, ["--date"]).get("--date") || today();
|
|
342
349
|
const dir = journalDir(root, date);
|
|
343
350
|
if (command === "dir-path") {
|
|
344
|
-
writers.stdout(
|
|
351
|
+
writers.stdout(printableJournalDir(root, date));
|
|
345
352
|
return 0;
|
|
346
353
|
}
|
|
347
354
|
if (command === "index-path") {
|
|
348
|
-
writers.stdout(`${
|
|
355
|
+
writers.stdout(`${printableJournalDir(root, date)}/INDEX.md\n`);
|
|
349
356
|
return 0;
|
|
350
357
|
}
|
|
351
358
|
if (command === "list") {
|
package/dist/index.d.ts
CHANGED
|
@@ -4,8 +4,9 @@ import "./node-network-compat-fast.js";
|
|
|
4
4
|
import { isVersionRequest } from "./version-request.js";
|
|
5
5
|
import { isFastCoreRequest } from "./fast-scaffold.js";
|
|
6
6
|
type PackageUseLeaseLoader = () => Promise<void>;
|
|
7
|
+
type PackageUseUpdateProbe = () => Promise<boolean>;
|
|
7
8
|
declare function isNpmPackageEntry(entryUrl: string): boolean;
|
|
8
|
-
declare function dispatchWithPackageUseLease(entryUrl: string, dispatch: () => void, loadLease?: PackageUseLeaseLoader): void | Promise<void>;
|
|
9
|
+
declare function dispatchWithPackageUseLease(entryUrl: string, dispatch: () => void, loadLease?: PackageUseLeaseLoader, updateRequestPending?: PackageUseUpdateProbe): void | Promise<void>;
|
|
9
10
|
declare function runCliEntry(entryUrl: string, dispatch: () => void, loadLease?: PackageUseLeaseLoader): void | Promise<void>;
|
|
10
11
|
export declare const __test__: {
|
|
11
12
|
isVersionRequest: typeof isVersionRequest;
|
package/dist/index.js
CHANGED
|
@@ -140,14 +140,30 @@ function packageUseErrorClass(error) {
|
|
|
140
140
|
function dispatchWithPackageUseLease(entryUrl, dispatch, loadLease = async () => {
|
|
141
141
|
const { acquireCurrentPackageUseLease } = await import("./utils/package-use-lease.js");
|
|
142
142
|
acquireCurrentPackageUseLease();
|
|
143
|
+
}, updateRequestPending = async () => {
|
|
144
|
+
const { currentPackageUseUpdatePending } = await import("./utils/package-use-lease.js");
|
|
145
|
+
return currentPackageUseUpdatePending();
|
|
143
146
|
}) {
|
|
144
147
|
if (!isNpmPackageEntry(entryUrl)) {
|
|
145
148
|
dispatch();
|
|
146
149
|
return;
|
|
147
150
|
}
|
|
148
|
-
return loadLease().then(dispatch, (error) => {
|
|
151
|
+
return loadLease().then(dispatch, async (error) => {
|
|
149
152
|
if (isPackageUseUpdatePendingError(error))
|
|
150
153
|
throw error;
|
|
154
|
+
try {
|
|
155
|
+
if (await updateRequestPending()) {
|
|
156
|
+
const pending = new Error("The HQ CLI package is being updated; retry after the update finishes");
|
|
157
|
+
pending.name = "PackageUseUpdatePendingError";
|
|
158
|
+
throw pending;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (probeError) {
|
|
162
|
+
if (isPackageUseUpdatePendingError(probeError))
|
|
163
|
+
throw probeError;
|
|
164
|
+
// A lease probe failure must not make the CLI unavailable. Keep the
|
|
165
|
+
// existing fail-open path when no live update request could be confirmed.
|
|
166
|
+
}
|
|
151
167
|
process.stderr.write(`hq-cli package-use lease unavailable (${packageUseErrorClass(error)}); continuing without lease\n`);
|
|
152
168
|
dispatch();
|
|
153
169
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30000;
|
|
2
|
+
export declare const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60000;
|
|
3
|
+
export interface BoundedDaemonFlagMonitorOptions {
|
|
4
|
+
read: () => Promise<boolean>;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
onChange?: (enabled: boolean) => void;
|
|
7
|
+
onReadError?: (error: unknown) => void;
|
|
8
|
+
}
|
|
9
|
+
export interface BoundedDaemonFlagMonitor {
|
|
10
|
+
enabled(): Promise<boolean>;
|
|
11
|
+
close(): void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Refreshes a long-running daemon's flag independently of incoming requests.
|
|
15
|
+
* A failed read keeps the last successful decision only until its 60s age limit.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createBoundedDaemonFlagMonitor(options: BoundedDaemonFlagMonitorOptions): BoundedDaemonFlagMonitor;
|
|
18
|
+
//# sourceMappingURL=flag-monitor.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30_000;
|
|
2
|
+
export const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60_000;
|
|
3
|
+
/**
|
|
4
|
+
* Refreshes a long-running daemon's flag independently of incoming requests.
|
|
5
|
+
* A failed read keeps the last successful decision only until its 60s age limit.
|
|
6
|
+
*/
|
|
7
|
+
export function createBoundedDaemonFlagMonitor(options) {
|
|
8
|
+
const now = options.now ?? Date.now;
|
|
9
|
+
let value;
|
|
10
|
+
let lastSuccessAt;
|
|
11
|
+
let lastAttemptAt;
|
|
12
|
+
let successVersion = 0;
|
|
13
|
+
let refreshInFlight;
|
|
14
|
+
let expiryTimer;
|
|
15
|
+
let closed = false;
|
|
16
|
+
const publish = (next) => {
|
|
17
|
+
const changed = value !== next;
|
|
18
|
+
value = next;
|
|
19
|
+
if (changed)
|
|
20
|
+
options.onChange?.(next);
|
|
21
|
+
};
|
|
22
|
+
const expireIfStale = () => {
|
|
23
|
+
if (!closed && lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
|
|
24
|
+
publish(false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const refresh = () => {
|
|
28
|
+
if (closed)
|
|
29
|
+
return Promise.resolve(false);
|
|
30
|
+
if (refreshInFlight)
|
|
31
|
+
return refreshInFlight;
|
|
32
|
+
lastAttemptAt = now();
|
|
33
|
+
refreshInFlight = Promise.resolve().then(options.read).then((next) => {
|
|
34
|
+
if (closed)
|
|
35
|
+
return false;
|
|
36
|
+
lastSuccessAt = now();
|
|
37
|
+
const thisSuccessVersion = ++successVersion;
|
|
38
|
+
publish(next);
|
|
39
|
+
if (expiryTimer)
|
|
40
|
+
clearTimeout(expiryTimer);
|
|
41
|
+
// Expire from the elapsed timer itself, not a wall-clock comparison.
|
|
42
|
+
expiryTimer = setTimeout(() => {
|
|
43
|
+
if (!closed && successVersion === thisSuccessVersion)
|
|
44
|
+
publish(false);
|
|
45
|
+
}, HQ_ANYWHERE_FLAG_MAX_STALENESS_MS);
|
|
46
|
+
expiryTimer.unref?.();
|
|
47
|
+
return next;
|
|
48
|
+
}).catch((error) => {
|
|
49
|
+
options.onReadError?.(error);
|
|
50
|
+
expireIfStale();
|
|
51
|
+
return value ?? false;
|
|
52
|
+
}).finally(() => {
|
|
53
|
+
refreshInFlight = undefined;
|
|
54
|
+
});
|
|
55
|
+
return refreshInFlight;
|
|
56
|
+
};
|
|
57
|
+
const refreshTimer = setInterval(() => {
|
|
58
|
+
expireIfStale();
|
|
59
|
+
void refresh();
|
|
60
|
+
}, HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS);
|
|
61
|
+
refreshTimer.unref?.();
|
|
62
|
+
void refresh();
|
|
63
|
+
return {
|
|
64
|
+
async enabled() {
|
|
65
|
+
expireIfStale();
|
|
66
|
+
if (lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
|
|
67
|
+
void refresh();
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (value === undefined)
|
|
71
|
+
return refresh();
|
|
72
|
+
if (lastAttemptAt === undefined || now() - lastAttemptAt >= HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS) {
|
|
73
|
+
void refresh();
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
},
|
|
77
|
+
close() {
|
|
78
|
+
closed = true;
|
|
79
|
+
if (refreshTimer)
|
|
80
|
+
clearInterval(refreshTimer);
|
|
81
|
+
if (expiryTimer)
|
|
82
|
+
clearTimeout(expiryTimer);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=flag-monitor.js.map
|
|
@@ -20,6 +20,8 @@ export interface ReadDaemonFlagOptions {
|
|
|
20
20
|
createClient?: (options: FlagClientOptions) => DaemonFlagClient;
|
|
21
21
|
getToken?: () => Promise<string>;
|
|
22
22
|
onError?: (error: unknown) => void;
|
|
23
|
+
/** Reject a failed registry read so the daemon monitor can distinguish it from an explicit OFF. */
|
|
24
|
+
throwOnError?: boolean;
|
|
23
25
|
}
|
|
24
26
|
export declare function readDaemonFlag(flagKey: string, options?: ReadDaemonFlagOptions): Promise<boolean>;
|
|
25
27
|
//# sourceMappingURL=flag.d.ts.map
|
package/dist/lib/daemon/flag.js
CHANGED
|
@@ -18,8 +18,10 @@ export async function readDaemonFlag(flagKey, options = {}) {
|
|
|
18
18
|
if (override !== null)
|
|
19
19
|
return override;
|
|
20
20
|
let failed = false;
|
|
21
|
+
let reportedError;
|
|
21
22
|
const onError = (error) => {
|
|
22
23
|
failed = true;
|
|
24
|
+
reportedError ??= error;
|
|
23
25
|
options.onError?.(error);
|
|
24
26
|
};
|
|
25
27
|
let client;
|
|
@@ -41,10 +43,17 @@ export async function readDaemonFlag(flagKey, options = {}) {
|
|
|
41
43
|
});
|
|
42
44
|
await client.ready();
|
|
43
45
|
const resolution = client.explain(flagKey, { fallback: false });
|
|
46
|
+
if (failed && options.throwOnError) {
|
|
47
|
+
throw new Error("hq-flags registry read failed", { cause: reportedError });
|
|
48
|
+
}
|
|
44
49
|
return !failed && resolution.source === "snapshot" && resolution.value === true;
|
|
45
50
|
}
|
|
46
51
|
catch (error) {
|
|
47
|
-
|
|
52
|
+
if (!failed)
|
|
53
|
+
onError(error);
|
|
54
|
+
if (options.throwOnError && failed) {
|
|
55
|
+
throw new Error("hq-flags registry read failed", { cause: reportedError ?? error });
|
|
56
|
+
}
|
|
48
57
|
return false;
|
|
49
58
|
}
|
|
50
59
|
finally {
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* machine. Sessions in any repo ask it which company they belong to and
|
|
7
7
|
* register themselves here.
|
|
8
8
|
*/
|
|
9
|
-
import { type ResolveResult } from "../registry/registry.js";
|
|
9
|
+
import { readRegistry, type ResolveResult } from "../registry/registry.js";
|
|
10
|
+
import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
|
|
10
11
|
import { SessionRegistry } from "./session-registry.js";
|
|
11
12
|
import { HqMcpBridge, type RunWorkerRequest } from "./ops/mcp.js";
|
|
12
13
|
import { type StdinMachineCredentials } from "./machine-login.js";
|
|
@@ -26,6 +27,8 @@ export interface HqdServerOptions {
|
|
|
26
27
|
workMeshRoot?: string;
|
|
27
28
|
/** Test seam for the hq-anywhere-runtime default-off gate. */
|
|
28
29
|
mcpEnabled?: () => boolean | Promise<boolean>;
|
|
30
|
+
/** Test seam for the live hq-flags read used by the daemon's bounded runtime monitor. */
|
|
31
|
+
runtimeFlagReader?: () => Promise<boolean>;
|
|
29
32
|
/** Test seam for the hosted Streamable HTTP MCP transport. */
|
|
30
33
|
mcpBridge?: HqMcpBridge;
|
|
31
34
|
/** Test seam for machine login while keeping production credentials off disk. */
|
|
@@ -43,6 +46,11 @@ export interface HqdServer {
|
|
|
43
46
|
close(): Promise<void>;
|
|
44
47
|
}
|
|
45
48
|
/** Resolve cwd → company through the registry, cached until registry.json changes. */
|
|
46
|
-
export declare function createResolver(registryPath: string
|
|
49
|
+
export declare function createResolver(registryPath: string, options?: {
|
|
50
|
+
hqRoot?: string;
|
|
51
|
+
log?: HqdServerOptions["log"];
|
|
52
|
+
readRegistry?: typeof readRegistry;
|
|
53
|
+
readCompaniesManifest?: typeof readCompaniesManifestMap;
|
|
54
|
+
}): (cwd: string) => ResolveResult;
|
|
47
55
|
export declare function startHqdServer(opts: HqdServerOptions): Promise<HqdServer>;
|
|
48
56
|
//# sourceMappingURL=server.d.ts.map
|
|
@@ -10,7 +10,8 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as net from "node:net";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { CLI_VERSION } from "../../cli-version.js";
|
|
13
|
-
import { canonicalDir, readRegistry, registryFile, resolveCompanyForDir } from "../registry/registry.js";
|
|
13
|
+
import { canonicalDir, emptyRegistry, readRegistry, registryFile, resolveCompanyForDir } from "../registry/registry.js";
|
|
14
|
+
import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
|
|
14
15
|
import { createLineSplitter, encodeMessage, HQD_DEFAULT_REQUEST_TIMEOUT_MS, HQD_MAX_LINE_BYTES, parseRequestLine } from "./protocol.js";
|
|
15
16
|
import { SessionRegistry } from "./session-registry.js";
|
|
16
17
|
import { journalAppend, JournalOpError } from "./ops/journal.js";
|
|
@@ -20,7 +21,9 @@ import { PolicyIndex, PolicyOpError, policyCheck } from "./ops/policy.js";
|
|
|
20
21
|
import { defaultMcpToken, HqMcpBridge, jsonResult, McpOpError, runWorker } from "./ops/mcp.js";
|
|
21
22
|
import { authenticateMachineCredentials, machineTokenExpiresAt, MachineLoginError, parseStdinMachineCredentials } from "./machine-login.js";
|
|
22
23
|
import { HQ_MCP_TOOL_NAMES, HQD_NATIVE_TOOL_NAMES } from "../mcp/tools.js";
|
|
23
|
-
import {
|
|
24
|
+
import { HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
|
|
25
|
+
import { createBoundedDaemonFlagMonitor } from "./flag-monitor.js";
|
|
26
|
+
import { readDaemonFlag } from "./flag.js";
|
|
24
27
|
// The allowlist is deliberately read-only. A newly added hosted MCP tool must
|
|
25
28
|
// pass hq.policy_check until it is explicitly reviewed as read-only here.
|
|
26
29
|
const MCP_READ_ONLY_TOOL_NAMES = new Set([
|
|
@@ -62,29 +65,89 @@ function stringArg(args, name, required = true) {
|
|
|
62
65
|
throw new OpError("bad_args", `${name} must be a non-empty string`);
|
|
63
66
|
}
|
|
64
67
|
/** Resolve cwd → company through the registry, cached until registry.json changes. */
|
|
65
|
-
export function createResolver(registryPath) {
|
|
68
|
+
export function createResolver(registryPath, options = {}) {
|
|
69
|
+
const loadRegistry = options.readRegistry ?? readRegistry;
|
|
70
|
+
const loadCompaniesManifest = options.readCompaniesManifest ?? readCompaniesManifestMap;
|
|
66
71
|
const cache = new Map();
|
|
67
|
-
let
|
|
72
|
+
let registryStamp = "";
|
|
73
|
+
let registry = emptyRegistry();
|
|
74
|
+
let registryLoadFailed = false;
|
|
75
|
+
let manifestStamp = "";
|
|
76
|
+
let companies = null;
|
|
77
|
+
let manifestLoadFailed = false;
|
|
68
78
|
return (cwd) => {
|
|
69
|
-
let
|
|
79
|
+
let currentRegistryStamp = "missing";
|
|
70
80
|
try {
|
|
71
81
|
const st = fs.statSync(registryPath);
|
|
72
|
-
|
|
82
|
+
currentRegistryStamp = `${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}:${st.mode}`;
|
|
73
83
|
}
|
|
74
84
|
catch (err) {
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
const code = err.code;
|
|
86
|
+
if (code !== "ENOENT") {
|
|
87
|
+
cache.clear();
|
|
88
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
89
|
+
options.log?.("warn", `hqd registry unavailable; using personal context: ${detail}`);
|
|
90
|
+
return { company: null, reason: "HQ registry is unavailable; using personal context." };
|
|
91
|
+
}
|
|
77
92
|
}
|
|
78
|
-
if (
|
|
93
|
+
if (currentRegistryStamp !== registryStamp) {
|
|
79
94
|
cache.clear();
|
|
80
|
-
|
|
95
|
+
registryStamp = currentRegistryStamp;
|
|
96
|
+
registryLoadFailed = false;
|
|
97
|
+
if (currentRegistryStamp === "missing") {
|
|
98
|
+
registry = emptyRegistry();
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
try {
|
|
102
|
+
registry = loadRegistry(registryPath);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
registryLoadFailed = true;
|
|
106
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
107
|
+
options.log?.("warn", `hqd registry unavailable; using personal context: ${detail}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (registryLoadFailed) {
|
|
112
|
+
return { company: null, reason: "HQ registry is unavailable; using personal context." };
|
|
81
113
|
}
|
|
82
114
|
const key = canonicalDir(cwd);
|
|
83
|
-
const
|
|
84
|
-
if (hit)
|
|
85
|
-
return hit;
|
|
86
|
-
const result = resolveCompanyForDir(readRegistry(registryPath), key);
|
|
115
|
+
const result = cache.get(key) ?? resolveCompanyForDir(registry, key);
|
|
87
116
|
cache.set(key, result);
|
|
117
|
+
if (result.company && options.hqRoot) {
|
|
118
|
+
const manifestPath = path.join(options.hqRoot, "companies", "manifest.yaml");
|
|
119
|
+
let currentManifestStamp;
|
|
120
|
+
try {
|
|
121
|
+
const st = fs.statSync(manifestPath);
|
|
122
|
+
currentManifestStamp = `${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}:${st.mode}`;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const code = err.code;
|
|
126
|
+
currentManifestStamp = code === "ENOENT" ? "missing" : `unavailable:${code ?? "unknown"}`;
|
|
127
|
+
}
|
|
128
|
+
if (currentManifestStamp !== manifestStamp) {
|
|
129
|
+
manifestStamp = currentManifestStamp;
|
|
130
|
+
companies = null;
|
|
131
|
+
manifestLoadFailed = false;
|
|
132
|
+
if (currentManifestStamp === "missing" || currentManifestStamp.startsWith("unavailable:")) {
|
|
133
|
+
manifestLoadFailed = true;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
companies = loadCompaniesManifest(options.hqRoot);
|
|
137
|
+
manifestLoadFailed = companies === null;
|
|
138
|
+
}
|
|
139
|
+
if (manifestLoadFailed) {
|
|
140
|
+
options.log?.("warn", "hqd companies/manifest.yaml unavailable or malformed; using personal context");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (manifestLoadFailed || !companies) {
|
|
144
|
+
return { company: null, reason: "HQ company manifest is unavailable; using personal context." };
|
|
145
|
+
}
|
|
146
|
+
if (!Object.hasOwn(companies, result.company)) {
|
|
147
|
+
options.log?.("warn", `hqd registry company ${result.company} is not present in companies/manifest.yaml; using personal context`);
|
|
148
|
+
return { company: null, reason: `Registry company ${result.company} is not present in companies/manifest.yaml; using personal context.` };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
88
151
|
return result;
|
|
89
152
|
};
|
|
90
153
|
}
|
|
@@ -106,12 +169,13 @@ export async function startHqdServer(opts) {
|
|
|
106
169
|
const isPipe = process.platform === "win32";
|
|
107
170
|
const startedAtMs = (opts.now ?? (() => new Date()))().getTime();
|
|
108
171
|
const sessions = new SessionRegistry(opts.now);
|
|
109
|
-
const resolve = createResolver(opts.registryPath ?? registryFile());
|
|
110
172
|
const log = opts.log ?? (() => { });
|
|
173
|
+
const resolve = createResolver(opts.registryPath ?? registryFile(), { hqRoot: opts.hqRoot, log });
|
|
111
174
|
const clock = opts.now ?? (() => new Date());
|
|
112
175
|
const env = opts.env ?? process.env;
|
|
113
176
|
const policyIndex = opts.hqRoot ? new PolicyIndex(opts.hqRoot, log) : null;
|
|
114
177
|
let ephemeralMachineToken;
|
|
178
|
+
let flagRevocationGeneration = 0;
|
|
115
179
|
const requireRoot = () => {
|
|
116
180
|
if (!opts.hqRoot)
|
|
117
181
|
throw new OpError("no_hq_root", "hqd has no HQ root configured (set hqRoot in daemon config or HQ_ROOT)");
|
|
@@ -138,7 +202,6 @@ export async function startHqdServer(opts) {
|
|
|
138
202
|
return explicit ?? boundCompany;
|
|
139
203
|
};
|
|
140
204
|
let hqMcp = opts.mcpBridge;
|
|
141
|
-
const mcpEnabled = opts.mcpEnabled ?? (() => anywhereRuntimeEnabled(env));
|
|
142
205
|
if (!isPipe) {
|
|
143
206
|
fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
144
207
|
if (fs.existsSync(socketPath)) {
|
|
@@ -147,6 +210,22 @@ export async function startHqdServer(opts) {
|
|
|
147
210
|
fs.unlinkSync(socketPath);
|
|
148
211
|
}
|
|
149
212
|
}
|
|
213
|
+
const runtimeFlagMonitor = opts.mcpEnabled ? undefined : createBoundedDaemonFlagMonitor({
|
|
214
|
+
read: opts.runtimeFlagReader ?? (() => readDaemonFlag(HQ_ANYWHERE_RUNTIME_FLAG, {
|
|
215
|
+
env,
|
|
216
|
+
throwOnError: true,
|
|
217
|
+
})),
|
|
218
|
+
onChange: (enabled) => {
|
|
219
|
+
if (!enabled) {
|
|
220
|
+
flagRevocationGeneration += 1;
|
|
221
|
+
ephemeralMachineToken = undefined;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
onReadError: (error) => {
|
|
225
|
+
log("warn", `hqd runtime flag refresh failed (${error instanceof Error ? error.name : "UnknownError"}); retaining the last decision only until its 60-second limit`);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
const mcpEnabled = opts.mcpEnabled ?? (() => runtimeFlagMonitor.enabled());
|
|
150
229
|
/** Synchronous ops answer in request order; policy.check and mcp.call may return a promise. */
|
|
151
230
|
const handle = (req) => {
|
|
152
231
|
const args = req.args ?? {};
|
|
@@ -162,7 +241,7 @@ export async function startHqdServer(opts) {
|
|
|
162
241
|
case "auth.status":
|
|
163
242
|
return Promise.resolve(mcpEnabled()).then((enabled) => {
|
|
164
243
|
if (!enabled)
|
|
165
|
-
|
|
244
|
+
ephemeralMachineToken = undefined;
|
|
166
245
|
if (ephemeralMachineToken && ephemeralMachineToken.expiresAt <= clock().getTime())
|
|
167
246
|
ephemeralMachineToken = undefined;
|
|
168
247
|
return { signedIn: Boolean(ephemeralMachineToken), tokenSource: ephemeralMachineToken ? "stdin-memory" : null };
|
|
@@ -171,10 +250,12 @@ export async function startHqdServer(opts) {
|
|
|
171
250
|
return Promise.resolve(mcpEnabled()).then(async (enabled) => {
|
|
172
251
|
if (!enabled)
|
|
173
252
|
throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
|
|
253
|
+
const loginGeneration = flagRevocationGeneration;
|
|
174
254
|
const credentials = parseStdinMachineCredentials(JSON.stringify(args.credentials) ?? "");
|
|
175
255
|
const controller = new AbortController();
|
|
176
256
|
const timeoutMs = opts.machineAuthTimeoutMs ?? 25_000;
|
|
177
257
|
let timer;
|
|
258
|
+
let authenticatedToken;
|
|
178
259
|
try {
|
|
179
260
|
const authentication = (opts.machineAuthenticator ?? ((input, signal) => authenticateMachineCredentials(input, { env, signal })))(credentials, controller.signal);
|
|
180
261
|
const deadline = new Promise((_, reject) => {
|
|
@@ -183,11 +264,10 @@ export async function startHqdServer(opts) {
|
|
|
183
264
|
reject(new MachineLoginError("auth_timeout", "hqd sign-in timed out; try again"));
|
|
184
265
|
}, timeoutMs);
|
|
185
266
|
});
|
|
186
|
-
|
|
187
|
-
const expiresAt = machineTokenExpiresAt(
|
|
267
|
+
authenticatedToken = await Promise.race([authentication, deadline]);
|
|
268
|
+
const expiresAt = machineTokenExpiresAt(authenticatedToken);
|
|
188
269
|
if (expiresAt <= clock().getTime())
|
|
189
270
|
throw new MachineLoginError("auth_failed", "hqd sign-in failed; the identity provider returned an expired token");
|
|
190
|
-
ephemeralMachineToken = { value: token, expiresAt };
|
|
191
271
|
}
|
|
192
272
|
catch {
|
|
193
273
|
throw new MachineLoginError("auth_failed", "hqd sign-in failed; check the machine credentials and try again");
|
|
@@ -196,6 +276,10 @@ export async function startHqdServer(opts) {
|
|
|
196
276
|
if (timer)
|
|
197
277
|
clearTimeout(timer);
|
|
198
278
|
}
|
|
279
|
+
if (loginGeneration !== flagRevocationGeneration || !(await mcpEnabled())) {
|
|
280
|
+
throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
|
|
281
|
+
}
|
|
282
|
+
ephemeralMachineToken = { value: authenticatedToken, expiresAt: machineTokenExpiresAt(authenticatedToken) };
|
|
199
283
|
return { signedIn: true, tokenSource: "stdin-memory" };
|
|
200
284
|
});
|
|
201
285
|
case "resolve": {
|
|
@@ -344,7 +428,7 @@ export async function startHqdServer(opts) {
|
|
|
344
428
|
const deadline = requestStartedAt + timeoutMs;
|
|
345
429
|
const forward = () => Promise.resolve(mcpEnabled()).then((enabled) => {
|
|
346
430
|
if (!enabled) {
|
|
347
|
-
throw new
|
|
431
|
+
throw new OpError("anywhere_disabled", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
|
|
348
432
|
}
|
|
349
433
|
const remainingMs = Math.floor(deadline - Date.now());
|
|
350
434
|
if (remainingMs <= 0) {
|
|
@@ -365,9 +449,14 @@ export async function startHqdServer(opts) {
|
|
|
365
449
|
});
|
|
366
450
|
return hqMcp.callTool(name, raw, remainingMs);
|
|
367
451
|
});
|
|
368
|
-
return
|
|
369
|
-
|
|
370
|
-
|
|
452
|
+
return Promise.resolve(mcpEnabled()).then((enabled) => {
|
|
453
|
+
if (!enabled) {
|
|
454
|
+
throw new OpError("anywhere_disabled", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
|
|
455
|
+
}
|
|
456
|
+
return !MCP_READ_ONLY_TOOL_NAMES.has(name)
|
|
457
|
+
? policyGuardedCall(forward)
|
|
458
|
+
: forward();
|
|
459
|
+
});
|
|
371
460
|
}
|
|
372
461
|
}
|
|
373
462
|
}
|
|
@@ -463,6 +552,7 @@ export async function startHqdServer(opts) {
|
|
|
463
552
|
sessions,
|
|
464
553
|
close: () => (closing ??= new Promise((done) => {
|
|
465
554
|
policyIndex?.close();
|
|
555
|
+
runtimeFlagMonitor?.close();
|
|
466
556
|
hqMcp?.close();
|
|
467
557
|
server.close(() => done());
|
|
468
558
|
// Answered-but-idle clients would otherwise hold close() open.
|
|
@@ -16,6 +16,10 @@ export declare function packageUseUpdateRequestPath(prefix: string, stateDirecto
|
|
|
16
16
|
declare function waitForUpdateRequest(requestPath: string, timeoutMs: number): void;
|
|
17
17
|
export declare function acquirePackageUseLease(prefix: string, hqVersion: string, stateDirectory?: string): PackageUseLease;
|
|
18
18
|
export declare function acquireCurrentPackageUseLease(): PackageUseLease | null;
|
|
19
|
+
/** True only when a valid update request belongs to a live updater process. */
|
|
20
|
+
export declare function packageUseUpdatePending(prefix: string, stateDirectory?: string): boolean;
|
|
21
|
+
/** True while the desktop updater owns the per-prefix update request. */
|
|
22
|
+
export declare function currentPackageUseUpdatePending(): boolean;
|
|
19
23
|
/** True while the desktop updater owns the per-prefix update request. */
|
|
20
24
|
export declare function currentPackageUseUpdateRequested(): boolean;
|
|
21
25
|
export declare const __test__: {
|
|
@@ -197,6 +197,34 @@ export function acquireCurrentPackageUseLease() {
|
|
|
197
197
|
}
|
|
198
198
|
return acquirePackageUseLease(prefix, pkg.version);
|
|
199
199
|
}
|
|
200
|
+
/** True only when a valid update request belongs to a live updater process. */
|
|
201
|
+
export function packageUseUpdatePending(prefix, stateDirectory) {
|
|
202
|
+
try {
|
|
203
|
+
waitForUpdateRequest(packageUseUpdateRequestPath(prefix, stateDirectory), 0);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error instanceof PackageUseUpdatePendingError)
|
|
208
|
+
return true;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** True while the desktop updater owns the per-prefix update request. */
|
|
213
|
+
export function currentPackageUseUpdatePending() {
|
|
214
|
+
const pkg = findCliPackage(path.dirname(fileURLToPath(import.meta.url)));
|
|
215
|
+
if (!pkg || isPnpmGlobal(pkg.root) || isBunGlobal(pkg.root))
|
|
216
|
+
return false;
|
|
217
|
+
const prefix = npmPrefix(pkg.root);
|
|
218
|
+
if (!prefix)
|
|
219
|
+
return false;
|
|
220
|
+
if (process.platform !== "win32") {
|
|
221
|
+
const parts = pkg.root.replace(/\\/g, "/").replace(/\/+$/, "").split("/").filter(Boolean);
|
|
222
|
+
const nodeModules = parts.lastIndexOf("node_modules");
|
|
223
|
+
if (nodeModules > 0 && parts[nodeModules - 1] !== "lib")
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
return packageUseUpdatePending(prefix);
|
|
227
|
+
}
|
|
200
228
|
/** True while the desktop updater owns the per-prefix update request. */
|
|
201
229
|
export function currentPackageUseUpdateRequested() {
|
|
202
230
|
const pkg = findCliPackage(path.dirname(fileURLToPath(import.meta.url)));
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -95,20 +95,19 @@ function identityLookupTimeoutMs() {
|
|
|
95
95
|
/**
|
|
96
96
|
* Best-effort peek of a 2xx JSON body for plan-limit status (US-016).
|
|
97
97
|
*
|
|
98
|
-
* For ok JSON responses:
|
|
99
|
-
* and return a NEW Response built from
|
|
100
|
-
* call .json()/.text()/.arrayBuffer().
|
|
101
|
-
* returned untouched (streamed binary
|
|
98
|
+
* For ok JSON responses: inspect a clone, record planLimits (best-effort),
|
|
99
|
+
* cancel the unused original tee branch, and return a NEW Response built from
|
|
100
|
+
* the buffered body so callers can still call .json()/.text()/.arrayBuffer().
|
|
101
|
+
* Non-JSON and non-ok responses are returned untouched (streamed binary
|
|
102
|
+
* downloads must never be buffered).
|
|
102
103
|
*
|
|
103
104
|
* On inspection errors, return the original response by default to preserve
|
|
104
105
|
* existing behavior. Secret-load callers opt into rethrowing body read errors
|
|
105
106
|
* so the load path can classify a broken stream for offline-cache fallback.
|
|
106
107
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* referenced and the process (or vitest worker / spawned CLI child) never
|
|
111
|
-
* exits on Linux CI. Reading once + re-wrapping fully drains the stream.
|
|
108
|
+
* Cloning tees the undici body stream. Cancel the original branch after the
|
|
109
|
+
* clone has been read so callers can use the buffered replacement without an
|
|
110
|
+
* unread tee branch keeping the connection/handle referenced.
|
|
112
111
|
*/
|
|
113
112
|
async function peekPlanLimitStatus(response, options = {}) {
|
|
114
113
|
let bufferedBody;
|
|
@@ -123,8 +122,9 @@ async function peekPlanLimitStatus(response, options = {}) {
|
|
|
123
122
|
if (!ct.includes('application/json') && !ct.includes('+json')) {
|
|
124
123
|
return response;
|
|
125
124
|
}
|
|
126
|
-
const buf = await response.arrayBuffer();
|
|
125
|
+
const buf = await response.clone().arrayBuffer();
|
|
127
126
|
bufferedBody = buf;
|
|
127
|
+
await response.body?.cancel();
|
|
128
128
|
try {
|
|
129
129
|
const text = new TextDecoder().decode(buf);
|
|
130
130
|
try {
|
|
@@ -147,8 +147,8 @@ async function peekPlanLimitStatus(response, options = {}) {
|
|
|
147
147
|
}
|
|
148
148
|
catch (err) {
|
|
149
149
|
// Only secret-load callers need the body-read error for offline fallback.
|
|
150
|
-
// Other endpoints retain the best-effort peek behavior
|
|
151
|
-
// original body
|
|
150
|
+
// Other endpoints retain the best-effort peek behavior. Reading the clone
|
|
151
|
+
// leaves the original body available when inspection fails.
|
|
152
152
|
if (bufferedBody !== undefined) {
|
|
153
153
|
// Retry with safe metadata: statusText can itself make Response
|
|
154
154
|
// construction throw after the original stream has already been drained.
|
package/docs/anywhere-runtime.md
CHANGED
|
@@ -34,6 +34,23 @@ When the flag is enabled, the global hook uses hqd for events outside the HQ
|
|
|
34
34
|
root. Inside the root, the project hook routes through hqd when it is running
|
|
35
35
|
and uses the direct master hook when it is stopped.
|
|
36
36
|
|
|
37
|
+
## Flag revocation
|
|
38
|
+
|
|
39
|
+
While hqd is running, it refreshes `hq-anywhere-runtime` every 30 seconds.
|
|
40
|
+
Successful reads are never trusted for more than 60 seconds. This gives a
|
|
41
|
+
healthy flag service a 30-second revocation window while limiting reads to two
|
|
42
|
+
per minute per daemon; the five-second flag request timeout bounds the work
|
|
43
|
+
when the service is slow. Staleness uses elapsed monotonic time, so wall-clock
|
|
44
|
+
corrections cannot extend an ON decision.
|
|
45
|
+
|
|
46
|
+
When a read fails, hqd keeps the last successful value only until it is 60
|
|
47
|
+
seconds old. After that, it fails closed. An OFF decision refuses new daemon
|
|
48
|
+
login and hosted MCP calls with `anywhere_disabled` and clears any stdin machine
|
|
49
|
+
token held in memory. `auth.status` remains available and reports signed out.
|
|
50
|
+
An authentication still in progress when revocation arrives fails cleanly;
|
|
51
|
+
hosted MCP calls already forwarded are allowed to finish or return their normal
|
|
52
|
+
request error. The daemon process itself does not terminate.
|
|
53
|
+
|
|
37
54
|
## Link a repository
|
|
38
55
|
|
|
39
56
|
Run `hq link <company>` from a repository to associate it with a company. The
|