@indigoai-us/hq-cli 5.345.155 → 5.345.156

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 (34) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/command-catalog.generated.d.ts +5 -1
  3. package/dist/command-catalog.generated.js +6 -1
  4. package/dist/commands/agent-inbox.js +2 -2
  5. package/dist/commands/core.js +1 -1
  6. package/dist/commands/files-browse.d.ts +16 -0
  7. package/dist/commands/files-browse.js +52 -1
  8. package/dist/commands/integrations-core.d.ts +13 -1
  9. package/dist/commands/integrations-core.js +49 -4
  10. package/dist/commands/integrations.js +4 -2
  11. package/dist/commands/secrets.d.ts +16 -0
  12. package/dist/commands/secrets.js +72 -3
  13. package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
  14. package/dist/lib/agent-kit/inbox-state.js +5 -1
  15. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  16. package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
  17. package/dist/lib/doctor/checks/sync-health.js +66 -1
  18. package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
  19. package/dist/lib/doctor/company-resolution-state.js +89 -0
  20. package/dist/lib/doctor/fix/apply.d.ts +36 -6
  21. package/dist/lib/doctor/fix/apply.js +128 -12
  22. package/dist/lib/doctor/types.d.ts +1 -1
  23. package/dist/lib/doctor/types.js +1 -0
  24. package/dist/lib/hook-engine/resolve.js +6 -2
  25. package/dist/lib/index-render/company-knowledge.d.ts +1 -1
  26. package/dist/lib/index-render/company-knowledge.js +3 -2
  27. package/dist/lib/index-render/index.js +44 -3
  28. package/dist/lib/index-render/projects.d.ts +1 -1
  29. package/dist/lib/index-render/projects.js +4 -2
  30. package/dist/sentry-before-send.js +1 -0
  31. package/dist/utils/access-denied-hint.js +6 -0
  32. package/dist/utils/client-health.d.ts +34 -0
  33. package/dist/utils/client-health.js +96 -3
  34. package/package.json +7 -7
