@indigoai-us/hq-cli 5.345.146 → 5.345.148

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 (138) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +14 -0
  3. package/assets/bot-workers/setup/worker.yaml +11 -0
  4. package/dist/command-catalog.generated.d.ts +326 -4
  5. package/dist/command-catalog.generated.js +403 -4
  6. package/dist/commands/agent-inbox.js +2 -1
  7. package/dist/commands/auth.js +4 -2
  8. package/dist/commands/bot.js +1 -1
  9. package/dist/commands/cloud.js +31 -1
  10. package/dist/commands/core-refresh-vault-access-native.d.ts +17 -0
  11. package/dist/commands/core-refresh-vault-access-native.js +214 -19
  12. package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
  13. package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
  14. package/dist/commands/core.js +6 -0
  15. package/dist/commands/daemon.d.ts +7 -0
  16. package/dist/commands/daemon.js +37 -3
  17. package/dist/commands/dm.js +9 -2
  18. package/dist/commands/doctor.d.ts +11 -1
  19. package/dist/commands/doctor.js +56 -1
  20. package/dist/commands/files.js +10 -6
  21. package/dist/commands/install-global.d.ts +7 -0
  22. package/dist/commands/install-global.js +129 -35
  23. package/dist/commands/lanes-shared.d.ts +3 -1
  24. package/dist/commands/lanes.d.ts +4 -4
  25. package/dist/commands/lanes.js +380 -25
  26. package/dist/commands/login.js +4 -3
  27. package/dist/commands/pack-install.d.ts +2 -2
  28. package/dist/commands/pack-install.js +133 -35
  29. package/dist/commands/sync-manifest.d.ts +2 -9
  30. package/dist/commands/sync-manifest.js +4 -15
  31. package/dist/hook-engine-path.d.ts +4 -0
  32. package/dist/hook-engine-path.js +36 -0
  33. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2784 -2360
  34. package/dist/hq-cloud-sync-runner/package.json +1 -1
  35. package/dist/index.js +6 -0
  36. package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
  37. package/dist/lib/agent-kit/inbox-redact.js +361 -0
  38. package/dist/lib/agent-kit/inbox-state.js +11 -2
  39. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  40. package/dist/lib/agent-kit/run/inbox.js +36 -7
  41. package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
  42. package/dist/lib/anywhere-runtime-flag-key.js +9 -0
  43. package/dist/lib/bot/daemon.js +3 -0
  44. package/dist/lib/bot/runtime/index.js +3 -1
  45. package/dist/lib/company-skill-wrapper.js +6 -4
  46. package/dist/lib/daemon/install.d.ts +47 -1
  47. package/dist/lib/daemon/install.js +181 -15
  48. package/dist/lib/daemon/lock.d.ts +32 -0
  49. package/dist/lib/daemon/lock.js +55 -2
  50. package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
  51. package/dist/lib/doctor/checks/anywhere.d.ts +3 -0
  52. package/dist/lib/doctor/checks/anywhere.js +23 -8
  53. package/dist/lib/doctor/checks/cli-path.d.ts +11 -0
  54. package/dist/lib/doctor/checks/cli-path.js +45 -0
  55. package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
  56. package/dist/lib/doctor/checks/hook-engine.js +16 -0
  57. package/dist/lib/doctor/checks/release-links.d.ts +23 -0
  58. package/dist/lib/doctor/checks/release-links.js +152 -0
  59. package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
  60. package/dist/lib/doctor/checks/sync-health.js +38 -2
  61. package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
  62. package/dist/lib/doctor/checks/temp-homes.js +54 -0
  63. package/dist/lib/doctor/fix/apply.d.ts +90 -1
  64. package/dist/lib/doctor/fix/apply.js +176 -10
  65. package/dist/lib/doctor/fix/json-output.d.ts +4 -1
  66. package/dist/lib/doctor/fix/json-output.js +6 -0
  67. package/dist/lib/doctor/fix/remediation.d.ts +7 -1
  68. package/dist/lib/doctor/fix/remediation.js +23 -1
  69. package/dist/lib/doctor/registry.js +13 -0
  70. package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
  71. package/dist/lib/doctor/temp-home-sweeper.js +418 -0
  72. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  73. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  74. package/dist/lib/flag-registry.js +9 -1
  75. package/dist/lib/flag-snapshot-file.js +8 -3
  76. package/dist/lib/hook-engine/resolve.d.ts +31 -0
  77. package/dist/lib/hook-engine/resolve.js +134 -0
  78. package/dist/lib/lanes/admission-host.d.ts +1 -0
  79. package/dist/lib/lanes/admission-host.js +8 -1
  80. package/dist/lib/lanes/admission.d.ts +4 -0
  81. package/dist/lib/lanes/admission.js +6 -2
  82. package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
  83. package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
  84. package/dist/lib/lanes/index.d.ts +2 -2
  85. package/dist/lib/lanes/index.js +1 -1
  86. package/dist/lib/lanes/keep-alive.js +4 -1
  87. package/dist/lib/lanes/local-host-identity.d.ts +40 -0
  88. package/dist/lib/lanes/local-host-identity.js +314 -0
  89. package/dist/lib/lanes/reaper.d.ts +1 -1
  90. package/dist/lib/lanes/reaper.js +7 -0
  91. package/dist/lib/lanes/reconcile.d.ts +4 -0
  92. package/dist/lib/lanes/reconcile.js +16 -8
  93. package/dist/lib/lanes/spawn.d.ts +20 -0
  94. package/dist/lib/lanes/spawn.js +48 -2
  95. package/dist/lib/lanes/status-fields.js +6 -1
  96. package/dist/lib/lanes/store.js +8 -1
  97. package/dist/lib/lanes/throttle.d.ts +49 -6
  98. package/dist/lib/lanes/throttle.js +154 -17
  99. package/dist/lib/lanes/types.d.ts +22 -1
  100. package/dist/lib/lanes/types.js +27 -3
  101. package/dist/lib/lanes/watcher.js +27 -2
  102. package/dist/lib/lanes/worker-continuity.d.ts +2 -0
  103. package/dist/lib/lanes/worker-continuity.js +18 -11
  104. package/dist/lib/manifest-disabled.d.ts +13 -0
  105. package/dist/lib/manifest-disabled.js +19 -0
  106. package/dist/lib/service-manager/launchd.d.ts +1 -0
  107. package/dist/lib/service-manager/launchd.js +1 -1
  108. package/dist/lib/windows/user-path.d.ts +48 -0
  109. package/dist/lib/windows/user-path.js +114 -0
  110. package/dist/lib/work-context/auto-bind.d.ts +1 -1
  111. package/dist/lib/work-context/auto-bind.js +22 -3
  112. package/dist/lib/work-context/lane-bind.d.ts +31 -0
  113. package/dist/lib/work-context/lane-bind.js +101 -0
  114. package/dist/lib/work-context/organize.js +1 -0
  115. package/dist/lib/work-context/prd-sync.js +7 -0
  116. package/dist/lib/work-context/project.d.ts +36 -10
  117. package/dist/lib/work-context/project.js +130 -41
  118. package/dist/lib/work-context/reconcile.d.ts +6 -0
  119. package/dist/lib/work-context/reconcile.js +25 -5
  120. package/dist/lib/work-context/state.d.ts +6 -0
  121. package/dist/lib/work-context/state.js +29 -1
  122. package/dist/lib/workers-registry/index.d.ts +6 -0
  123. package/dist/lib/workers-registry/index.js +8 -0
  124. package/dist/utils/copy-tree.d.ts +61 -0
  125. package/dist/utils/copy-tree.js +249 -0
  126. package/dist/utils/environmental-error.js +7 -3
  127. package/dist/utils/headless.d.ts +2 -0
  128. package/dist/utils/headless.js +8 -0
  129. package/dist/utils/login-browser.d.ts +4 -0
  130. package/dist/utils/login-browser.js +122 -0
  131. package/dist/utils/npm-invocation.d.ts +27 -0
  132. package/dist/utils/npm-invocation.js +54 -0
  133. package/dist/utils/safe-link.d.ts +50 -0
  134. package/dist/utils/safe-link.js +300 -0
  135. package/dist/utils/team-upgrade.js +1 -7
  136. package/package.json +17 -5
  137. package/scripts/hook-engine-manifest.mjs +22 -0
  138. package/scripts/install-hook-engine.mjs +10 -0
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.82","bundleSha256":"0bf6cd65150094c035cb4ee532c00bde78d030ad0d2132aa606c566f11997f41","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.88","bundleSha256":"7e9ee380eae55e059a474fd5f415e8aeb16088805b761d791318d5299645becd","formatVersion":1}
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { installWindowsHideDefault } from "./utils/windows-hide-default.js";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { parseMonitorCheckFastRequest } from "./lanes-monitor-check-argv.js";
18
18
  import { createRequire } from "node:module";
