@indigoai-us/hq-cli 5.345.176 → 5.345.178

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.
@@ -22,7 +22,7 @@ import * as path from "path";
22
22
  import { share, getStateDir, listJournals, createIgnoreFilter, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, readSyncProgress, syncDoctor, } from "@indigoai-us/hq-cloud";
23
23
  import { syncLeg } from "../utils/sync-operation-lock.js";
24
24
  import { SYNC_WATCHDOG_EXIT_CODE, SYNC_WATCHDOG_REPORT_GRACE_MS, SyncWatchdogTimeoutError, createSnapshotProgressProbe, resolveSyncWatchdogLimits, withSyncWatchdog, } from "../utils/sync-watchdog.js";
25
- import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, loadCachedTokens, } from "../utils/cognito-session.js";
25
+ import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, IdentitySwapRefusal, cognitoCachePrincipalUid, isDefinitivePersonAuthRejection, isIdentitySwapRefusal, loadCachedTokens, } from "../utils/cognito-session.js";
26
26
  import { resolveUploadAuthor } from "../utils/upload-author.js";
27
27
  import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
28
28
  import { beginSyncHealthReport, classifySyncFailure, } from "../utils/client-health.js";
@@ -66,12 +66,130 @@ async function ensureSyncCognitoToken() {
66
66
  interactive: process.stdin.isTTY === true && process.stdout.isTTY === true,
67
67
  });
68
68
  }
