@indigoai-us/hq-cli 5.345.147 → 5.345.149

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 (168) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +2 -0
  3. package/assets/bot-workers/setup/worker.yaml +11 -0
  4. package/dist/command-catalog.generated.d.ts +341 -11
  5. package/dist/command-catalog.generated.js +421 -11
  6. package/dist/command-help.js +2 -1
  7. package/dist/commands/access.d.ts +45 -2
  8. package/dist/commands/access.js +537 -223
  9. package/dist/commands/agent-inbox.js +2 -1
  10. package/dist/commands/billing-usage.d.ts +33 -2
  11. package/dist/commands/billing-usage.js +75 -4
  12. package/dist/commands/bot.js +2 -2
  13. package/dist/commands/channels.d.ts +27 -0
  14. package/dist/commands/channels.js +108 -4
  15. package/dist/commands/cloud.js +31 -1
  16. package/dist/commands/core-refresh-vault-access-native.d.ts +33 -0
  17. package/dist/commands/core-refresh-vault-access-native.js +356 -28
  18. package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
  19. package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
  20. package/dist/commands/core.js +9 -0
  21. package/dist/commands/daemon.d.ts +7 -0
  22. package/dist/commands/daemon.js +37 -3
  23. package/dist/commands/dm.js +18 -3
  24. package/dist/commands/doctor.d.ts +11 -1
  25. package/dist/commands/doctor.js +56 -1
  26. package/dist/commands/install-global.d.ts +8 -8
  27. package/dist/commands/install-global.js +139 -61
  28. package/dist/commands/lanes-shared.d.ts +3 -1
  29. package/dist/commands/lanes.d.ts +4 -4
  30. package/dist/commands/lanes.js +380 -25
  31. package/dist/commands/library-add.d.ts +31 -1
  32. package/dist/commands/library-add.js +69 -2
  33. package/dist/commands/library.js +37 -3
  34. package/dist/commands/pack-install.d.ts +2 -2
  35. package/dist/commands/pack-install.js +133 -35
  36. package/dist/commands/reindex.js +19 -0
  37. package/dist/commands/sync-manifest.d.ts +2 -9
  38. package/dist/commands/sync-manifest.js +4 -15
  39. package/dist/hook-engine-path.d.ts +4 -0
  40. package/dist/hook-engine-path.js +43 -0
  41. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
  42. package/dist/hq-cloud-sync-runner/package.json +1 -1
  43. package/dist/index.d.ts +10 -0
  44. package/dist/index.js +76 -5
  45. package/dist/lazy-commands.js +2 -0
  46. package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
  47. package/dist/lib/agent-kit/inbox-redact.js +361 -0
  48. package/dist/lib/agent-kit/inbox-state.js +11 -2
  49. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  50. package/dist/lib/agent-kit/run/inbox.js +36 -7
  51. package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
  52. package/dist/lib/anywhere-runtime-flag-key.js +9 -0
  53. package/dist/lib/billing/usage-cost.d.ts +31 -1
  54. package/dist/lib/billing/usage-cost.js +46 -0
  55. package/dist/lib/company-skill-wrapper.js +6 -4
  56. package/dist/lib/daemon/install.d.ts +47 -1
  57. package/dist/lib/daemon/install.js +181 -15
  58. package/dist/lib/daemon/lock.d.ts +32 -0
  59. package/dist/lib/daemon/lock.js +55 -2
  60. package/dist/lib/db/local.d.ts +2 -2
  61. package/dist/lib/db/local.js +2 -2
  62. package/dist/lib/db/sql.js +2 -2
  63. package/dist/lib/db/sqlite-binding.d.ts +22 -0
  64. package/dist/lib/db/sqlite-binding.js +168 -0
  65. package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
  66. package/dist/lib/doctor/checks/anywhere.d.ts +15 -0
  67. package/dist/lib/doctor/checks/anywhere.js +77 -11
  68. package/dist/lib/doctor/checks/cli-path.d.ts +12 -0
  69. package/dist/lib/doctor/checks/cli-path.js +46 -0
  70. package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
  71. package/dist/lib/doctor/checks/hook-engine.js +17 -0
  72. package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
  73. package/dist/lib/doctor/checks/sync-health.js +38 -2
  74. package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
  75. package/dist/lib/doctor/checks/temp-homes.js +54 -0
  76. package/dist/lib/doctor/fix/apply.d.ts +90 -1
  77. package/dist/lib/doctor/fix/apply.js +176 -10
  78. package/dist/lib/doctor/fix/json-output.d.ts +4 -1
  79. package/dist/lib/doctor/fix/json-output.js +6 -0
  80. package/dist/lib/doctor/fix/remediation.d.ts +7 -1
  81. package/dist/lib/doctor/fix/remediation.js +23 -1
  82. package/dist/lib/doctor/registry.js +10 -0
  83. package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
  84. package/dist/lib/doctor/temp-home-sweeper.js +418 -0
  85. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  86. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  87. package/dist/lib/flag-registry.js +9 -1
  88. package/dist/lib/flag-snapshot-file.js +8 -3
  89. package/dist/lib/hook-engine/reconcile.d.ts +9 -0
  90. package/dist/lib/hook-engine/reconcile.js +134 -0
  91. package/dist/lib/hook-engine/registration.d.ts +17 -0
  92. package/dist/lib/hook-engine/registration.js +50 -0
  93. package/dist/lib/hook-engine/resolve.d.ts +31 -0
  94. package/dist/lib/hook-engine/resolve.js +134 -0
  95. package/dist/lib/install/claude-targets.d.ts +2 -1
  96. package/dist/lib/install/claude-targets.js +8 -3
  97. package/dist/lib/install/codex-targets.d.ts +3 -0
  98. package/dist/lib/install/codex-targets.js +15 -4
  99. package/dist/lib/install/hook-target.d.ts +9 -0
  100. package/dist/lib/install/hook-target.js +39 -0
  101. package/dist/lib/lanes/admission-host.d.ts +1 -0
  102. package/dist/lib/lanes/admission-host.js +8 -1
  103. package/dist/lib/lanes/admission.d.ts +4 -0
  104. package/dist/lib/lanes/admission.js +6 -2
  105. package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
  106. package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
  107. package/dist/lib/lanes/index.d.ts +2 -2
  108. package/dist/lib/lanes/index.js +1 -1
  109. package/dist/lib/lanes/keep-alive.js +4 -1
  110. package/dist/lib/lanes/local-host-identity.d.ts +40 -0
  111. package/dist/lib/lanes/local-host-identity.js +314 -0
  112. package/dist/lib/lanes/reaper.d.ts +1 -1
  113. package/dist/lib/lanes/reaper.js +7 -0
  114. package/dist/lib/lanes/reconcile.d.ts +4 -0
  115. package/dist/lib/lanes/reconcile.js +16 -8
  116. package/dist/lib/lanes/spawn.d.ts +20 -0
  117. package/dist/lib/lanes/spawn.js +48 -2
  118. package/dist/lib/lanes/status-fields.js +6 -1
  119. package/dist/lib/lanes/store.js +8 -1
  120. package/dist/lib/lanes/throttle.d.ts +49 -6
  121. package/dist/lib/lanes/throttle.js +154 -17
  122. package/dist/lib/lanes/types.d.ts +22 -1
  123. package/dist/lib/lanes/types.js +27 -3
  124. package/dist/lib/lanes/watcher.js +27 -2
  125. package/dist/lib/lanes/worker-continuity.d.ts +2 -0
  126. package/dist/lib/lanes/worker-continuity.js +18 -11
  127. package/dist/lib/manifest-disabled.d.ts +13 -0
  128. package/dist/lib/manifest-disabled.js +19 -0
  129. package/dist/lib/service-manager/launchd.d.ts +1 -0
  130. package/dist/lib/service-manager/launchd.js +1 -1
  131. package/dist/lib/windows/console-encoding.d.ts +24 -0
  132. package/dist/lib/windows/console-encoding.js +83 -0
  133. package/dist/lib/windows/install-followup.d.ts +35 -0
  134. package/dist/lib/windows/install-followup.js +142 -0
  135. package/dist/lib/windows/user-path.d.ts +125 -0
  136. package/dist/lib/windows/user-path.js +275 -0
  137. package/dist/lib/work-context/auto-bind.d.ts +1 -1
  138. package/dist/lib/work-context/auto-bind.js +22 -3
  139. package/dist/lib/work-context/lane-bind.d.ts +31 -0
  140. package/dist/lib/work-context/lane-bind.js +101 -0
  141. package/dist/lib/work-context/organize.js +1 -0
  142. package/dist/lib/work-context/prd-sync.js +7 -0
  143. package/dist/lib/work-context/project.d.ts +36 -10
  144. package/dist/lib/work-context/project.js +130 -41
  145. package/dist/lib/work-context/reconcile.d.ts +6 -0
  146. package/dist/lib/work-context/reconcile.js +25 -5
  147. package/dist/lib/work-context/state.d.ts +6 -0
  148. package/dist/lib/work-context/state.js +29 -1
  149. package/dist/main.js +2 -1
  150. package/dist/unknown-command.d.ts +18 -0
  151. package/dist/unknown-command.js +88 -0
  152. package/dist/utils/cognito-session.d.ts +8 -0
  153. package/dist/utils/cognito-session.js +14 -2
  154. package/dist/utils/copy-tree.d.ts +61 -0
  155. package/dist/utils/copy-tree.js +249 -0
  156. package/dist/utils/environmental-error.js +7 -3
  157. package/dist/utils/hook-trust.d.ts +9 -0
  158. package/dist/utils/hook-trust.js +30 -2
  159. package/dist/utils/npm-invocation.d.ts +27 -0
  160. package/dist/utils/npm-invocation.js +54 -0
  161. package/dist/utils/qmd-native-binding-error.js +3 -1
  162. package/dist/utils/resolve-executable.d.ts +2 -0
  163. package/dist/utils/resolve-executable.js +6 -2
  164. package/dist/utils/safe-link.d.ts +50 -0
  165. package/dist/utils/safe-link.js +300 -0
  166. package/package.json +17 -5
  167. package/scripts/hook-engine-manifest.mjs +22 -0
  168. package/scripts/install-hook-engine.mjs +10 -0
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.87","bundleSha256":"916fb28a230f0f0dd83cfaa87366670512898636a137c74a4c989a261301cf5e","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.88","bundleSha256":"7e9ee380eae55e059a474fd5f415e8aeb16088805b761d791318d5299645becd","formatVersion":1}
package/dist/index.d.ts CHANGED
@@ -3,6 +3,15 @@ import "./node-preflight.js";
3
3
  import "./node-network-compat-fast.js";