package/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.156] — 2026-10-21
6
+ - `hq doctor --fix` stops pulling a company the sync service could not find
7
+ for your HQ account. When a pull fails because the company does not exist
8
+ for you or access is refused, the next check shows that company's stale
9
+ sync journal as "not on this machine" with one next step: check you are
10
+ signed in to the right account and ask to be re-invited, or delete the old
11
+ journal. It is no longer auto-fixable, so the client-health hook stops
12
+ reporting it. This classification requires another company pull to succeed
13
+ in the same run; an outage affecting every company remains visible. Any
14
+ later successful sync invalidates the cached failure, and `--fix` retries
15
+ unresolved companies after 14 days.
16
+ - The same company's stale sync manifest now follows its journal: it shows as
17
+ "not on this machine" and `--fix` stops re-uploading it. A manifest upload
18
+ that fails with "No membership found" counts as the same evidence, under the
19
+ same rule that another company must succeed in that run. A later successful
20
+ sync or manifest upload clears it.
21
+ - One `hq doctor --fix` run now refreshes up to 10 stale sync journals
22
+ instead of 2. Unattended runs (`--yes`) stop starting new pulls after 120
23
+ seconds so they still finish inside the health hook's time limit. When a
24
+ run stops early it says how many are left and asks you to run it again.
25
+ - `hq files cat` / `get` and the agent file tools now say why a signed vault download failed after access was granted: a response that did not come from the storage host is reported as a likely proxy or egress block naming the host to allow, and a storage refusal shows S3's error code and request id. These failures no longer send you to `hq access`, which only re-checked the grant that had already passed.
26
+ - `hq agent inbox --json` and the agent `hq_inbox_read` tool now include `source`: `server` when the server inbox was read, `local-mirror` when the server could not be reached and the list may be stale, so automation can tell a stale fallback from a real empty inbox.
27
+ - Fix company knowledge and project index rebuilds to honor help and explicit
28
+ company arguments. Invalid arguments fail before writing, and a
29
+ company-scoped project rebuild leaves the personal index untouched.
30
+ - `hq integrations call` has a new `--timeout <seconds>` option (default 270,
31
+ max 1800) that sets how long it waits for a call the gateway runs as a
32
+ background job. When a call that asked for background execution instead
33
+ comes back as an inline HTTP 500, 502 or 504, the error now says the call
34
+ likely hit the gateway's 30-second inline limit and that async integration
35
+ jobs must be enabled for the company, instead of only suggesting a retry.
36
+ Feedback 4b2573d6-fab7-4b63-8fa6-f50b472a887f.
37
+ - Every `hq` command (including `hq secrets exec`) sends a client-health
38
+ heartbeat, and about once every 15 minutes that heartbeat re-reads the local
39
+ sync journals. That read loads the whole local sync state store into memory,
40
+ and on a large store it took the `hq` process itself to several GB. Commands
41
+ that are not sync commands now do this read in a separate child process
42
+ capped at a 512 MB heap and 20 seconds. If the child hits a cap, the command
43
+ keeps the last known sync time and does not retry the read for 15 minutes.
44
+ Sync commands still read the journals in-process.
45
+ Feedback d70e9d63-7b46-42bc-8c28-a545235cd7c6.
46
+ - `hq secrets script approve --remote-path` now accepts a path relative to the
47
+ HQ root (for example `companies/<co>/skills/<skill>/<file>.mjs`). Once
48
+ hq-pro has the matching change deployed, that one approval matches the
49
+ script on every HQ root, including a hosted agent's. For a relative path a
50
+ leading `./` is dropped and `\` becomes `/`. Approve refuses, with a plain
51
+ error and no API call, a relative path that could never match: a bare file
52
+ name with no folder, a `.`, `..` or empty segment, a trailing `/`, a leading
53
+ `~`, a drive letter without a separator, or surrounding whitespace. An
54
+ absolute path is sent exactly as typed and must match exactly. If the
55
+ server answers without an active approval for the id (for example the id was
56
+ revoked earlier), approve now fails with a hint to use a new `--id` instead
57
+ of printing success. The success line names the stored path.
58
+ Feedback 6e0a1040-4d8e-4743-863e-386eb98aa0a4.
59
+ - Installing the native hook engine no longer fails with `ERR_FS_CP_EEXIST` on
60
+ Node 24.22 and later. The engine is now copied into a new directory inside the
61
+ staging folder instead of onto the staging folder itself.
62
+
5
63
  ## [5.345.155] — 2026-10-21
6
64
  - `hq secrets exec --personal` now works in a session locked to one company.
7
65
  It injects your own personal-vault secrets into the command, read-only, and
@@ -2066,7 +2066,7 @@ export declare const COMMAND_CATALOG: readonly [{
2066
2066
  readonly hidden: false;
2067
2067
  }, {
2068
2068
  readonly flags: "--remote-path <path>";
2069
- readonly description: "Script path reported by the target runtime (defaults to the local path)";
2069
+ readonly description: "Script path to approve instead of the local path. A path relative to the HQ root (for example companies/<co>/skills/<skill>/<file>) matches on every HQ root; an absolute path must match the runtime's path exactly";
2070
2070
  readonly hidden: false;
2071
2071
  }, {
2072
2072
  readonly flags: "--attestation <level>";
@@ -5075,6 +5075,10 @@ export declare const COMMAND_CATALOG: readonly [{
5075
5075
  readonly flags: "--idempotency-key <key>";
5076
5076
  readonly description: "Stable key so retries execute at most once (default: generated)";
5077
5077
  readonly hidden: false;
5078
+ }, {
5079
+ readonly flags: "--timeout <seconds>";
5080
+ readonly description: "How long to wait for a long call that runs as a background job (default 270, max 1800)";
5081
+ readonly hidden: false;
5078
5082
  }, {
5079
5083
  readonly flags: "--json";
5080
5084
  readonly description: "Machine-readable output";
@@ -2481,7 +2481,7 @@ export const COMMAND_CATALOG = [
2481
2481
  },
2482
2482
  {
2483
2483
  "flags": "--remote-path <path>",
2484
- "description": "Script path reported by the target runtime (defaults to the local path)",
2484
+ "description": "Script path to approve instead of the local path. A path relative to the HQ root (for example companies/<co>/skills/<skill>/<file>) matches on every HQ root; an absolute path must match the runtime's path exactly",
2485
2485
  "hidden": false
2486
2486
  },
2487
2487
  {
@@ -6145,6 +6145,11 @@ export const COMMAND_CATALOG = [
6145
6145
  "description": "Stable key so retries execute at most once (default: generated)",
6146
6146
  "hidden": false
6147
6147
  },
6148
+ {
6149
+ "flags": "--timeout <seconds>",
6150
+ "description": "How long to wait for a long call that runs as a background job (default 270, max 1800)",
6151
+ "hidden": false
6152
+ },
6148
6153
  {
6149
6154
  "flags": "--json",
6150
6155
  "description": "Machine-readable output",
@@ -26,7 +26,7 @@ export function registerAgentInboxCommand(agent) {
26
26
  // Merged with the server, never the local mirror alone: a dead kit
27
27
  // poller would otherwise make this command answer confidently with
28
28
  // yesterday's mail and no sign anything was wrong.
29
- const { entries, note } = await mergeAgentInbox(paths, async () => {
29
+ const { entries, note, source } = await mergeAgentInbox(paths, async () => {
30
30
  const token = await ensureCognitoToken({ tokenSource: "machine", interactive: false });
31
31
  const res = await vaultApiFetch({
32
32
  token,
@@ -39,7 +39,7 @@ export function registerAgentInboxCommand(agent) {
39
39
  .filter((e) => opts.all || !done.has(e.id))
40
40
  .map((e) => summarizeInboxEntry(redactAgentInboxItem(e), done.has(e.id)));
41
41
  if (opts.json) {
42
- console.log(JSON.stringify({ count: rows.length, messages: rows, ...(note ? { note } : {}) }, null, 2));
42
+ console.log(JSON.stringify({ count: rows.length, source, messages: rows, ...(note ? { note } : {}) }, null, 2));
43
43
  return;
44
44
  }
45
45
  if (note)
@@ -663,7 +663,7 @@ export function registerCoreCommands(program) {
663
663
  const rendererArgs = passthrough.filter((arg) => arg !== "--accept");
664
664
  const isCompaniesTarget = entry.renderer === "companies";
665
665
  const wantsHelp = rendererArgs.some((arg) => arg === "--help" || arg === "-h");
666
- if (isCompaniesTarget && wantsHelp) {
666
+ if (wantsHelp && ["companies", "company-knowledge", "projects"].includes(entry.renderer)) {
667
667
  renderIndexTarget(entry.renderer, { root: "", output: (message) => process.stdout.write(message) }, rendererArgs);
668
668
  return;
669
669
  }
@@ -443,6 +443,22 @@ export declare const PRESIGN_BUFFER_MAX_BYTES: number;
443
443
  * access token + companyUid are captured here; the orchestrator just calls
444
444
  * `send(...)` as if it held an S3 client.
445
445
  */
446
+ /** Error code for a presigned storage download that failed after the API authorized it. */
447
+ export declare const STORAGE_DOWNLOAD_FAILED_CODE = "FILES_STORAGE_DOWNLOAD_FAILED";
448
+ /**
449
+ * Build the error for a failed presigned download. The API already checked the
450
+ * caller's ACL before it signed the URL, so a failure here is a storage or
451
+ * network outcome, not a missing grant. The error carries no `status: 403` and
452
+ * a code that `isAccessDeniedError` treats as not an access denial, so the CLI
453
+ * does not send the caller to `hq access` (which only re-checks the ACL that
454
+ * already passed).
455
+ *
456
+ * A response that really came from S3 carries `x-amz-request-id`; we report
457
+ * S3's error code from the XML body. A response without it came from something
458
+ * between this machine and the storage host, usually a sandbox egress proxy or
459
+ * firewall that does not allow the bucket host, and we say so.
460
+ */
461
+ export declare function presignedDownloadError(key: string, url: string, dl: Response): Promise<Error>;
446
462
  export declare function createCompanyPresignClient(input: {
447
463
  token: string;
448
464
  companyUid: string;
@@ -709,6 +709,57 @@ export const PRESIGN_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
709
709
  * access token + companyUid are captured here; the orchestrator just calls
710
710
  * `send(...)` as if it held an S3 client.
711
711
  */
712
+ /** Error code for a presigned storage download that failed after the API authorized it. */
713
+ export const STORAGE_DOWNLOAD_FAILED_CODE = "FILES_STORAGE_DOWNLOAD_FAILED";
714
+ /**
715
+ * Build the error for a failed presigned download. The API already checked the
716
+ * caller's ACL before it signed the URL, so a failure here is a storage or
717
+ * network outcome, not a missing grant. The error carries no `status: 403` and
718
+ * a code that `isAccessDeniedError` treats as not an access denial, so the CLI
719
+ * does not send the caller to `hq access` (which only re-checks the ACL that
720
+ * already passed).
721
+ *
722
+ * A response that really came from S3 carries `x-amz-request-id`; we report
723
+ * S3's error code from the XML body. A response without it came from something
724
+ * between this machine and the storage host, usually a sandbox egress proxy or
725
+ * firewall that does not allow the bucket host, and we say so.
726
+ */
727
+ export async function presignedDownloadError(key, url, dl) {
728
+ let host = "the storage host";
729
+ try {
730
+ host = new URL(url).host;
731
+ }
732
+ catch {
733
+ // keep the generic label
734
+ }
735
+ const requestId = dl.headers.get("x-amz-request-id");
736
+ const bodyText = (await dl.text().catch(() => "")).slice(0, 4096);
737
+ const fromStorage = requestId !== null && requestId.length > 0;
738
+ let message;
739
+ let storageCode;
740
+ if (fromStorage) {
741
+ storageCode = /<Code>([^<]{1,100})<\/Code>/.exec(bodyText)?.[1];
742
+ message =
743
+ `Failed to download '${key}' (HTTP ${dl.status}` +
744
+ (storageCode ? `, storage error ${storageCode}` : "") +
745
+ `, request id ${requestId}). Your file access was granted; the storage ` +
746
+ `service refused the signed download. Report this with: hq feedback bug`;
747
+ }
748
+ else {
749
+ message =
750
+ `Failed to download '${key}' (HTTP ${dl.status}). Your file access was ` +
751
+ `granted, but the response did not come from ${host}. A proxy or network ` +
752
+ `egress rule on this machine likely blocked it. Allow outbound HTTPS to ` +
753
+ `${host} (HQ vault files are served from *.s3.us-east-1.amazonaws.com).`;
754
+ }
755
+ return Object.assign(new Error(message), {
756
+ code: STORAGE_DOWNLOAD_FAILED_CODE,
757
+ key,
758
+ httpStatus: dl.status,
759
+ fromStorage,
760
+ ...(storageCode ? { storageCode } : {}),
761
+ });
762
+ }
712
763
  export function createCompanyPresignClient(input) {
713
764
  const { token, companyUid, companySlug, baseUrl } = input;
714
765
  const denied = (message, key, extra) => Object.assign(new Error(message), {
@@ -780,7 +831,7 @@ export function createCompanyPresignClient(input) {
780
831
  }
781
832
  const dl = await fetch(first.url, { signal: AbortSignal.timeout(120_000) });
782
833
  if (!dl.ok) {
783
- throw new Error(`Failed to download '${key}' (HTTP ${dl.status})`);
834
+ throw await presignedDownloadError(key, first.url, dl);
784
835
  }
785
836
  // HQ-CLI-5: when the response declares a length at or below the ceiling,
786
837
  // DRAIN it here — read the whole body before send() resolves and replay it
@@ -120,6 +120,16 @@ interface GatewayMessage {
120
120
  }
121
121
  export type GatewayErrorShape = "string_error" | "object_no_code" | "object_code" | "string_code" | "nested_code" | "empty" | "redacted_empty";
122
122
  export type GatewayFailureCode = "invalid_envelope";
123
+ /** Default client wait for a background (202) integration job. */
124
+ export declare const ASYNC_GATEWAY_DEADLINE_MS: number;
125
+ /** Upper bound for `hq integrations call --timeout`, in seconds. */
126
+ export declare const MAX_CALL_TIMEOUT_SECONDS: number;
127
+ /**
128
+ * Parse `--timeout <seconds>` for `hq integrations call` into milliseconds.
129
+ * Bounds how long the CLI waits for a background job; it never shortens the
130
+ * gateway's own limits.
131
+ */
132
+ export declare function parseCallTimeoutMs(raw: string | undefined): number;
123
133
  export declare class IntegrationsCliError extends Error {
124
134
  /**
125
135
  * True when the error is the caller's request/state/permission (a client 4xx
@@ -338,7 +348,9 @@ export declare function resolveConnection(token: string, companyUid: string, app
338
348
  }, recoveryOpts?: {
339
349
  allowSingleRevoked?: boolean;
340
350
  }): Promise<AdminConnection>;
341
- export declare function callGateway(token: string, params: Record<string, unknown>, idempotencyKey?: string, method?: "tools/call" | "tools/list"): Promise<GatewayMessage>;
351
+ export declare function callGateway(token: string, params: Record<string, unknown>, idempotencyKey?: string, method?: "tools/call" | "tools/list", opts?: {
352
+ waitMs?: number;
353
+ }): Promise<GatewayMessage>;
342
354
  /**
343
355
  * Gateway results arrive MCP-style: `{ content: [{ type: "text", text }] }`
344
356
  * where `text` is the provider's JSON. Unwrap to the inner payload; fall back
@@ -19,7 +19,43 @@ import { connectionHealthNotice } from "../lib/integrations/health.js";
19
19
  import { bareProvider } from "../lib/integrations/provider-slug.js";
20
20
  export { bareProvider } from "../lib/integrations/provider-slug.js";
21
21
  const ASYNC_GATEWAY_POLL_INTERVAL_MS = 1_000;
22
- const ASYNC_GATEWAY_DEADLINE_MS = 4 * 60_000 + 30_000;
22
+ /** Default client wait for a background (202) integration job. */
23
+ export const ASYNC_GATEWAY_DEADLINE_MS = 4 * 60_000 + 30_000;
24
+ /** Upper bound for `hq integrations call --timeout`, in seconds. */
25
+ export const MAX_CALL_TIMEOUT_SECONDS = 30 * 60;
26
+ /**
27
+ * Parse `--timeout <seconds>` for `hq integrations call` into milliseconds.
28
+ * Bounds how long the CLI waits for a background job; it never shortens the
29
+ * gateway's own limits.
30
+ */
31
+ export function parseCallTimeoutMs(raw) {
32
+ if (raw === undefined)
33
+ return ASYNC_GATEWAY_DEADLINE_MS;
34
+ const trimmed = raw.trim();
35
+ if (!/^\d+$/.test(trimmed)) {
36
+ throw new IntegrationsCliError(`--timeout must be a whole number of seconds (got "${raw}").`, { expected: true });
37
+ }
38
+ const seconds = Number(trimmed);
39
+ if (seconds < 1 || seconds > MAX_CALL_TIMEOUT_SECONDS) {
40
+ throw new IntegrationsCliError(`--timeout must be between 1 and ${MAX_CALL_TIMEOUT_SECONDS} seconds (got ${seconds}).`, { expected: true });
41
+ }
42
+ return seconds * 1_000;
43
+ }
44
+ /**
45
+ * Statuses the gateway returns when an inline (non-background) tools/call ran
46
+ * into the IntegrationMcpFunction 30s wall or its provider budget.
47
+ */
48
+ function isInlineTimeoutStatus(status) {
49
+ return status === 500 || status === 502 || status === 504;
50
+ }
51
+ function inlineTimeoutHint(status) {
52
+ return (`${upstreamUnavailableMessage(status)} ` +
53
+ "If this was a long-running call (for example a large analytical read), it may have hit the " +
54
+ "gateway's 30-second limit: the gateway ran it inline, not as a background job. Long calls " +
55
+ "run as background jobs only when async integration jobs are enabled for your company; ask " +
56
+ "HQ support to enable them. Once enabled, hq integrations call waits for the result " +
57
+ "automatically (see --timeout).");
58
+ }
23
59
  export class IntegrationsCliError extends Error {
24
60
  /**
25
61
  * True when the error is the caller's request/state/permission (a client 4xx
@@ -627,9 +663,9 @@ function gatewaySubcommandFor(params, method) {
627
663
  return "tools_call";
628
664
  return "other";
629
665
  }
630
- export async function callGateway(token, params, idempotencyKey, method = "tools/call") {
666
+ export async function callGateway(token, params, idempotencyKey, method = "tools/call", opts = {}) {
631
667
  const gatewaySubcommand = gatewaySubcommandFor(params, method);
632
- const deadline = Date.now() + ASYNC_GATEWAY_DEADLINE_MS;
668
+ const deadline = Date.now() + (opts.waitMs ?? ASYNC_GATEWAY_DEADLINE_MS);
633
669
  const res = await vaultApiFetch({
634
670
  token,
635
671
  path: "/v1/integrations/mcp",
@@ -663,6 +699,15 @@ export async function callGateway(token, params, idempotencyKey, method = "tools
663
699
  }
664
700
  if (res.status !== 202 && (!res.ok || !terminalMessage)) {
665
701
  raiseIfUnauthorized(res);
702
+ if (method === "tools/call" &&
703
+ params.acceptAsync === true &&
704
+ isInlineTimeoutStatus(res.status)) {
705
+ throw new IntegrationsCliError(inlineTimeoutHint(res.status), {
706
+ expected: true,
707
+ status: res.status,
708
+ gatewaySubcommand,
709
+ });
710
+ }
666
711
  raiseIfUpstreamUnavailable(res);
667
712
  // Carry the HTTP status so a reportable transport failure (e.g. a 501 that
668
713
  // is neither 401 nor an upstream-availability status, so it is captured)
@@ -773,7 +818,7 @@ async function pollAsyncGatewayJob(token, companyUid, jobId, idempotencyKey, dea
773
818
  break;
774
819
  await new Promise((resolve) => setTimeout(resolve, Math.min(ASYNC_GATEWAY_POLL_INTERVAL_MS, remainingMs)));
775
820
  }
776
- throw new IntegrationsCliError(`Timed out waiting for integration job ${jobId}; the server job may still finish. To safely retry, rerun with --idempotency-key ${idempotencyKey}.`, { expected: true });
821
+ throw new IntegrationsCliError(`Timed out waiting for integration job ${jobId}; the server job may still finish. To safely retry, rerun with --idempotency-key ${idempotencyKey} (add --timeout <seconds> to wait longer).`, { expected: true });
777
822
  }
778
823
  /**
779
824
  * Gateway results arrive MCP-style: `{ content: [{ type: "text", text }] }`
@@ -56,7 +56,7 @@ import chalk from "chalk";
56
56
  import { ensureCognitoIdToken } from "../utils/cognito-session.js";
57
57
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
58
58
  import { redactSensitiveUrl, redactSensitiveUrls, redactUrlsInText } from "../utils/redact-url.js";
59
- import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, fetchUsableApps, isBuiltInGatewayProvider, isClientError, printHealthNotice, printJson, printIntegrationCallResultJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, revokedConnectionDetails, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
59
+ import { IntegrationsCliError, bareProvider, callGateway, parseCallTimeoutMs, fetchConnections, fetchUsableApps, isBuiltInGatewayProvider, isClientError, printHealthNotice, printJson, printIntegrationCallResultJson, raiseIfUnauthorized, raiseIfUpstreamUnavailable, gatewayResultIsError, selectConnection, revokedConnectionDetails, toolPrefixForProvider, unwrapGatewayResult, queuedOutcome, } from "./integrations-core.js";
60
60
  import { registerConnectCommands } from "./integrations-connect.js";
61
61
  import { registerImportCommands } from "./integrations-import.js";
62
62
  import { registerManageCommands } from "./integrations-manage.js";
@@ -483,8 +483,10 @@ export function registerIntegrationsCommand(program) {
483
483
  .option("--args <json>", "Tool arguments as a JSON object (default {}); use - to read the JSON from stdin")
484
484
  .option("--args-file <path>", "Read tool arguments from a JSON file (use - for stdin); avoids shell quoting of apostrophes and parentheses")
485
485
  .option("--idempotency-key <key>", "Stable key so retries execute at most once (default: generated)")
486
+ .option("--timeout <seconds>", "How long to wait for a long call that runs as a background job (default 270, max 1800)")
486
487
  .option("--json", "Machine-readable output")
487
488
  .action(async (tool, opts) => {
489
+ const waitMs = parseCallTimeoutMs(opts.timeout);
488
490
  const parsedArgs = await resolveCallArgs(opts);
489
491
  const token = await ensureCognitoIdToken();
490
492
  const companyUid = await getCompanyUid(token, opts.company);
@@ -522,7 +524,7 @@ export function registerIntegrationsCommand(program) {
522
524
  },
523
525
  };
524
526
  }
525
- const message = await callGateway(token, gatewayParams, idempotencyKey);
527
+ const message = await callGateway(token, gatewayParams, idempotencyKey, "tools/call", { waitMs });
526
528
  const payload = unwrapGatewayResult(message.result);
527
529
  const queued = queuedOutcome(payload);
528
530
  if (queued) {
@@ -45,5 +45,21 @@ export declare function parseDestinationUrl(raw: string): {
45
45
  };
46
46
  export declare function collectSecretNames(value: string, previous?: string[]): string[];
47
47
  export declare function scrubSandboxOutput(text: string, secretNames?: string[]): string;
48
+ /**
49
+ * Check a `--remote-path` value and return the path to store, or the reason it
50
+ * can never match.
51
+ *
52
+ * An absolute path is sent exactly as typed: the server compares absolute
53
+ * approvals byte for byte with the path `hq secrets exec` reports. A relative
54
+ * path names the script from the HQ root. `\` becomes `/` and a leading `./`
55
+ * is dropped, then the same rules the server applies are checked: at least one
56
+ * folder before the file name, no `.`, `..` or empty segments, no `~`, no
57
+ * drive letter, and no surrounding whitespace.
58
+ */
59
+ export declare function normalizeApprovedScriptPath(input: string): {
60
+ path: string;
61
+ } | {
62
+ error: string;
63
+ };
48
64
  export declare function registerSecretsCommand(program: Command): void;
49
65
  //# sourceMappingURL=secrets.d.ts.map
@@ -462,6 +462,61 @@ function normalizePolicyRecord(secretPath, data) {
462
462
  scripts,
463
463
  };
464
464
  }
465
+ /**
466
+ * Check a `--remote-path` value and return the path to store, or the reason it
467
+ * can never match.
468
+ *
469
+ * An absolute path is sent exactly as typed: the server compares absolute
470
+ * approvals byte for byte with the path `hq secrets exec` reports. A relative
471
+ * path names the script from the HQ root. `\` becomes `/` and a leading `./`
472
+ * is dropped, then the same rules the server applies are checked: at least one
473
+ * folder before the file name, no `.`, `..` or empty segments, no `~`, no
474
+ * drive letter, and no surrounding whitespace.
475
+ */
476
+ export function normalizeApprovedScriptPath(input) {
477
+ if (input.length === 0 || input.trim().length === 0) {
478
+ return { error: "the path is empty" };
479
+ }
480
+ if (input !== input.trim()) {
481
+ return { error: "the path starts or ends with whitespace" };
482
+ }
483
+ if (input.startsWith("/") || input.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(input)) {
484
+ return { path: input };
485
+ }
486
+ if (input.startsWith("~")) {
487
+ return {
488
+ error: "the path starts with ~, which is never expanded; give the absolute path or a path relative to the HQ root",
489
+ };
490
+ }
491
+ if (/^[A-Za-z]:/.test(input)) {
492
+ return { error: "the path starts with a drive letter but is not absolute" };
493
+ }
494
+ const relative = input.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
495
+ const segments = relative.split("/");
496
+ if (segments.some((segment) => segment === "")) {
497
+ return {
498
+ error: "the path has an empty segment (a trailing or doubled /)",
499
+ };
500
+ }
501
+ if (segments.some((segment) => segment === "." || segment === "..")) {
502
+ return { error: "the path has a . or .. segment" };
503
+ }
504
+ if (segments.length < 2) {
505
+ return {
506
+ error: "a bare file name would match that file in any folder; include at least one folder, for example skills/<skill>/<file>",
507
+ };
508
+ }
509
+ return { path: relative };
510
+ }
511
+ function approvalIsActive(policy, scriptId) {
512
+ const approved = policy.scriptLock?.approvedScripts;
513
+ if (!Array.isArray(approved))
514
+ return true;
515
+ return approved.some((script) => script !== null &&
516
+ typeof script === "object" &&
517
+ script.scriptId === scriptId &&
518
+ !script.revokedAt);
519
+ }
465
520
  function isSecretPolicyScript(value) {
466
521
  if (!value || typeof value !== "object" || Array.isArray(value))
467
522
  return false;
@@ -988,7 +1043,7 @@ export function registerSecretsCommand(program) {
988
1043
  .description("Approve a script for a secret path")
989
1044
  .requiredOption("--id <scriptId>", "Stable script identifier")
990
1045
  .requiredOption("--script <path>", "Path to the local script file")
991
- .option("--remote-path <path>", "Script path reported by the target runtime (defaults to the local path)")
1046
+ .option("--remote-path <path>", "Script path to approve instead of the local path. A path relative to the HQ root (for example companies/<co>/skills/<skill>/<file>) matches on every HQ root; an absolute path must match the runtime's path exactly")
992
1047
  .option("--attestation <level>", "Attestation level", "self-asserted-hash")
993
1048
  .action(async (secretPath, opts) => {
994
1049
  try {
@@ -997,7 +1052,16 @@ export function registerSecretsCommand(program) {
997
1052
  console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
998
1053
  process.exit(1);
999
1054
  }
1055
+ const checkedRemotePath = opts.remotePath === undefined
1056
+ ? undefined
1057
+ : normalizeApprovedScriptPath(opts.remotePath);
1058
+ if (checkedRemotePath && "error" in checkedRemotePath) {
1059
+ console.error(chalk.red(`Invalid --remote-path '${opts.remotePath}': ${checkedRemotePath.error}.`));
1060
+ process.exit(1);
1061
+ }
1062
+ const remotePath = checkedRemotePath?.path;
1000
1063
  const usage = await buildSecretUsage("exec", opts.script, opts.id, opts.attestation);
1064
+ const approvedPath = remotePath ?? usage.script?.path;
1001
1065
  const token = await requireCognitoTokenForSecrets("secrets script approve", "secrets:write");
1002
1066
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1003
1067
  const res = await vaultApiFetch({
@@ -1007,7 +1071,7 @@ export function registerSecretsCommand(program) {
1007
1071
  body: {
1008
1072
  path: secretPath,
1009
1073
  scriptId: usage.script?.scriptId,
1010
- scriptPath: opts.remotePath ?? usage.script?.path,
1074
+ scriptPath: approvedPath,
1011
1075
  sha256: usage.script?.sha256,
1012
1076
  attestationLevel: usage.script?.attestationLevel,
1013
1077
  },
@@ -1018,7 +1082,12 @@ export function registerSecretsCommand(program) {
1018
1082
  process.exit(1);
1019
1083
  }
1020
1084
  removeCacheEntry(companyUid, secretPath);
1021
- console.log(chalk.green(`Approved script '${opts.id}' for '${secretPath}'.`));
1085
+ const data = (await res.json().catch(() => ({})));
1086
+ if (data.policy && !approvalIsActive(data.policy, opts.id)) {
1087
+ console.error(chalk.red(`Failed to approve script: the server did not store an active approval for '${opts.id}' on '${secretPath}'. If this id was revoked before, approve the script under a new --id and pass that id to --script-id.`));
1088
+ process.exit(1);
1089
+ }
1090
+ console.log(chalk.green(`Approved script '${opts.id}' for '${secretPath}' (path: ${approvedPath}).`));
1022
1091
  }
1023
1092
  catch (err) {
1024
1093
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -75,6 +75,13 @@ export interface MergedInbox {
75
75
  entries: MirroredInboxEntry[];
76
76
  /** Set when the server could not be consulted, so the caller can say so. */
77
77
  note?: string;
78
+ /**
79
+ * Machine-readable origin of the answer. `server` means the server inbox was
80
+ * read (merged with the mirror); `local-mirror` means the server could not be
81
+ * consulted and the entries may be stale. Lets an automated consumer tell a
82
+ * stale fallback from a verified empty inbox without parsing `note`.
83
+ */
84
+ source: "server" | "local-mirror";
78
85
  }
79
86
  export declare function mergeAgentInbox(paths: Pick<AgentKitPaths, "inboxDir">, fetchServerInbox: () => Promise<{
80
87
  status: number;
@@ -163,6 +163,10 @@ export async function mergeAgentInbox(paths, fetchServerInbox) {
163
163
  catch (err) {
164
164
  note = `server inbox unavailable (${err instanceof Error ? err.message : String(err)}); showing the local mirror only, which may be stale`;
165
165
  }
166
- return { entries: [...byId.values()], ...(note ? { note } : {}) };
166
+ return {
167
+ entries: [...byId.values()],
168
+ source: note ? "local-mirror" : "server",
169
+ ...(note ? { note } : {}),
170
+ };
167
171
  }
168
172
  //# sourceMappingURL=inbox-state.js.map
@@ -280,7 +280,7 @@ export function buildAgentMcpTools(clients) {
280
280
  const limit = int(args, "limit", { min: 1, max: 100, fallback: 20 });
281
281
  const includeDone = args.include_done === true;
282
282
  const done = readDoneIds(clients.paths);
283
- const { entries, note: serverNote } = await mergeAgentInbox(clients.paths, async () => {
283
+ const { entries, note: serverNote, source } = await mergeAgentInbox(clients.paths, async () => {
284
284
  const token = await clients.getToken();
285
285
  return clients.apiJson(token, `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox`);
286
286
  });
@@ -292,6 +292,7 @@ export function buildAgentMcpTools(clients) {
292
292
  const hasFiles = rows.some((r) => r.attachments?.length);
293
293
  return textResult(clampOutput(JSON.stringify({
294
294
  count: rows.length,
295
+ source,
295
296
  messages: rows,
296
297
  ...(serverNote ? { note: serverNote } : {}),
297
298
  ...(hasFiles ? { attachmentsHint: ATTACHMENTS_HINT } : {}),
@@ -100,6 +100,15 @@ export interface SyncHealthDeps {
100
100
  * unhealthy-sync signal, and `hq doctor --fix` has nothing local to refresh.
101
101
  */
102
102
  localCompanyExists?: (slug: string) => boolean;
103
+ /**
104
+ * Whether the sync service can resolve the company for this machine's
105
+ * identity. Defaults to true unless a recent `hq doctor --fix` pull of it
106
+ * failed because the service has no such company for the caller or refused
107
+ * access (see company-resolution-state.ts). A stale journal for a company
108
+ * that fails this has nothing `--fix` can pull, so it is NA ("not on this
109
+ * machine") rather than an auto-fixable WARN.
110
+ */
111
+ companySyncResolvable?: (slug: string, lastSync?: string | null) => boolean;
103
112
  /**
104
113
  * Whether HQ_SYNC_MANIFEST_DISABLED turns manifest uploads off on this
105
114
  * machine. Defaults to reading the environment the way `hq sync manifest`