69
+ /** Shown when the session expires during a sync and cannot be renewed. */
70
+ export const SYNC_SESSION_EXPIRED_MESSAGE = "Your HQ session expired during sync and could not be refreshed. Run `hq login`, then rerun the sync.";
71
+ /** Shown when the refresh failed for a network or server reason. */
72
+ export const SYNC_REFRESH_UNAVAILABLE_MESSAGE = "Could not refresh your HQ session during sync because the sign-in service did not respond. Check your connection, then rerun the sync.";
73
+ /** Shown when the start token carries no principal to bind the run to. */
74
+ export const SYNC_NO_IDENTITY_MESSAGE = "The HQ session token does not identify an account, so the sync did not start. Run `hq login`, then rerun the sync.";
75
+ /** Shown when the token would change to a different account mid-run. */
76
+ export const SYNC_IDENTITY_CHANGED_MESSAGE = "The signed-in HQ account changed while this sync was running, so the sync stopped instead of continuing as a different account. Rerun the sync as the account you want.";
77
+ /**
78
+ * The session token expired during a sync run and the non-interactive
79
+ * refresh failed. `kind` separates a rejected session (sign in again) from a
80
+ * network or server failure (retry later).
81
+ */
82
+ export class SyncSessionExpiredError extends Error {
83
+ kind;
84
+ reason;
85
+ constructor(kind, reason) {
86
+ const base = kind === "reauth" ? SYNC_SESSION_EXPIRED_MESSAGE : SYNC_REFRESH_UNAVAILABLE_MESSAGE;
87
+ super(reason ? `${base} (${reason})` : base);
88
+ this.kind = kind;
89
+ this.reason = reason;
90
+ this.name = "SyncSessionExpiredError";
91
+ }
92
+ }
93
+ /**
94
+ * Per-config record of the last token-function failure. hq-cloud's one-shot
95
+ * 401 retry swallows getter errors and rethrows the original 401, so
96
+ * {@link formatSyncFailure} reads this (given the run's config) to report the
97
+ * real cause. Keyed by config so two runs in one process never share a record.
98
+ */
99
+ const syncAuthFailures = new WeakMap();
100
+ /** The last token failure recorded by a {@link buildSyncVaultConfig} config. */
101
+ export function syncAuthFailureFor(config) {
102
+ return config ? (syncAuthFailures.get(config)?.() ?? null) : null;
103
+ }
104
+ function classifyRefreshFailure(err) {
105
+ const reason = err instanceof Error ? err.message : String(err);
106
+ const reauth = isDefinitivePersonAuthRejection(err) ||
107
+ /No valid HQ session|Run `hq login`/i.test(reason);
108
+ return new SyncSessionExpiredError(reauth ? "reauth" : "unavailable", reason);
109
+ }
110
+ /**
111
+ * Vault config for a sync run whose token stays valid for the whole run.
112
+ *
113
+ * `initialToken` is resolved (and, on a TTY, logged in) before the run starts.
114
+ * The config carries a getter instead of that string, so every vault request
115
+ * re-reads the cached session and refreshes it when it is close to expiry.
116
+ * When a request still gets a 401, hq-cloud's VaultClient calls the getter
117
+ * once more and retries once if the token changed (no loop). A multi-company
118
+ * `hq sync pull --all` can outlast the one-hour Cognito access token; a
119
+ * captured string made every later target fail with 401 (feedback_3d3fab0b).
120
+ *
121
+ * The getter is bound to the principal of `initialToken`. If a later token
122
+ * belongs to a different principal (a mid-run `hq login` as another account,
123
+ * or the machine-identity fallback after a rejected person refresh), it throws
124
+ * an {@link IdentitySwapRefusal} and the run stops. It never continues as a
125
+ * different principal.
126
+ *
127
+ * Mid-run refreshes never open a browser.
128
+ */
129
+ export function buildSyncVaultConfig(initialToken, deps = {}) {
130
+ const ensureToken = deps.ensureToken ?? (() => ensureCognitoToken({ interactive: false }));
131
+ const runPrincipal = cognitoCachePrincipalUid(initialToken);
132
+ // A start token with no sub/entityUid cannot be bound to a principal, and a
133
+ // null binding would accept any later identity-less token. Refuse up front.
134
+ if (runPrincipal === null) {
135
+ throw new IdentitySwapRefusal(SYNC_NO_IDENTITY_MESSAGE);
136
+ }
137
+ let refused = null;
138
+ let lastSyncAuthFailure = null;
139
+ const authToken = async () => {
140
+ // Once refused, stay refused for the rest of the run.
141
+ if (refused)
142
+ throw refused;
143
+ let token;
144
+ try {
145
+ token = await ensureToken();
146
+ }
147
+ catch (err) {
148
+ const failure = isIdentitySwapRefusal(err) ? err : classifyRefreshFailure(err);
149
+ if (isIdentitySwapRefusal(failure))
150
+ refused = failure;
151
+ lastSyncAuthFailure = failure;
152
+ throw failure;
153
+ }
154
+ if (cognitoCachePrincipalUid(token) !== runPrincipal) {
155
+ refused = new IdentitySwapRefusal(SYNC_IDENTITY_CHANGED_MESSAGE);
156
+ lastSyncAuthFailure = refused;
157
+ throw refused;
158
+ }
159
+ lastSyncAuthFailure = null;
160
+ return token;
161
+ };
162
+ const config = { ...buildVaultConfig(initialToken), authToken };
163
+ syncAuthFailures.set(config, () => lastSyncAuthFailure);
164
+ return config;
165
+ }
166
+ function isVaultAuthRejection(err) {
167
+ // Duck-typed (VaultAuthError sets statusCode 401) so this stays compatible
168
+ // across independently released hq-cloud versions.
169
+ if (typeof err !== "object" || err === null)
170
+ return false;
171
+ const e = err;
172
+ return e.name === "VaultAuthError" || e.statusCode === 401;
173
+ }
69
174
  /**
70
175
  * Preserve the vault's typed write-scope refusal at the CLI boundary. This is
71
176
  * deliberately duck-typed so hq-cli remains compatible while hq-cloud and the
72
177
  * CLI are released independently.
73
178
  */
74
179
  export function formatSyncFailure(err, ctx = {}) {
180
+ if (err instanceof SyncSessionExpiredError || isIdentitySwapRefusal(err)) {
181
+ return err.message;
182
+ }
183
+ if (isVaultAuthRejection(err)) {
184
+ // hq-cloud's 401 retry swallowed the token-function error; report it.
185
+ const recorded = syncAuthFailureFor(ctx.vaultConfig);
186
+ if (recorded)
187
+ return recorded.message;
188
+ // A 401 that survived the one refresh-and-retry: the session is no longer
189
+ // accepted, so the fix is a fresh sign-in.
190
+ const base = err instanceof Error ? err.message : String(err);
191
+ return `${base}. ${SYNC_SESSION_EXPIRED_MESSAGE}`;
192
+ }
75
193
  const unreachable = formatUnreachablePushPaths(err, ctx);
76
194
  if (unreachable)
77
195
  return unreachable;
@@ -694,7 +812,7 @@ export async function pullAll(options, deps) {
694
812
  }
695
813
  }
696
814
  catch (err) {
697
- const message = formatSyncFailure(err);
815
+ const message = (deps.formatFailure ?? formatSyncFailure)(err);
698
816
  result.errors.push({ company: entry.slug, message });
699
817
  result.perCompany.push({ slug: entry.slug, error: message });
700
818
  }