4
4
  import { isVersionRequest } from "./version-request.js";
5
5
  import { isFastCoreRequest } from "./fast-scaffold.js";
6
+ /**
7
+ * Whether the install follow-up would read the registry or spawn the mesh
8
+ * daemon. False for fast routes' callers, and for commands that are not run
9
+ * from an npm install: the PATH repair needs the npm shim folder above
10
+ * node_modules, and the daemon step needs the installed package. Inlined so
11
+ * those routes do not load user-path.ts. Keep the skip rules aligned with
12
+ * install-followup.ts.
13
+ */
14
+ export declare function installFollowupWouldDoWork(argv: readonly string[], env: NodeJS.ProcessEnv, platform: NodeJS.Platform): boolean;
6
15
  type PackageUseLeaseLoader = () => Promise<void>;
7
16
  type PackageUseUpdateProbe = () => Promise<boolean>;
8
17
  /** Resolves true once the command was re-run (exit code already set). */
@@ -18,6 +27,7 @@ declare function runCliEntry(entryUrl: string, dispatch: () => void, loadLease?:
18
27
  export declare const __test__: {
19
28
  isVersionRequest: typeof isVersionRequest;
20
29
  isFastCoreRequest: typeof isFastCoreRequest;
30
+ installFollowupWouldDoWork: typeof installFollowupWouldDoWork;
21
31
  dispatchWithPackageUseLease: typeof dispatchWithPackageUseLease;
22
32
  waitForUpdateThenReexec: typeof waitForUpdateThenReexec;
23
33
  runCliEntry: typeof runCliEntry;
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.
@@ -155,10 +156,48 @@ async function runMonitorCheckFastWithRecovery(request) {
155
156
  return;
156
157
  await runMainWithInstallTreeRecovery();
157
158
  }
159
+ /**
160
+ * Whether the install follow-up would read the registry or spawn the mesh
161
+ * daemon. False for fast routes' callers, and for commands that are not run
162
+ * from an npm install: the PATH repair needs the npm shim folder above
163
+ * node_modules, and the daemon step needs the installed package. Inlined so
164
+ * those routes do not load user-path.ts. Keep the skip rules aligned with
165
+ * install-followup.ts.
166
+ */
167
+ export function installFollowupWouldDoWork(argv, env, platform) {
168
+ if (env.HQ_INSTALL_FOLLOWUP === "1" || env.HQ_STAGED_INSTALL === "1")
169
+ return false;
170
+ const args = argv.slice(2).filter((arg) => arg !== "--verbose");
171
+ if (args[0] === "mesh" && args[1] === "daemon")
172
+ return false;
173
+ if (args[0] === "daemon" && (args[1] === "run" || args[1] === "install"))
174
+ return false;
175
+ if (platform === "win32")
176
+ return /[/\\]node_modules[/\\]/i.test(argv[1] ?? "");
177
+ return /node_modules[/\\]@indigoai-us[/\\]hq-cli[/\\]/i.test(argv[1] ?? "");
178
+ }
158
179
  function dispatchCliEntry() {
159
180
  // Before anything spawns: a background hq on Windows (no terminal) hides the
160
181
  // console window of every child it starts (feedback_351a40d5).
161
182
  installWindowsHideDefault();
183
+ const engineArgs = hookEnginePathArgs(process.argv);
184
+ if (engineArgs !== null) {
185
+ void runHookEnginePath(engineArgs);
186
+ return;
187
+ }
188
+ // cmd.exe code page 437 prints em dashes as mojibake. Pipes stay UTF-8.
189
+ // Loaded only on Windows so Linux fast routes do not pay for the module.
190
+ if (process.platform === "win32") {
191
+ void import("./lib/windows/console-encoding.js").then(({ installWindowsConsoleEncoding }) => {
192
+ installWindowsConsoleEncoding();
193
+ }).catch(() => undefined).then(() => {
194
+ dispatchCliRoutes();
195
+ });
196
+ return;
197
+ }
198
+ dispatchCliRoutes();
199
+ }
200
+ function dispatchCliRoutes() {
162
201
  if (process.platform === "win32" && coreCommandHasPathOperands(process.argv)) {
163
202
  // Keep cygpath off non-Windows and non-path command routes.
164
203
  process.argv = normalizeCorePathOperands(process.argv, "win32", cygpathMixedPath);
@@ -166,6 +205,37 @@ function dispatchCliEntry() {
166
205
  const versionRequest = isVersionRequest(process.argv);
167
206
  const helpRequest = !versionRequest && isCommandHelpRequest(process.argv);
168
207
  const parsedFastCore = versionRequest || helpRequest ? null : parseFastCore(process.argv);
208
+ const monitorRequest = versionRequest || helpRequest ? null : parseMonitorCheckFastRequest(process.argv);
209
+ const secretsRequest = versionRequest || helpRequest || monitorRequest || parsedFastCore
210
+ ? null
211
+ : parseSecretsEnvFastRequest(process.argv);
212
+ const mcpRequest = !versionRequest &&
213
+ !helpRequest &&
214
+ !monitorRequest &&
215
+ !parsedFastCore &&
216
+ !secretsRequest &&
217
+ isMcpServeRequest(process.argv);
218
+ // `--version`, hook, MCP, and secrets fast routes must not pay for PATH or
219
+ // daemon work, so they never start PowerShell or reg.exe. The module stays
220
+ // unloaded when there is nothing to do, so a Linux fast route does not
221
+ // import it.
222
+ if (!versionRequest && !monitorRequest && !parsedFastCore && !secretsRequest && !mcpRequest
223
+ && installFollowupWouldDoWork(process.argv, process.env, process.platform)) {
224
+ void import("./lib/windows/install-followup.js").then(({ runWindowsInstallFollowup }) => {
225
+ try {
226
+ runWindowsInstallFollowup({ argv: process.argv });
227
+ }
228
+ catch {
229
+ // Install repair must not turn into a failed command.
230
+ }
231
+ }).catch(() => undefined).then(() => {
232
+ dispatchCliCommand(versionRequest, helpRequest, parsedFastCore, monitorRequest, secretsRequest, mcpRequest);
233
+ });
234
+ return;
235
+ }
236
+ dispatchCliCommand(versionRequest, helpRequest, parsedFastCore, monitorRequest, secretsRequest, mcpRequest);
237
+ }
238
+ function dispatchCliCommand(versionRequest, helpRequest, parsedFastCore, monitorRequest, secretsRequest, mcpRequest) {
169
239
  if (versionRequest) {
170
240
  // Bare `--version` is the CLI semver only (the contract field updaters
171
241
  // compare). `hq version` and `--json` print the labelled HQ/CLI report.
@@ -196,11 +266,11 @@ function dispatchCliEntry() {
196
266
  await runMainWithInstallTreeRecovery();
197
267
  });
198
268
  }
199
- else if (parseMonitorCheckFastRequest(process.argv)) {
269
+ else if (monitorRequest) {
200
270
  // The Stop hook runs this every turn in every session. Answer the common
201
271
  // no-active-lane case from the lane-record directory alone; anything the
202
272
  // fast route cannot prove falls back to the full command graph.
203
- void runMonitorCheckFastWithRecovery(parseMonitorCheckFastRequest(process.argv));
273
+ void runMonitorCheckFastWithRecovery(monitorRequest);
204
274
  }
205
275
  else if (parsedFastCore?.entry.kind === "native" &&
206
276
  (process.env.HQ_FLAG_HQ_CORE_NATIVE_FAST_ROUTE === "true" ||
@@ -216,8 +286,8 @@ function dispatchCliEntry() {
216
286
  // existing full-graph route.
217
287
  void runNativeFastRouteWithRecovery(parsedFastCore);
218
288
  }
219
- else if (parseSecretsEnvFastRequest(process.argv)) {
220
- const request = parseSecretsEnvFastRequest(process.argv);
289
+ else if (secretsRequest) {
290
+ const request = secretsRequest;
221
291
  void import("./secrets-env-fast-route.js").then(async (route) => {
222
292
  await route.dispatchSecretsEnvFastRequest(request, {
223
293
  isEnabled: () => route.secretsEnvFastRouteEnabled(request),
@@ -231,7 +301,7 @@ function dispatchCliEntry() {
231
301
  });
232
302
  });
233
303
  }
234
- else if (isMcpServeRequest(process.argv)) {
304
+ else if (mcpRequest) {
235
305
  // Keep the minimal startup route; runMcpServe applies the hq-flags gate.
236
306
  void import("./commands/mcp-serve.js").then(({ runMcpServe }) => runMcpServe());
237
307
  }
@@ -367,6 +437,7 @@ void runCliEntry(import.meta.url, dispatchCliEntry);
367
437
  export const __test__ = {
368
438
  isVersionRequest,
369
439
  isFastCoreRequest,
440
+ installFollowupWouldDoWork,
370
441
  dispatchWithPackageUseLease,
371
442
  waitForUpdateThenReexec,
372
443
  runCliEntry,
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { COMMAND_CATALOG } from "./command-catalog.generated.js";
6
+ import { installUnknownCommandSuggestions } from "./unknown-command.js";
6
7
  const commandCatalog = COMMAND_CATALOG;
7
8
  /**
8
9
  * One entry for every root, including groups that are assembled by several
@@ -105,6 +106,7 @@ export function findRuntimeHqRoot(start, parentOf = dirname, isRoot = (directory
105
106
  export function registerCommandCatalog(program) {
106
107
  for (const command of commandCatalog)
107
108
  program.addCommand(commandDefinition(command));
109
+ installUnknownCommandSuggestions(program);
108
110
  }
109
111
  function commandForToken(token) {
110
112
  return commandCatalog.find((candidate) => candidate.name === token || candidate.aliases.includes(token));
@@ -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,