19
+ import { hookEnginePathArgs, runHookEnginePath } from "./hook-engine-path.js";
19
20
  function cygpathMixedPath(value) {
20
21
  // Require only a Node builtin: the package supports Node releases before
21
22
  // require(ESM) became available, and this runs in the synchronous dispatcher.
@@ -159,6 +160,11 @@ function dispatchCliEntry() {
159
160
  // Before anything spawns: a background hq on Windows (no terminal) hides the
160
161
  // console window of every child it starts (feedback_351a40d5).
161
162
  installWindowsHideDefault();
163
+ const engineArgs = hookEnginePathArgs(process.argv);
164
+ if (engineArgs !== null) {
165
+ void runHookEnginePath(engineArgs);
166
+ return;
167
+ }
162
168
  if (process.platform === "win32" && coreCommandHasPathOperands(process.argv)) {
163
169
  // Keep cygpath off non-Windows and non-path command routes.
164
170
  process.argv = normalizeCorePathOperands(process.argv, "win32", cygpathMixedPath);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Owner STS credentials ride on an agent inbox item as
3
+ * `actingContext.credentials`. The mirror files are readable by whatever
4
+ * model has file tools, so those credentials are removed before a write and
5
+ * again on read. Only `mode`, `ownerPersonUid`, `ownerRole`, and `expiresAt`
6
+ * stay on `actingContext`. Any other key under that object is dropped.
7
+ *
8
+ * A scrub that cannot write (EPERM, ENOSPC, and the same class) is counted
9
+ * and skipped. It does not throw into the poller or a reader. The next poll
10
+ * or read tries again. Failure logs are a count and errno codes, never a
11
+ * path or a file byte.
12
+ *
13
+ * `hq dm` output drops the rest of `actingContext` as well. That stricter
14
+ * stdout rule lives with the command; credential removal has this one
15
+ * implementation.
16
+ */
17
+ import type { AgentKitPaths } from "./paths.js";
18
+ export interface InboxScrubReport {
19
+ scrubbed: number;
20
+ failures: number;
21
+ /** Errno names only. Never paths, file names, or file contents. */
22
+ codes: string[];
23
+ }
24
+ /**
25
+ * One writer at a time for the mirror directory. The journal is rewritten
26
+ * in place when credentials are scrubbed and appended when a message arrives;
27
+ * those two must not overlap or the rewrite can drop the append.
28
+ * Returns undefined when the directory is missing or the lock is not acquired.
29
+ */
30
+ export declare function withInboxMirrorLock<T>(inboxDir: string, waitMs: number, fn: () => T): T | undefined;
31
+ /**
32
+ * Remove credential and unknown `actingContext` keys. Allowed delegation
33
+ * fields are copied through. Does not mutate `item`.
34
+ */
35
+ export declare function redactAgentInboxItem<T>(item: T): T;
36
+ /**
37
+ * Rewrite mirror files that still contain delegated credentials, drop torn
38
+ * journal lines that contain them, and delete stale `*.json.tmp.*` files.
39
+ * A clean directory is left untouched, so repeating the call is a no-op.
40
+ * Returns how many records were rewritten or removed. Write and rename
41
+ * errors are counted on the report and are not thrown.
42
+ */
43
+ export declare function scrubInboxMirror(paths: Pick<AgentKitPaths, "inboxDir">, log?: (report: InboxScrubReport) => void): number;
44
+ //# sourceMappingURL=inbox-redact.d.ts.map
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Owner STS credentials ride on an agent inbox item as
3
+ * `actingContext.credentials`. The mirror files are readable by whatever
4
+ * model has file tools, so those credentials are removed before a write and
5
+ * again on read. Only `mode`, `ownerPersonUid`, `ownerRole`, and `expiresAt`
6
+ * stay on `actingContext`. Any other key under that object is dropped.
7
+ *
8
+ * A scrub that cannot write (EPERM, ENOSPC, and the same class) is counted
9
+ * and skipped. It does not throw into the poller or a reader. The next poll
10
+ * or read tries again. Failure logs are a count and errno codes, never a
11
+ * path or a file byte.
12
+ *
13
+ * `hq dm` output drops the rest of `actingContext` as well. That stricter
14
+ * stdout rule lives with the command; credential removal has this one
15
+ * implementation.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { writePrivateFile } from "./private-file.js";
20
+ const INBOX_JSONL_NAME = "inbox.jsonl";
21
+ const STATE_FILES = new Set(["seen-ids.json", "done-ids.json"]);
22
+ const LOCK_DIR_NAME = ".inbox-write.lock";
23
+ const LOCK_STALE_MS = 30_000;
24
+ const ACTING_CONTEXT_KEYS = ["mode", "ownerPersonUid", "ownerRole", "expiresAt"];
25
+ const ALLOWED_ACTING_KEYS = new Set(ACTING_CONTEXT_KEYS);
26
+ /** Substrings that mark a torn line or temp. The values themselves are not logged. */
27
+ const CREDENTIAL_MARKERS = ["\"credentials\"", "secretAccessKey", "sessionToken", "accessKeyId"];
28
+ function emptyTally() {
29
+ return { scrubbed: 0, failures: 0, codes: [] };
30
+ }
31
+ function errnoCode(err) {
32
+ const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
33
+ return typeof code === "string" && /^[A-Z][A-Z0-9]{1,15}$/.test(code) ? code : "ERR";
34
+ }
35
+ function noteFailure(tally, err) {
36
+ tally.failures += 1;
37
+ const code = errnoCode(err);
38
+ if (!tally.codes.includes(code))
39
+ tally.codes.push(code);
40
+ }
41
+ function sleepMs(ms) {
42
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
43
+ }
44
+ /**
45
+ * One writer at a time for the mirror directory. The journal is rewritten
46
+ * in place when credentials are scrubbed and appended when a message arrives;
47
+ * those two must not overlap or the rewrite can drop the append.
48
+ * Returns undefined when the directory is missing or the lock is not acquired.
49
+ */
50
+ export function withInboxMirrorLock(inboxDir, waitMs, fn) {
51
+ let dirStat;
52
+ try {
53
+ dirStat = fs.lstatSync(inboxDir);
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ if (!dirStat.isDirectory())
59
+ return undefined;
60
+ const lockDir = path.join(inboxDir, LOCK_DIR_NAME);
61
+ const deadline = Date.now() + Math.max(0, waitMs);
62
+ for (;;) {
63
+ try {
64
+ fs.mkdirSync(lockDir);
65
+ }
66
+ catch (err) {
67
+ if (err.code !== "EEXIST")
68
+ return undefined;
69
+ try {
70
+ if (Date.now() - fs.statSync(lockDir).mtimeMs > LOCK_STALE_MS)
71
+ fs.rmdirSync(lockDir);
72
+ }
73
+ catch {
74
+ /* the holder released it, or it is not a directory */
75
+ }
76
+ if (Date.now() >= deadline)
77
+ return undefined;
78
+ sleepMs(20);
79
+ continue;
80
+ }
81
+ try {
82
+ return fn();
83
+ }
84
+ finally {
85
+ try {
86
+ fs.rmdirSync(lockDir);
87
+ }
88
+ catch {
89
+ /* already released */
90
+ }
91
+ }
92
+ }
93
+ }
94
+ function actingContextRecord(value) {
95
+ if (!value || typeof value !== "object" || Array.isArray(value))
96
+ return null;
97
+ const acting = value.actingContext;
98
+ if (!acting || typeof acting !== "object" || Array.isArray(acting))
99
+ return null;
100
+ return acting;
101
+ }
102
+ function actingNeedsRewrite(acting) {
103
+ return Object.keys(acting).some((key) => !ALLOWED_ACTING_KEYS.has(key));
104
+ }
105
+ function allowlistedActing(acting) {
106
+ const kept = {};
107
+ for (const key of ACTING_CONTEXT_KEYS) {
108
+ if (Object.prototype.hasOwnProperty.call(acting, key))
109
+ kept[key] = acting[key];
110
+ }
111
+ return kept;
112
+ }
113
+ function textHasCredentialMaterial(text) {
114
+ return CREDENTIAL_MARKERS.some((marker) => text.includes(marker));
115
+ }
116
+ function isStaleJsonTemp(name) {
117
+ return (name.includes(".json.tmp.") ||
118
+ name.includes(".jsonl.tmp.") ||
119
+ name.endsWith(".json.tmp") ||
120
+ name.endsWith(".jsonl.tmp"));
121
+ }
122
+ /**
123
+ * Remove credential and unknown `actingContext` keys. Allowed delegation
124
+ * fields are copied through. Does not mutate `item`.
125
+ */
126
+ export function redactAgentInboxItem(item) {
127
+ const acting = actingContextRecord(item);
128
+ if (!acting || !actingNeedsRewrite(acting))
129
+ return item;
130
+ const record = item;
131
+ const kept = allowlistedActing(acting);
132
+ if (Object.keys(kept).length === 0) {
133
+ const { actingContext: _actingContext, ...without } = record;
134
+ return without;
135
+ }
136
+ return { ...record, actingContext: kept };
137
+ }
138
+ /**
139
+ * One journal record per line. A torn line cannot be parsed, and an
140
+ * interrupted append can still hold a credential on that line. Drop those
141
+ * lines. A torn line with no credential marker stays, so a truncated
142
+ * message that is not a secret is not thrown away.
143
+ */
144
+ function redactJsonl(text) {
145
+ let changed = 0;
146
+ const kept = [];
147
+ for (const line of text.split("\n")) {
148
+ if (!line.trim()) {
149
+ kept.push(line);
150
+ continue;
151
+ }
152
+ let parsed;
153
+ try {
154
+ parsed = JSON.parse(line);
155
+ }
156
+ catch {
157
+ if (!textHasCredentialMaterial(line)) {
158
+ kept.push(line);
159
+ continue;
160
+ }
161
+ changed += 1;
162
+ continue;
163
+ }
164
+ const acting = actingContextRecord(parsed);
165
+ if (!acting || !actingNeedsRewrite(acting)) {
166
+ kept.push(line);
167
+ continue;
168
+ }
169
+ changed += 1;
170
+ kept.push(JSON.stringify(redactAgentInboxItem(parsed)));
171
+ }
172
+ return { text: kept.join("\n"), changed };
173
+ }
174
+ function scrubJsonFile(file, tally) {
175
+ let text;
176
+ try {
177
+ const st = fs.lstatSync(file);
178
+ if (!st.isFile())
179
+ return;
180
+ text = fs.readFileSync(file, "utf8");
181
+ }
182
+ catch (err) {
183
+ if (err.code === "ENOENT")
184
+ return;
185
+ noteFailure(tally, err);
186
+ return;
187
+ }
188
+ let parsed;
189
+ try {
190
+ parsed = JSON.parse(text);
191
+ }
192
+ catch {
193
+ if (!textHasCredentialMaterial(text))
194
+ return;
195
+ try {
196
+ fs.unlinkSync(file);
197
+ tally.scrubbed += 1;
198
+ }
199
+ catch (err) {
200
+ noteFailure(tally, err);
201
+ }
202
+ return;
203
+ }
204
+ const acting = actingContextRecord(parsed);
205
+ if (!acting || !actingNeedsRewrite(acting))
206
+ return;
207
+ try {
208
+ // The temp writePrivateFile creates is the redacted body, not the original.
209
+ writePrivateFile(file, `${JSON.stringify(redactAgentInboxItem(parsed), null, 2)}\n`, 0o600);
210
+ tally.scrubbed += 1;
211
+ }
212
+ catch (err) {
213
+ noteFailure(tally, err);
214
+ }
215
+ }
216
+ function redactedTempBody(text) {
217
+ try {
218
+ return `${JSON.stringify(redactAgentInboxItem(JSON.parse(text)), null, 2)}\n`;
219
+ }
220
+ catch {
221
+ return "\n";
222
+ }
223
+ }
224
+ /** Delete a leftover `*.json.tmp.*` from a crashed atomic write. Rewrite it if it cannot be removed. */
225
+ function discardStaleTemp(file, tally) {
226
+ let text;
227
+ try {
228
+ const st = fs.lstatSync(file);
229
+ if (!st.isFile())
230
+ return;
231
+ text = fs.readFileSync(file, "utf8");
232
+ }
233
+ catch (err) {
234
+ if (err.code === "ENOENT")
235
+ return;
236
+ text = undefined;
237
+ }
238
+ const poisoned = text !== undefined && textHasCredentialMaterial(text);
239
+ try {
240
+ fs.unlinkSync(file);
241
+ if (poisoned)
242
+ tally.scrubbed += 1;
243
+ return;
244
+ }
245
+ catch (err) {
246
+ if (err.code === "ENOENT")
247
+ return;
248
+ if (text === undefined) {
249
+ noteFailure(tally, err);
250
+ return;
251
+ }
252
+ }
253
+ if (text === undefined)
254
+ return;
255
+ try {
256
+ writePrivateFile(file, redactedTempBody(text), 0o600);
257
+ if (poisoned)
258
+ tally.scrubbed += 1;
259
+ }
260
+ catch (err) {
261
+ noteFailure(tally, err);
262
+ }
263
+ }
264
+ function scrubJournal(journal, tally) {
265
+ for (let attempt = 0; attempt < 3; attempt++) {
266
+ let before;
267
+ try {
268
+ before = fs.readFileSync(journal, "utf8");
269
+ }
270
+ catch (err) {
271
+ if (err.code === "ENOENT")
272
+ return;
273
+ noteFailure(tally, err);
274
+ return;
275
+ }
276
+ const redacted = redactJsonl(before);
277
+ if (redacted.changed === 0)
278
+ return;
279
+ let latest;
280
+ try {
281
+ latest = fs.readFileSync(journal, "utf8");
282
+ }
283
+ catch (err) {
284
+ noteFailure(tally, err);
285
+ return;
286
+ }
287
+ if (latest !== before)
288
+ continue;
289
+ try {
290
+ writePrivateFile(journal, redacted.text, 0o600);
291
+ }
292
+ catch (err) {
293
+ noteFailure(tally, err);
294
+ return;
295
+ }
296
+ try {
297
+ if (fs.readFileSync(journal, "utf8") === redacted.text) {
298
+ tally.scrubbed += redacted.changed;
299
+ return;
300
+ }
301
+ }
302
+ catch {
303
+ /* the next pass, or the next read, rewrites whatever landed */
304
+ }
305
+ }
306
+ noteFailure(tally, Object.assign(new Error("inbox scrub did not land"), { code: "EAGAIN" }));
307
+ }
308
+ function scanInboxDir(inboxDir) {
309
+ const tally = emptyTally();
310
+ let names;
311
+ try {
312
+ names = fs.readdirSync(inboxDir);
313
+ }
314
+ catch (err) {
315
+ noteFailure(tally, err);
316
+ return tally;
317
+ }
318
+ for (const name of names) {
319
+ if (isStaleJsonTemp(name)) {
320
+ discardStaleTemp(path.join(inboxDir, name), tally);
321
+ continue;
322
+ }
323
+ if (!name.endsWith(".json") || STATE_FILES.has(name))
324
+ continue;
325
+ scrubJsonFile(path.join(inboxDir, name), tally);
326
+ }
327
+ scrubJournal(path.join(inboxDir, INBOX_JSONL_NAME), tally);
328
+ return tally;
329
+ }
330
+ function defaultScrubLog(report) {
331
+ if (report.scrubbed > 0) {
332
+ console.error(`inbox: scrubbed ${report.scrubbed} mirrored record(s) containing delegated credentials`);
333
+ }
334
+ if (report.failures > 0) {
335
+ const codes = report.codes.length > 0 ? report.codes.join(",") : "ERR";
336
+ console.error(`inbox: scrub failed for ${report.failures} file(s) (${codes})`);
337
+ }
338
+ }
339
+ /**
340
+ * Rewrite mirror files that still contain delegated credentials, drop torn
341
+ * journal lines that contain them, and delete stale `*.json.tmp.*` files.
342
+ * A clean directory is left untouched, so repeating the call is a no-op.
343
+ * Returns how many records were rewritten or removed. Write and rename
344
+ * errors are counted on the report and are not thrown.
345
+ */
346
+ export function scrubInboxMirror(paths, log = defaultScrubLog) {
347
+ let tally = emptyTally();
348
+ try {
349
+ // Missing directory: do not create it. Lock not acquired: leave the files
350
+ // for the next read rather than rewriting the journal without the lock.
351
+ tally = withInboxMirrorLock(paths.inboxDir, 2_000, () => scanInboxDir(paths.inboxDir)) ?? tally;
352
+ }
353
+ catch (err) {
354
+ noteFailure(tally, err);
355
+ }
356
+ if (tally.scrubbed > 0 || tally.failures > 0) {
357
+ log({ scrubbed: tally.scrubbed, failures: tally.failures, codes: tally.codes });
358
+ }
359
+ return tally.scrubbed;
360
+ }
361
+ //# sourceMappingURL=inbox-redact.js.map
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
+ import { redactAgentInboxItem, scrubInboxMirror } from "./inbox-redact.js";
13
14
  import { writePrivateFile } from "./private-file.js";
14
15
  const DONE_IDS_NAME = "done-ids.json";
15
16
  const MAX_DONE_IDS = 5000;
@@ -37,6 +38,14 @@ export function markInboxDone(paths, ids) {
37
38
  }
38
39
  /** Every mirrored entry, oldest first, de-duplicated by id (last write wins). */
39
40
  export function readMirroredInbox(paths) {
41
+ // Files written before credential redaction are rewritten here. A failed
42
+ // rewrite is retried on the next read. It must not hide mail already on disk.
43
+ try {
44
+ scrubInboxMirror(paths);
45
+ }
46
+ catch {
47
+ /* scrubInboxMirror records write failures and does not throw them */
48
+ }
40
49
  let text;
41
50
  try {
42
51
  text = fs.readFileSync(path.join(paths.inboxDir, INBOX_JSONL), "utf8");
@@ -49,7 +58,7 @@ export function readMirroredInbox(paths) {
49
58
  if (!line.trim())
50
59
  continue;
51
60
  try {
52
- const entry = JSON.parse(line);
61
+ const entry = redactAgentInboxItem(JSON.parse(line));
53
62
  if (typeof entry.id === "string" && entry.id) {
54
63
  byId.delete(entry.id);
55
64
  byId.set(entry.id, entry);
@@ -144,7 +153,7 @@ export async function mergeAgentInbox(paths, fetchServerInbox) {
144
153
  const raw = m;
145
154
  const id = typeof raw.id === "string" ? raw.id : typeof raw.messageId === "string" ? raw.messageId : null;
146
155
  if (id && !byId.has(id))
147
- byId.set(id, { ...raw, id });
156
+ byId.set(id, redactAgentInboxItem({ ...raw, id }));
148
157
  }
149
158
  }
150
159
  else {
@@ -18,6 +18,7 @@
18
18
  import { CLI_VERSION } from "../../../cli-version.js";
19
19
  import { peekIdToken } from "../../../utils/id-token.js";
20
20
  import { isRecipientUid } from "../../../lib/notify-recipient.js";
21
+ import { redactAgentInboxItem } from "../inbox-redact.js";
21
22
  import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../inbox-state.js";
22
23
  import { AttachmentCompanyUnresolvedError, fetchChatAttachment } from "./attachments.js";
23
24
  import { errorResult, McpToolInputError, textResult } from "./jsonrpc.js";
@@ -287,7 +288,7 @@ export function buildAgentMcpTools(clients) {
287
288
  const rows = [...byId.values()]
288
289
  .filter((e) => includeDone || !done.has(e.id))
289
290
  .slice(0, limit)
290
- .map((e) => summarizeInboxEntry(e, done.has(e.id)));
291
+ .map((e) => summarizeInboxEntry(redactAgentInboxItem(e), done.has(e.id)));
291
292
  const hasFiles = rows.some((r) => r.attachments?.length);
292
293
  return textResult(clampOutput(JSON.stringify({
293
294
  count: rows.length,
@@ -20,7 +20,8 @@ import * as path from "node:path";
20
20
  import { vaultApiFetch } from "../../../utils/vault-api.js";
21
21
  import { writeComponentStatus } from "../creds.js";
22
22
  import { pendingInbox } from "../inbox-state.js";
23
- import { writePrivateFile } from "./../private-file.js";
23
+ import { redactAgentInboxItem, scrubInboxMirror, withInboxMirrorLock } from "../inbox-redact.js";
24
+ import { mkdirPrivate, writePrivateFile } from "./../private-file.js";
24
25
  import { createAgentKitInboxBackoffFlagReader } from "../inbox-poll-backoff-flag.js";
25
26
  import { buildWakeNotice, currentWakeState, fireWake, readWakeConfig, shouldWake, } from "../wake.js";
26
27
  export const SEEN_IDS_NAME = "seen-ids.json";
@@ -79,12 +80,22 @@ function inboxJournalContains(paths, id) {
79
80
  }
80
81
  export function mirrorInboxItem(paths, item) {
81
82
  const dest = path.join(paths.inboxDir, `${item.id}.json`);
82
- const recoveringInterruptedMirror = fs.existsSync(dest);
83
- writePrivateFile(dest, `${JSON.stringify(item.raw, null, 2)}\n`, 0o600);
84
- if (!recoveringInterruptedMirror || !inboxJournalContains(paths, item.id)) {
85
- fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
86
- }
87
- return dest;
83
+ // One redaction covers the per-id file and the journal line. The journal
84
+ // spreads this object, so a credential left on `raw` would be written twice.
85
+ const safeRaw = redactAgentInboxItem(item.raw);
86
+ const write = () => {
87
+ const recoveringInterruptedMirror = fs.existsSync(dest);
88
+ writePrivateFile(dest, `${JSON.stringify(safeRaw, null, 2)}\n`, 0o600);
89
+ if (!recoveringInterruptedMirror || !inboxJournalContains(paths, item.id)) {
90
+ fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...safeRaw })}\n`, { mode: 0o600 });
91
+ }
92
+ return dest;
93
+ };
94
+ // Create the directory first so the scrub lock has somewhere to live.
95
+ // The write still runs without the lock if the directory cannot be locked;
96
+ // dropping the message is worse than a rare overlapping rewrite.
97
+ mkdirPrivate(paths.inboxDir);
98
+ return withInboxMirrorLock(paths.inboxDir, 10_000, write) ?? write();
88
99
  }
89
100
  export const INBOX_ERROR_BODY_MAX = 300;
90
101
  /**
@@ -142,6 +153,24 @@ export function defaultAckItem(agentUid, apiBaseUrl) {
142
153
  export async function pollInboxOnce(deps) {
143
154
  if (deps.signal?.aborted)
144
155
  return { ok: false, mirrored: 0 };
156
+ // Mirrors written before credential redaction are scrubbed on the next poll,
157
+ // before a wake can send the bot at the files. A write or rename error is
158
+ // counted and retried on the next poll. The log is a count and errno codes,
159
+ // never a path or a file byte.
160
+ try {
161
+ scrubInboxMirror(deps.paths, (report) => {
162
+ if (report.scrubbed > 0) {
163
+ deps.log("info", `inbox scrubbed ${report.scrubbed} mirrored record(s) containing delegated credentials`);
164
+ }
165
+ if (report.failures > 0) {
166
+ const codes = report.codes.length > 0 ? report.codes.join(",") : "ERR";
167
+ deps.log("warn", `inbox scrub failed for ${report.failures} file(s) (${codes})`);
168
+ }
169
+ });
170
+ }
171
+ catch {
172
+ deps.log("warn", "inbox scrub failed for 1 file(s) (ERR)");
173
+ }
145
174
  const fetchInbox = deps.fetchInbox ?? defaultFetchInbox(deps.agentUid, deps.apiBaseUrl);
146
175
  const ackItem = deps.ackItem ?? defaultAckItem(deps.agentUid, deps.apiBaseUrl);
147
176
  // No default cache warm: the human conversation routes 403 for agent callers.
@@ -1,6 +1,9 @@
1
1
  /** hq-flags key for the staged HQ Anywhere runtime. The registry default is on. */
2
2
  export declare const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
3
3
  export declare const HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER = "hq-anywhere-runtime.revoked";
4
+ export declare const HQ_ANYWHERE_RUNTIME_FALSE_MAX_AGE_MS: number;
5
+ /** An old disable decision must not survive an unavailable registry indefinitely. */
6
+ export declare function freshAnywhereRuntimeFlags(flags: Record<string, boolean>, fetchedAt: number, now?: number): Record<string, boolean>;
4
7
  /** The HQ Anywhere environment kill switch stays compatible with managed provider wrappers. */
5
8
  export declare function hqAnywhereRuntimeLocallyDisabled(env?: NodeJS.ProcessEnv): boolean;
6
9
  //# sourceMappingURL=anywhere-runtime-flag-key.d.ts.map
@@ -1,6 +1,15 @@
1
1
  /** hq-flags key for the staged HQ Anywhere runtime. The registry default is on. */
2
2
  export const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
3
3
  export const HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER = "hq-anywhere-runtime.revoked";
4
+ export const HQ_ANYWHERE_RUNTIME_FALSE_MAX_AGE_MS = 60 * 60 * 1000;
5
+ /** An old disable decision must not survive an unavailable registry indefinitely. */
6
+ export function freshAnywhereRuntimeFlags(flags, fetchedAt, now = Date.now()) {
7
+ if (flags[HQ_ANYWHERE_RUNTIME_FLAG] !== false || now - fetchedAt < HQ_ANYWHERE_RUNTIME_FALSE_MAX_AGE_MS)
8
+ return flags;
9
+ const fresh = { ...flags };
10
+ delete fresh[HQ_ANYWHERE_RUNTIME_FLAG];
11
+ return fresh;
12
+ }
4
13
  /** The HQ Anywhere environment kill switch stays compatible with managed provider wrappers. */
5
14
  export function hqAnywhereRuntimeLocallyDisabled(env = process.env) {
6
15
  const value = env.HQ_FLAG_HQ_ANYWHERE_RUNTIME;
@@ -77,6 +77,8 @@ export function renderBotLaunchdPlist(p) {
77
77
  ["HOME", p.home],
78
78
  ["PATH", p.pathEntries.join(":")],
79
79
  ["HQ_NO_UPDATE_CHECK", "1"],
80
+ // Bots must never replace/relaunch the interactive desktop, including during setup.
81
+ ["HQ_NO_ENSURE_HQ_DESKTOP", "1"],
80
82
  ...(p.hqRoot ? [["HQ_ROOT", p.hqRoot]] : []),
81
83
  ]
82
84
  .map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
@@ -135,6 +137,7 @@ RestartSec=5
135
137
  Environment=HOME=${p.home}
136
138
  Environment=PATH=${p.pathEntries.join(":")}
137
139
  Environment=HQ_NO_UPDATE_CHECK=1
140
+ Environment=HQ_NO_ENSURE_HQ_DESKTOP=1
138
141
  ${p.hqRoot ? `Environment=HQ_ROOT=${p.hqRoot}\n` : ""}StandardOutput=append:${p.logPath}
139
142
  StandardError=append:${p.logPath}
140
143
 
@@ -345,7 +345,9 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
345
345
  const recheck = memo !== undefined && (nowMs() - memo.at >= MODEL_FALLBACK_RECHECK_MS || memo.turns >= MODEL_FALLBACK_RECHECK_TURNS);
346
346
  const remembered = memo && !recheck ? memo : undefined;
347
347
  const args = runtime.buildArgs(remembered ? { ...turnInput, model: remembered.to } : turnInput, { outputFile, newSessionId });
348
- const env = input.env ?? deps.env ?? process.env;
348
+ // Enforce at the model boundary too: old plists and direct `bot run` callers
349
+ // may lack the guard. A bot prompt must never quit the user's desktop app.
350
+ const env = { ...(input.env ?? deps.env ?? process.env), HQ_NO_ENSURE_HQ_DESKTOP: "1" };
349
351
  const binary = runtime.resolveBinary?.(env, input.cwd, turnInput.platform) ?? runtime.binary;
350
352
  const stdin = runtime.stdinInput?.(turnInput);
351
353
  const protocol = runtime.spawnProtocol?.(turnInput);
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { removeEntryNoFollow } from "../utils/safe-link.js";
3
4
  const WIN32_RESERVED_CHARS = /[<>:"/\\|?*]/;
4
5
  const WIN32_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
5
6
  const LOCAL_SLUG = /^[a-z0-9][a-z0-9-]*$/;
@@ -227,7 +228,8 @@ function pruneStaleNamespacedWrappers(runtimeRoot, currentWrapperName, hqRoot, c
227
228
  if (LOCAL_SLUG.test(skillSlug) &&
228
229
  !fs.existsSync(canonicalSkill) &&
229
230
  isGeneratedNamespacedWrapper(candidate, hqRoot, companySlug)) {
230
- fs.rmSync(candidate, { recursive: true, force: false });
231
+ // A Windows wrapper is a junction; never recurse into its target.
232
+ removeEntryNoFollow(candidate);
231
233
  removeGeneratedMarker(candidate);
232
234
  }
233
235
  }
@@ -340,7 +342,7 @@ export function surfaceCompanySkill(input) {
340
342
  }
341
343
  if (previousMoved) {
342
344
  try {
343
- fs.rmSync(backupPath, { recursive: true, force: true });
345
+ removeEntryNoFollow(backupPath, { force: true });
344
346
  }
345
347
  catch {
346
348
  // The new wrapper is already committed. A hidden generated backup is
@@ -354,13 +356,13 @@ export function surfaceCompanySkill(input) {
354
356
  for (const legacyPath of [legacyBase, `${legacyBase}.md`]) {
355
357
  if (legacyPath !== wrapperPath &&
356
358
  isGeneratedLegacyEntry(legacyPath, sourceDir, skillFile)) {
357
- fs.rmSync(legacyPath, { recursive: true, force: false });
359
+ removeEntryNoFollow(legacyPath);
358
360
  }
359
361
  }
360
362
  }
361
363
  }
362
364
  catch (error) {
363
- fs.rmSync(stagingPath, { recursive: true, force: true });
365
+ removeEntryNoFollow(stagingPath, { force: true });
364
366
  throw error;
365
367
  }
366
368
  return { status: 0, wrapperPath };