@@ -823,7 +941,7 @@ export async function pushAll(options, deps) {
823
941
  });
824
942
  }
825
943
  catch (err) {
826
- const message = formatSyncFailure(err);
944
+ const message = (deps.formatFailure ?? formatSyncFailure)(err);
827
945
  result.errors.push({ company: entry.slug, message });
828
946
  result.perCompany.push({ slug: entry.slug, error: message });
829
947
  }
@@ -1126,6 +1244,8 @@ export function registerCloudCommands(program) {
1126
1244
  // Assigned right before the sync engine runs, so option-validation
1127
1245
  // failures never count as a sync attempt (US-003).
1128
1246
  let syncHealth;
1247
+ /** The run's vault config, so the catch can report its recorded token failure. */
1248
+ let runVaultConfig;
1129
1249
  let explicitPersonalPaths;
1130
1250
  // Company folder the push resolves relative paths against; names the
1131
1251
  // .hqinclude line and the blocked path in failure output.
@@ -1172,11 +1292,13 @@ export function registerCloudCommands(program) {
1172
1292
  // Upload creds are pre-vended in entityContext, but prefix filtering
1173
1293
  // still needs vault-service membership/grant resolution.
1174
1294
  const accessToken = await ensureSyncCognitoToken();
1175
- scopeVaultConfig = buildVaultConfig(accessToken);
1295
+ scopeVaultConfig = buildSyncVaultConfig(accessToken);
1296
+ runVaultConfig = scopeVaultConfig;
1176
1297
  }
1177
1298
  else {
1178
1299
  const accessToken = await ensureSyncCognitoToken();
1179
- vaultConfig = buildVaultConfig(accessToken);
1300
+ vaultConfig = buildSyncVaultConfig(accessToken);
1301
+ runVaultConfig = vaultConfig;
1180
1302
  scopeVaultConfig = vaultConfig;
1181
1303
  }
1182
1304
  // Resolve the target. For `--personal`, look up the caller's
@@ -1426,6 +1548,7 @@ export function registerCloudCommands(program) {
1426
1548
  const message = formatSyncFailure(err, {
1427
1549
  hqRoot: options.hqRoot,
1428
1550
  companySlug: pushCompanySlug,
1551
+ vaultConfig: runVaultConfig,
1429
1552
  });
1430
1553
  if (jsonMode) {
1431
1554
  // In JSON mode, the parent process is parsing stderr for ndjson —
@@ -1493,12 +1616,15 @@ export function registerCloudCommands(program) {
1493
1616
  return;
1494
1617
  }
1495
1618
  let syncHealth;
1619
+ /** The run's vault config, so the catch can report its recorded token failure. */
1620
+ let runVaultConfig;
1496
1621
  try {
1497
1622
  console.log(chalk.bold("\nHQ Sync — Pull"));
1498
1623
  console.log(` HQ root: ${options.hqRoot}`);
1499
1624
  console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
1500
1625
  const accessToken = await ensureSyncCognitoToken();
1501
- const vaultConfig = buildVaultConfig(accessToken);
1626
+ const vaultConfig = buildSyncVaultConfig(accessToken);
1627
+ runVaultConfig = vaultConfig;
1502
1628
  // US-011 (2026-05-21 fix): resolve the caller's sync-config for
1503
1629
  // the targeted membership BEFORE the pull, so we can (a) emit the
1504
1630
  // narrow-hint banner after success if still on all-mode and
@@ -1581,7 +1707,7 @@ export function registerCloudCommands(program) {
1581
1707
  catch (err) {
1582
1708
  if (syncHealth)
1583
1709
  await syncHealth.failed();
1584
- const message = formatSyncFailure(err);
1710
+ const message = formatSyncFailure(err, { vaultConfig: runVaultConfig });
1585
1711
  console.error(chalk.red("\n✗ Pull failed:"), message);
1586
1712
  process.exit(1);
1587
1713
  }
@@ -1843,9 +1969,12 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
1843
1969
  logVaultSkips(skipPersonal, skipCompanies);
1844
1970
  console.log("");
1845
1971
  let result;
1972
+ /** The run's vault config, so the catch can report its recorded token failure. */
1973
+ let runVaultConfig;
1846
1974
  try {
1847
1975
  const accessToken = await ensureSyncCognitoToken();
1848
- const vaultConfig = buildVaultConfig(accessToken);
1976
+ const vaultConfig = buildSyncVaultConfig(accessToken);
1977
+ runVaultConfig = vaultConfig;
1849
1978
  const realClient = new VaultClient(vaultConfig);
1850
1979
  const adapter = {
1851
1980
  listMyMemberships: () => realClient.listMyMemberships(),
@@ -1871,6 +2000,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
1871
2000
  ...(skipCompanies && skipCompanies.size > 0 ? { skipCompanies } : {}),
1872
2001
  }, {
1873
2002
  vaultClient: adapter,
2003
+ formatFailure: (err) => formatSyncFailure(err, { vaultConfig }),
1874
2004
  // DEV-1768: resolve each company's REAL pull scope (mode + prefixSet)
1875
2005
  // via the shared resolver, so the actual pull is scoped — the CLI no
1876
2006
  // longer seeds an all-mode PullRecord that wedges the menubar runner.
@@ -1898,7 +2028,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
1898
2028
  });
1899
2029
  }
1900
2030
  catch (err) {
1901
- console.error(chalk.red("\n✗ Pull-all failed:"), describeSyncError(err));
2031
+ console.error(chalk.red("\n✗ Pull-all failed:"), formatSyncFailure(err, { vaultConfig: runVaultConfig }));
1902
2032
  // Unwind (never process.exit) so withSyncHealthReport records the
1903
2033
  // terminal sync_failure before exiting on our behalf.
1904
2034
  throw new SyncExitError(1);
@@ -1937,9 +2067,12 @@ export async function runPullPersonal(hqRoot, onConflict) {
1937
2067
  console.log(chalk.bold("\nHQ Sync — Pull (personal)"));
1938
2068
  console.log(` HQ root: ${hqRoot}`);
1939
2069
  console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
2070
+ /** The run's vault config, so the catch can report its recorded token failure. */
2071
+ let runVaultConfig;
1940
2072
  try {
1941
2073
  const accessToken = await ensureSyncCognitoToken();
1942
- const vaultConfig = buildVaultConfig(accessToken);
2074
+ const vaultConfig = buildSyncVaultConfig(accessToken);
2075
+ runVaultConfig = vaultConfig;
1943
2076
  const client = new VaultClient(vaultConfig);
1944
2077
  const personalUid = await resolveCanonicalPersonUid({
1945
2078
  listMyMemberships: () => client.listMyMemberships(),
@@ -1967,7 +2100,7 @@ export async function runPullPersonal(hqRoot, onConflict) {
1967
2100
  catch (err) {
1968
2101
  if (err instanceof SyncExitError)
1969
2102
  throw err;
1970
- console.error(chalk.red("\n✗ Pull (personal) failed:"), describeSyncError(err));
2103
+ console.error(chalk.red("\n✗ Pull (personal) failed:"), formatSyncFailure(err, { vaultConfig: runVaultConfig }));
1971
2104
  throw new SyncExitError(1);
1972
2105
  }
1973
2106
  }
@@ -1983,9 +2116,12 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompani
1983
2116
  logVaultSkips(skipPersonal, skipCompanies);
1984
2117
  console.log("");
1985
2118
  let result;
2119
+ /** The run's vault config, so the catch can report its recorded token failure. */
2120
+ let runVaultConfig;
1986
2121
  try {
1987
2122
  const accessToken = await ensureSyncCognitoToken();
1988
- const vaultConfig = buildVaultConfig(accessToken);
2123
+ const vaultConfig = buildSyncVaultConfig(accessToken);
2124
+ runVaultConfig = vaultConfig;
1989
2125
  const realClient = new VaultClient(vaultConfig);
1990
2126
  const author = resolveUploadAuthor();
1991
2127
  const adapter = {
@@ -2008,6 +2144,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompani
2008
2144
  ...(skipCompanies && skipCompanies.size > 0 ? { skipCompanies } : {}),
2009
2145
  }, {
2010
2146
  vaultClient: adapter,
2147
+ formatFailure: (err) => formatSyncFailure(err, { vaultConfig }),
2011
2148
  resolveScope: (companyUid, slug) => resolvePullScope(realClient, companyUid, slug, hqRoot),
2012
2149
  beforeShare: async ({ slug, companyUid, companyRoot }) => warnAboutInvalidOntologyEntities({
2013
2150
  hqRoot,
@@ -2052,7 +2189,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompani
2052
2189
  });
2053
2190
  }
2054
2191
  catch (err) {
2055
- console.error(chalk.red("\n✗ Push-all failed:"), describeSyncError(err));
2192
+ console.error(chalk.red("\n✗ Push-all failed:"), formatSyncFailure(err, { vaultConfig: runVaultConfig }));
2056
2193
  // Unwind (never process.exit) so withSyncHealthReport records the
2057
2194
  // terminal sync_failure before exiting on our behalf.
2058
2195
  throw new SyncExitError(1);
@@ -2149,9 +2286,12 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2149
2286
  console.log(` HQ root: ${hqRoot}`);
2150
2287
  console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
2151
2288
  console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
2289
+ /** The run's vault config, so the catch can report its recorded token failure. */
2290
+ let runVaultConfig;
2152
2291
  try {
2153
2292
  const accessToken = await ensureSyncCognitoToken();
2154
- const vaultConfig = buildVaultConfig(accessToken);
2293
+ const vaultConfig = buildSyncVaultConfig(accessToken);
2294
+ runVaultConfig = vaultConfig;
2155
2295
  const author = resolveUploadAuthor();
2156
2296
  // Resolve the target. For --personal, look up the canonical person and
2157
2297
  // route paths/journal through personalMode. Otherwise use the company arg
@@ -2403,7 +2543,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2403
2543
  catch (err) {
2404
2544
  if (err instanceof SyncExitError)
2405
2545
  throw err;
2406
- console.error(chalk.red("\n✗ Sync now failed:"), describeSyncError(err));
2546
+ console.error(chalk.red("\n✗ Sync now failed:"), formatSyncFailure(err, { vaultConfig: runVaultConfig }));
2407
2547
  throw new SyncExitError(1);
2408
2548
  }
2409
2549
  }
@@ -18,7 +18,8 @@
18
18
  *
19
19
  * Network is injected (`presign`, `put`) so the sequence is unit-testable.
20
20
  */
21
- import { createReadStream, statSync } from "node:fs";
21
+ import { readFileSync, statSync } from "node:fs";
22
+ import { Readable } from "node:stream";
22
23
  import path from "node:path";
23
24
  import { vaultApiFetch } from "../utils/vault-api.js";
24
25
  import { contentTypeFor, streamPut } from "./library-add.js";
@@ -89,7 +90,17 @@ export function validateAttachmentPaths(paths, stat = statSync) {
89
90
  }
90
91
  export const defaultAttachmentUploadDeps = {
91
92
  presign: (token, body) => vaultApiFetch({ token, path: "/v1/files/presign", method: "POST", body }),
92
- put: (file, url, headers) => streamPut(url, headers, createReadStream(file.path), file.sizeBytes),
93
+ // Read the bytes once and declare their real length. Streaming with the
94
+ // size from validateAttachmentPaths' earlier stat sent a Content-Length the
95
+ // body could fall short of (file rewritten or truncated in between); S3 then
96
+ // waits ~20s and answers 400 RequestTimeout. Attachments are capped at 25 MB.
97
+ put: (file, url, headers) => {
98
+ const bytes = readFileSync(file.path);
99
+ if (bytes.length > MAX_DM_ATTACHMENT_BYTES) {
100
+ return Promise.reject(new Error(`Attachment too large: ${file.path} grew past the 25 MB limit after it was checked.`));
101
+ }
102
+ return streamPut(url, headers, Readable.from([bytes]), bytes.length);
103
+ },
93
104
  };
94
105
  /**
95
106
  * Presign-put every file into `scopeUid`, then return the `attachments[]`
@@ -138,7 +149,8 @@ export async function uploadDmAttachments(args) {
138
149
  }
139
150
  const put = await deps.put(p.file, row.url, headers);
140
151
  if (put.status < 200 || put.status >= 300) {
141
- throw new Error(`Upload failed for ${p.file.path} (HTTP ${put.status}). The message was not sent.`);
152
+ const reason = [put.errorCode, put.errorMessage].filter(Boolean).join(": ");
153
+ throw new Error(`Upload failed for ${p.file.path} (HTTP ${put.status}${reason ? ` ${reason}` : ""}). The message was not sent.`);
142
154
  }
143
155
  out.push({
144
156
  vaultPath: p.key,
@@ -19,7 +19,16 @@ export interface OpenedSource {
19
19
  export interface PutResponse {
20
20
  status: number;
21
21
  headers: Record<string, string | undefined>;
22
+ /** S3 `<Code>` from a non-2xx response body (e.g. `RequestTimeout`), when present. */
23
+ errorCode?: string;
24
+ /** S3 `<Message>` from a non-2xx response body, when present. */
25
+ errorMessage?: string;
22
26
  }
27
+ /** Pull `<Code>` and `<Message>` out of an S3 XML error body. Pure. */
28
+ export declare function parseS3Error(body: string): {
29
+ errorCode?: string;
30
+ errorMessage?: string;
31
+ };
23
32
  export interface LibraryAddDeps {
24
33
  hqRoot: string;
25
34
  now: () => Date;
@@ -33,6 +33,14 @@ import { CONTENT_SHA256_METADATA_KEY, PLACEHOLDER_SUFFIX, companyOffloadKey, con
33
33
  import { formatPlanGateError, formatUpgradeRefusal, planGateErrorFromResponse, } from "../utils/plan-gate-error.js";
34
34
  /** Metadata key hq-pro binds to `checksumSha256` (sync's content hash). */
35
35
  const CONTENT_SHA256_META_KEY = CONTENT_SHA256_METADATA_KEY;
36
+ /** Cap on how much of a non-2xx PUT response body is kept for the error reason. */
37
+ const PUT_ERROR_BODY_LIMIT = 8 * 1024;
38
+ /** Pull `<Code>` and `<Message>` out of an S3 XML error body. Pure. */
39
+ export function parseS3Error(body) {
40
+ const code = /<Code>([^<]{1,200})<\/Code>/.exec(body)?.[1]?.trim();
41
+ const message = /<Message>([^<]{1,500})<\/Message>/.exec(body)?.[1]?.trim();
42
+ return { ...(code ? { errorCode: code } : {}), ...(message ? { errorMessage: message } : {}) };
43
+ }
36
44
  const MEDIA_TYPES = {
37
45
  ".png": "image/png",
38
46
  ".jpg": "image/jpeg",
@@ -515,12 +523,21 @@ export function streamPut(url, headers, body, size) {
515
523
  return new Promise((resolve, reject) => {
516
524
  const target = new URL(url);
517
525
  const request = (target.protocol === "http:" ? http : https).request(target, { method: "PUT", headers: { ...headers, "content-length": String(size) } }, (res) => {
518
- res.resume();
526
+ const status = res.statusCode ?? 0;
527
+ // Keep the start of a failure body so the caller can name S3's reason
528
+ // (RequestTimeout, EntityTooLarge, ...) instead of a bare status.
529
+ let errorBody = "";
530
+ res.setEncoding("utf8");
531
+ res.on("data", (chunk) => {
532
+ if ((status < 200 || status >= 300) && errorBody.length < PUT_ERROR_BODY_LIMIT) {
533
+ errorBody += chunk.slice(0, PUT_ERROR_BODY_LIMIT - errorBody.length);
534
+ }
535
+ });
519
536
  res.on("end", () => {
520
537
  const out = {};
521
538
  for (const [k, v] of Object.entries(res.headers))
522
539
  out[k] = Array.isArray(v) ? v.join(", ") : v;
523
- resolve({ status: res.statusCode ?? 0, headers: out });
540
+ resolve({ status, headers: out, ...(errorBody ? parseS3Error(errorBody) : {}) });
524
541
  });
525
542
  res.on("error", reject);
526
543
  });
@@ -10,7 +10,7 @@ import { computeSha256 } from '../utils/integrity.js';
10
10
  import { isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from '../utils/secret-load-availability.js';
11
11
  import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
12
12
  import { discoverSchemas } from '../run/discover-schemas.js';
13
- import { installHqPlugin, prewarmHqSecrets } from '../run/hq-plugin.js';
13
+ import { installHqPlugin, itemIsOptional, prewarmHqSecrets } from '../run/hq-plugin.js';
14
14
  import { assertEnvGraphLoaded, resolveEnvValuesOrThrow, EnvGraphLoadError, EnvResolutionError, } from '../run/env-graph-guard.js';
15
15
  import { hadFatalRejection } from '../unhandled-rejection-boundary.js';
16
16
  import { withRunSecretReadTelemetry } from './run-feature-telemetry.js';
@@ -223,6 +223,14 @@ export function registerRunCommand(program) {
223
223
  const schemaItems = Object.entries(graph.configSchema);
224
224
  const itemMessages = (wantWarnings) => schemaItems.flatMap(([k, item]) => (item.errors ?? [])
225
225
  .filter((e) => Boolean(e?.isWarning) === wantWarnings)
226
+ .filter((e) => {
227
+ if (wantWarnings)
228
+ return true;
229
+ if (!itemIsOptional(item))
230
+ return true;
231
+ const msg = e?.message ?? '';
232
+ return !/No read permission|does not exist in company|not returned by vault/.test(msg);
233
+ })
226
234
  .map((e) => ` ${k}: ${e?.message ?? String(e)}`));
227
235
  const warningMsgs = itemMessages(true);
228
236
  if (warningMsgs.length > 0) {
@@ -90,6 +90,8 @@ export interface BotConfig {
90
90
  ownerUid: string;
91
91
  runtime: BotRuntimeId;
92
92
  engine?: BotEngineMetadata;
93
+ fleetAuthPending?: boolean;
94
+ fleetServiceInstallPending?: boolean;
93
95
  /**
94
96
  * "personal" (acts as its owner everywhere) or "company" (acts as itself,
95
97
  * member of `companies`). Missing on bots created before kinds existed:
@@ -0,0 +1,31 @@
1
+ import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-client";
2
+ /** Personal bots are created on the HQ Fleet Runtime for companies with this flag on. */
3
+ export declare const LOCAL_BOTS_FLEET_DEFAULT_FLAG = "agents.local-bots-hq-fleet-default";
4
+ export declare const LOCAL_BOTS_FLEET_DEFAULT_ENV = "HQ_BOT_FLEET_DEFAULT";
5
+ type DefaultFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
6
+ export interface OwnerMembership {
7
+ companyUid: string;
8
+ companySlug?: string;
9
+ status: string;
10
+ }
11
+ export interface FleetDefaultFlagDependencies {
12
+ env?: NodeJS.ProcessEnv;
13
+ createClient?: (options: FlagClientOptions) => DefaultFlagClient;
14
+ getToken?: FlagClientOptions["getToken"];
15
+ fetch?: typeof fetch;
16
+ /** The signed-in owner's memberships; never prompts for sign-in. */
17
+ memberships?: () => Promise<OwnerMembership[]>;
18
+ }
19
+ /** Every active company the signed-in owner can access, independent of rollout flags or local folders. */
20
+ export declare function fleetOwnerCompanies(dependencies?: Pick<FleetDefaultFlagDependencies, "memberships">): Promise<string[]>;
21
+ /**
22
+ * The owner's companies that turned the fleet-default flag on: active
23
+ * memberships whose company folder is on this computer. Any lookup failure
24
+ * leaves that company out. `HQ_BOT_FLEET_DEFAULT=1` skips the flag and keeps
25
+ * every such membership.
26
+ */
27
+ export declare function fleetDefaultCompanies(hqRoot: string, dependencies?: FleetDefaultFlagDependencies): Promise<string[]>;
28
+ /** `HQ_BOT_FLEET_DEFAULT=1|0` overrides the registry; otherwise on when any flagged company was found. */
29
+ export declare function fleetRuntimeIsDefault(flaggedCompanies: readonly string[], env?: NodeJS.ProcessEnv): boolean;
30
+ export {};
31
+ //# sourceMappingURL=fleet-default-flag.d.ts.map
@@ -0,0 +1,127 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
4
+ import { createFlagClient, } from "@indigoai-us/hq-flags-client";
5
+ import { ensureCognitoToken } from "../../utils/cognito-session.js";
6
+ import { vaultApiFetch } from "../../utils/vault-api.js";
7
+ import { resolveFlagGate } from "../flag-registry.js";
8
+ import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
9
+ /** Personal bots are created on the HQ Fleet Runtime for companies with this flag on. */
10
+ export const LOCAL_BOTS_FLEET_DEFAULT_FLAG = "agents.local-bots-hq-fleet-default";
11
+ export const LOCAL_BOTS_FLEET_DEFAULT_ENV = "HQ_BOT_FLEET_DEFAULT";
12
+ const FLAG_TIMEOUT_MS = 3_000;
13
+ const MEMBERSHIP_TIMEOUT_MS = 10_000;
14
+ const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
15
+ const COMPANY_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
16
+ async function signedInMemberships() {
17
+ const token = await ensureCognitoToken({ tokenSource: "person", interactive: false });
18
+ const res = await vaultApiFetch({ token, path: "/membership/me", signal: AbortSignal.timeout(MEMBERSHIP_TIMEOUT_MS) });
19
+ if (!res.ok)
20
+ throw new Error(`membership lookup returned ${res.status}`);
21
+ const data = (await res.json());
22
+ if (!Array.isArray(data.memberships))
23
+ throw new Error("membership lookup returned no list");
24
+ return data.memberships;
25
+ }
26
+ /** Every active company the signed-in owner can access, independent of rollout flags or local folders. */
27
+ export async function fleetOwnerCompanies(dependencies = {}) {
28
+ const memberships = await (dependencies.memberships ?? signedInMemberships)();
29
+ const companies = new Set();
30
+ for (const membership of memberships) {
31
+ const slug = membership.companySlug?.trim() ?? "";
32
+ if (membership.status !== "active" || !COMPANY_UID_RE.test(membership.companyUid))
33
+ continue;
34
+ if (!COMPANY_SLUG_RE.test(slug) || slug === "personal")
35
+ continue;
36
+ companies.add(slug);
37
+ }
38
+ return [...companies].sort((a, b) => a.localeCompare(b));
39
+ }
40
+ function hasLocalFolder(hqRoot, slug) {
41
+ try {
42
+ const stat = fs.lstatSync(path.join(hqRoot, "companies", slug));
43
+ return stat.isDirectory() && !stat.isSymbolicLink();
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /**
50
+ * The owner's companies that turned the fleet-default flag on: active
51
+ * memberships whose company folder is on this computer. Any lookup failure
52
+ * leaves that company out. `HQ_BOT_FLEET_DEFAULT=1` skips the flag and keeps
53
+ * every such membership.
54
+ */
55
+ export async function fleetDefaultCompanies(hqRoot, dependencies = {}) {
56
+ const env = dependencies.env ?? process.env;
57
+ let memberships;
58
+ try {
59
+ memberships = await (dependencies.memberships ?? signedInMemberships)();
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ const candidates = new Map();
65
+ for (const membership of memberships) {
66
+ const slug = membership.companySlug?.trim() ?? "";
67
+ if (membership.status !== "active" || !COMPANY_UID_RE.test(membership.companyUid))
68
+ continue;
69
+ if (!COMPANY_SLUG_RE.test(slug) || slug === "personal" || !hasLocalFolder(hqRoot, slug))
70
+ continue;
71
+ candidates.set(slug, membership.companyUid);
72
+ }
73
+ const entries = [...candidates.entries()].sort(([a], [b]) => a.localeCompare(b));
74
+ const override = env[LOCAL_BOTS_FLEET_DEFAULT_ENV]?.trim();
75
+ if (override === "1" || override === "true")
76
+ return entries.map(([slug]) => slug);
77
+ const createClient = dependencies.createClient ?? createFlagClient;
78
+ const getToken = dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? "");
79
+ const endpoint = resolveFlagRegistryEndpoint(undefined, env);
80
+ const doFetch = dependencies.fetch ?? globalThis.fetch;
81
+ const flagOn = async (slug, companyUid) => {
82
+ let client;
83
+ let failed = false;
84
+ try {
85
+ client = createClient({
86
+ endpoint,
87
+ companyUid,
88
+ companyIdentifiers: [slug],
89
+ env: {},
90
+ getToken,
91
+ ...(doFetch ? { fetch: doFetch } : {}),
92
+ refreshIntervalMs: 0,
93
+ requestTimeoutMs: FLAG_TIMEOUT_MS,
94
+ onError: () => {
95
+ failed = true;
96
+ },
97
+ });
98
+ await client.ready();
99
+ if (failed)
100
+ return false;
101
+ return resolveFlagGate(client, LOCAL_BOTS_FLEET_DEFAULT_FLAG, { fallback: false }, () => false);
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ finally {
107
+ try {
108
+ client?.close();
109
+ }
110
+ catch {
111
+ /* closing a flag client never changes the answer */
112
+ }
113
+ }
114
+ };
115
+ const results = await Promise.all(entries.map(([slug, uid]) => flagOn(slug, uid)));
116
+ return entries.filter((_, index) => results[index]).map(([slug]) => slug);
117
+ }
118
+ /** `HQ_BOT_FLEET_DEFAULT=1|0` overrides the registry; otherwise on when any flagged company was found. */
119
+ export function fleetRuntimeIsDefault(flaggedCompanies, env = process.env) {
120
+ const override = env[LOCAL_BOTS_FLEET_DEFAULT_ENV]?.trim();
121
+ if (override === "1" || override === "true")
122
+ return true;
123
+ if (override === "0" || override === "false")
124
+ return false;
125
+ return flaggedCompanies.length > 0;
126
+ }
127
+ //# sourceMappingURL=fleet-default-flag.js.map