@indigoai-us/hq-cli 5.345.138 → 5.345.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.140] — 2026-10-16
6
+ - `hq auth status` now reports the machine token cache when HQ runs under a machine identity (fleet agents and other machine sign-ins). Before, it read only the person token file and reported "no cached session yet" on a machine that held a valid machine session, even though `hq auth refresh` had just renewed it.
7
+
8
+ ## [5.345.139] — 2026-10-15
9
+ - `hq storage offload` and `hq storage fetch` no longer fail about 15 to 25 minutes into a run. Personal and company
10
+ vault credentials are now re-vended before they expire, for the whole run and in the middle of a multipart upload.
11
+ When S3 still refuses a request's credentials, HQ re-vends once and retries that request once. A request refused
12
+ again fails, and an interrupted company upload resumes from its last finished part on the next run.
13
+ - Vault errors now name the request, the vault, the HTTP status and the error code, for example
14
+ `HEAD failed in company vault 'acme': HTTP 403 Forbidden (no error body: credentials expired or access denied)`,
15
+ instead of a bare `UnknownError`.
16
+
5
17
  ## [5.345.138] — 2026-10-15
6
18
  - `hq storage offload --current` can now free company files larger than the 50 MB sync limit. It uploads each one to its company vault, checks the vault copy against the file's sha256 and size, records it as cloud-only, writes the `.hqcloud` placeholder, and only then removes the local copy. An interrupted upload leaves the file in place and resumes on the next run. The dry run shows `upload+offload` with the byte count and the vault key. A path the `.hqinclude` allowlist excludes is refused with the line to add. `hq storage fetch` checks the vault copy's sha256 before restoring it. The upload path turns on once the bundled hq-cloud includes the new upload exports; until then these files report that HQ sync needs an update.
7
19
 
@@ -21,7 +21,7 @@ import { open, realpath } from "node:fs/promises";
21
21
  import path from "node:path";
22
22
  import chalk from "chalk";
23
23
  import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
24
- import { DEFAULT_COGNITO, refreshCachedSession, resolveCognitoTokenSource, ensureMachineSessionTokens, personTokenCacheFile, machineTokenCacheFile, } from "../utils/cognito-session.js";
24
+ import { DEFAULT_COGNITO, refreshCachedSession, resolveCognitoTokenSource, ensureMachineSessionTokens, personTokenCacheFile, machineTokenCacheFile, loadMachineCachedTokens, } from "../utils/cognito-session.js";
25
25
  // Static (node builtins only): a lazy import here would need a resident
26
26
  // preload entry for the auth-login path.
27
27
  import * as tokenKeychain from "../utils/token-keychain.js";
@@ -358,7 +358,11 @@ export function registerAuthCommands(program) {
358
358
  // the person cache rejected (transient refresh errors still report person).
359
359
  const tokenSource = resolveCognitoTokenSource();
360
360
  const machine = tokenSource === "machine";
361
- const cached = loadCachedTokens();
361
+ // Machine tokens live in the daemon state dir, never ~/.hq. Reading the
362
+ // person cache here reported "no cached session" on agent boxes whose
363
+ // machine cache was valid, and could show a stale person session as the
364
+ // machine one.
365
+ const cached = machine ? loadMachineCachedTokens() : loadCachedTokens();
362
366
  if (!cached) {
363
367
  if (machine) {
364
368
  // No cached session yet, but machine creds mint one on demand —
@@ -130,12 +130,54 @@ export interface BlobStore {
130
130
  export interface OffloadS3Client {
131
131
  send(cmd: PutObjectCommand | HeadObjectCommand | GetObjectCommand): Promise<unknown>;
132
132
  }
133
+ /**
134
+ * True when S3 refused the request's credentials: an expired, invalid or
135
+ * denied token, or a 403 with no error body (what S3 sends for HEAD).
136
+ */
137
+ export declare function isCredentialRejection(err: unknown): boolean;
138
+ /** A failed vault request, described with its HTTP status, error code and vault. */
139
+ export declare class VaultRequestError extends Error {
140
+ readonly status?: number;
141
+ readonly code?: string;
142
+ constructor(message: string, opts: {
143
+ status?: number;
144
+ code?: string;
145
+ cause: unknown;
146
+ });
147
+ }
148
+ /**
149
+ * Describes an AWS error from `op` against `where` so the user sees the HTTP
150
+ * status, the error code and the vault, never the SDK's bare "UnknownError".
151
+ * Errors that did not come from AWS pass through unchanged.
152
+ */
153
+ export declare function describeVaultError(err: unknown, op: string, where: string, retried?: boolean): unknown;
154
+ /** Turns any per-file failure into one line for the run's error list. */
155
+ export declare function failureMessage(err: unknown): string;
156
+ /**
157
+ * Runs vault requests on `initial` and, when S3 refuses the credentials,
158
+ * re-vends once and retries the request once on the new client. Requests
159
+ * refused on the same client share one re-vend. A request refused again
160
+ * after the re-vend fails with a described error; it is never retried twice.
161
+ */
162
+ export declare function credentialRetry<C>(initial: C, where: string, revend?: () => Promise<C>): <T>(op: string, fn: (client: C) => Promise<T>) => Promise<T>;
163
+ /**
164
+ * Wraps every method of `initial` (a multipart client) so each call gets
165
+ * {@link credentialRetry}: one re-vend and one retry when S3 refuses the
166
+ * credentials, and a described error otherwise.
167
+ */
168
+ export declare function credentialRetryingClient<C extends object>(initial: C, where: string, revend: () => Promise<C>): C;
169
+ export interface S3StoreOptions<C> {
170
+ /** How errors name the vault, e.g. "personal vault" or "company vault 'acme'". */
171
+ where?: string;
172
+ /** Vends fresh credentials and returns a client that uses them. */
173
+ revend?: () => Promise<C>;
174
+ }
133
175
  /**
134
176
  * S3-backed store over the caller's personal vault bucket. Accepts only
135
177
  * personal vault buckets (`hq-vault-prs-*`) so a mis-resolved entity can never
136
178
  * receive offloaded bytes.
137
179
  */
138
- export declare function s3BlobStore(bucket: string, s3: OffloadS3Client): BlobStore;
180
+ export declare function s3BlobStore(bucket: string, s3: OffloadS3Client, opts?: S3StoreOptions<OffloadS3Client>): BlobStore;
139
181
  /** Read-only access to vault objects at their sync keys, pinned to an ETag. */
140
182
  export interface VaultObjectStore {
141
183
  /**
@@ -153,7 +195,7 @@ export interface VaultObjectStore {
153
195
  export interface VaultObjectS3Client {
154
196
  send(cmd: GetObjectCommand | HeadObjectCommand): Promise<unknown>;
155
197
  }
156
- export declare function s3VaultObjectStore(bucket: string, s3: VaultObjectS3Client): VaultObjectStore;
198
+ export declare function s3VaultObjectStore(bucket: string, s3: VaultObjectS3Client, opts?: S3StoreOptions<VaultObjectS3Client>): VaultObjectStore;
157
199
  export interface OffloadDeps {
158
200
  hqRoot: string;
159
201
  now: () => Date;
@@ -484,13 +526,4 @@ export declare function getOffloadStatus(deps: OffloadDeps, opts: {
484
526
  export declare function gitExportBlob(hqRoot: string): (sha: string, dest: string) => Promise<void>;
485
527
  export declare function gitListPlaceholders(hqRoot: string): () => Promise<string[]>;
486
528
  export declare function defaultTmpDir(): string;
487
- export declare function personalS3Store(input: {
488
- bucket: string;
489
- region: string;
490
- credentials: {
491
- accessKeyId: string;
492
- secretAccessKey: string;
493
- sessionToken: string;
494
- };
495
- }): BlobStore;
496
529
  //# sourceMappingURL=storage-offload.d.ts.map
@@ -50,7 +50,7 @@ import * as fs from "node:fs";
50
50
  import * as os from "node:os";
51
51
  import * as path from "node:path";
52
52
  import { spawn, execFile } from "node:child_process";
53
- import { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from "@aws-sdk/client-s3";
53
+ import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from "@aws-sdk/client-s3";
54
54
  import { checkPreconditions, coreHasFlag, coreSupports, dropExpiredBackups, PRECONDITION_FALLBACK, coreKeepsUndo, reclaimFields, reclaimWindowHours, runCoreScript, } from "./storage-core.js";
55
55
  // ── Constants ───────────────────────────────────────────────────────────────
56
56
  export const PLACEHOLDER_SUFFIX = ".hqcloud";
@@ -211,103 +211,268 @@ function isNotFound(err) {
211
211
  const e = err;
212
212
  return e?.name === "NotFound" || e?.name === "NoSuchKey" || e?.$metadata?.httpStatusCode === 404;
213
213
  }
214
+ // ── Vault request errors and credential refresh ─────────────────────────────
215
+ /** Error codes S3 and STS return when the request's credentials are refused. */
216
+ const CREDENTIAL_ERROR_HINTS = {
217
+ ExpiredToken: "credentials expired",
218
+ ExpiredTokenException: "credentials expired",
219
+ TokenRefreshRequired: "credentials expired",
220
+ RequestExpired: "credentials expired",
221
+ InvalidToken: "credentials rejected",
222
+ InvalidAccessKeyId: "credentials rejected",
223
+ InvalidSecurityToken: "credentials rejected",
224
+ InvalidClientTokenId: "credentials rejected",
225
+ AccessDenied: "access denied",
226
+ };
227
+ /** Placeholder names the SDK uses when the response carried no error code. */
228
+ const NO_CODE = new Set(["", "Unknown", "UnknownError", "Error"]);
229
+ function awsErrorCode(err) {
230
+ const candidates = [err.Code, err.code, err.name, err.$response?.headers?.["x-amz-error-code"]];
231
+ return candidates.find((c) => typeof c === "string" && !NO_CODE.has(c));
232
+ }
233
+ function isAwsError(err) {
234
+ if (!err || typeof err !== "object")
235
+ return false;
236
+ const e = err;
237
+ return typeof e.$metadata === "object" || typeof e.$fault === "string" || e.name === "Unknown";
238
+ }
239
+ /**
240
+ * True when S3 refused the request's credentials: an expired, invalid or
241
+ * denied token, or a 403 with no error body (what S3 sends for HEAD).
242
+ */
243
+ export function isCredentialRejection(err) {
244
+ if (!isAwsError(err))
245
+ return false;
246
+ const status = err.$metadata?.httpStatusCode;
247
+ const code = awsErrorCode(err);
248
+ if (code)
249
+ return code in CREDENTIAL_ERROR_HINTS && (status === undefined || status === 400 || status === 403);
250
+ return status === 403;
251
+ }
252
+ /** A failed vault request, described with its HTTP status, error code and vault. */
253
+ export class VaultRequestError extends Error {
254
+ status;
255
+ code;
256
+ constructor(message, opts) {
257
+ super(message, { cause: opts.cause });
258
+ this.name = "VaultRequestError";
259
+ this.status = opts.status;
260
+ this.code = opts.code;
261
+ }
262
+ }
263
+ /**
264
+ * Describes an AWS error from `op` against `where` so the user sees the HTTP
265
+ * status, the error code and the vault, never the SDK's bare "UnknownError".
266
+ * Errors that did not come from AWS pass through unchanged.
267
+ */
268
+ export function describeVaultError(err, op, where, retried = false) {
269
+ if (err instanceof VaultRequestError || !isAwsError(err))
270
+ return err;
271
+ const status = err.$metadata?.httpStatusCode;
272
+ const code = awsErrorCode(err);
273
+ let what;
274
+ if (code) {
275
+ const hint = CREDENTIAL_ERROR_HINTS[code];
276
+ what = hint ? `${code} (${hint})` : code;
277
+ }
278
+ else if (status === 403) {
279
+ what = "Forbidden (no error body: credentials expired or access denied)";
280
+ }
281
+ else {
282
+ what = "error with no error code";
283
+ }
284
+ let message = `${op} failed in ${where}: ${status ? `HTTP ${status} ` : ""}${what}`;
285
+ if (retried)
286
+ message += ", after re-vending credentials once";
287
+ const detail = err.message?.trim();
288
+ if (detail && !NO_CODE.has(detail) && detail !== code)
289
+ message += `: ${detail}`;
290
+ return new VaultRequestError(message, { status, code, cause: err });
291
+ }
292
+ /** Turns any per-file failure into one line for the run's error list. */
293
+ export function failureMessage(err) {
294
+ const described = describeVaultError(err, "Request", "the vault");
295
+ return described instanceof Error ? described.message : String(described);
296
+ }
297
+ /**
298
+ * Runs vault requests on `initial` and, when S3 refuses the credentials,
299
+ * re-vends once and retries the request once on the new client. Requests
300
+ * refused on the same client share one re-vend. A request refused again
301
+ * after the re-vend fails with a described error; it is never retried twice.
302
+ */
303
+ export function credentialRetry(initial, where, revend) {
304
+ let client = initial;
305
+ let generation = 0;
306
+ let pending;
307
+ return async function call(op, fn) {
308
+ const used = generation;
309
+ try {
310
+ return await fn(client);
311
+ }
312
+ catch (err) {
313
+ if (!revend || !isCredentialRejection(err))
314
+ throw describeVaultError(err, op, where);
315
+ if (generation === used) {
316
+ pending ??= revend()
317
+ .then((fresh) => {
318
+ client = fresh;
319
+ generation += 1;
320
+ })
321
+ .finally(() => {
322
+ pending = undefined;
323
+ });
324
+ try {
325
+ await pending;
326
+ }
327
+ catch (vendErr) {
328
+ const refused = describeVaultError(err, op, where);
329
+ throw new Error(`${refused.message}; re-vending credentials for ${where} failed: ${vendErr?.message ?? String(vendErr)}`, { cause: vendErr });
330
+ }
331
+ }
332
+ try {
333
+ return await fn(client);
334
+ }
335
+ catch (again) {
336
+ throw describeVaultError(again, op, where, true);
337
+ }
338
+ }
339
+ };
340
+ }
341
+ const CLIENT_METHOD_OPS = {
342
+ create: "CreateMultipartUpload",
343
+ uploadPart: "UploadPart",
344
+ listParts: "ListParts",
345
+ complete: "CompleteMultipartUpload",
346
+ abort: "AbortMultipartUpload",
347
+ head: "HeadObject",
348
+ };
349
+ /**
350
+ * Wraps every method of `initial` (a multipart client) so each call gets
351
+ * {@link credentialRetry}: one re-vend and one retry when S3 refuses the
352
+ * credentials, and a described error otherwise.
353
+ */
354
+ export function credentialRetryingClient(initial, where, revend) {
355
+ const call = credentialRetry(initial, where, revend);
356
+ const wrapped = {};
357
+ for (const name of Object.keys(initial)) {
358
+ if (typeof initial[name] !== "function")
359
+ continue;
360
+ wrapped[name] = (...args) => call(CLIENT_METHOD_OPS[name] ?? name, (c) => c[name].apply(c, args));
361
+ }
362
+ return { ...initial, ...wrapped };
363
+ }
364
+ function pipeToNewFile(body, dest) {
365
+ return new Promise((resolve, reject) => {
366
+ const out = fs.createWriteStream(dest, { flags: "wx" });
367
+ body.on("error", reject);
368
+ out.on("error", reject);
369
+ out.on("finish", () => resolve());
370
+ body.pipe(out);
371
+ });
372
+ }
214
373
  /**
215
374
  * S3-backed store over the caller's personal vault bucket. Accepts only
216
375
  * personal vault buckets (`hq-vault-prs-*`) so a mis-resolved entity can never
217
376
  * receive offloaded bytes.
218
377
  */
219
- export function s3BlobStore(bucket, s3) {
378
+ export function s3BlobStore(bucket, s3, opts = {}) {
220
379
  if (!bucket || !bucket.startsWith(PERSONAL_BUCKET_PREFIX)) {
221
380
  throw new Error(`Refusing to offload into '${bucket}': offload only writes to your personal vault.`);
222
381
  }
382
+ const call = credentialRetry(s3, opts.where ?? `vault '${bucket}'`, opts.revend);
223
383
  return {
224
384
  vault: "personal",
225
- async head(key) {
226
- try {
227
- const res = (await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" })));
228
- const fromChecksum = res.ChecksumSHA256 && !res.ChecksumSHA256.includes("-")
229
- ? Buffer.from(res.ChecksumSHA256, "base64").toString("hex")
230
- : undefined;
231
- // The server-computed checksum wins; client metadata is a fallback for
232
- // objects stored without one.
233
- return { size: res.ContentLength ?? -1, sha256: fromChecksum ?? res.Metadata?.sha256 };
234
- }
235
- catch (err) {
236
- if (isNotFound(err))
237
- return null;
238
- throw err;
239
- }
385
+ head(key) {
386
+ return call("HEAD", async (client) => {
387
+ try {
388
+ const res = (await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" })));
389
+ const fromChecksum = res.ChecksumSHA256 && !res.ChecksumSHA256.includes("-")
390
+ ? Buffer.from(res.ChecksumSHA256, "base64").toString("hex")
391
+ : undefined;
392
+ // The server-computed checksum wins; client metadata is a fallback for
393
+ // objects stored without one.
394
+ return { size: res.ContentLength ?? -1, sha256: fromChecksum ?? res.Metadata?.sha256 };
395
+ }
396
+ catch (err) {
397
+ if (isNotFound(err))
398
+ return null;
399
+ throw err;
400
+ }
401
+ });
240
402
  },
241
403
  async put(key, file, digest) {
242
- await s3.send(new PutObjectCommand({
243
- Bucket: bucket,
244
- Key: key,
245
- Body: fs.createReadStream(file),
246
- ContentLength: digest.size,
247
- ChecksumSHA256: Buffer.from(digest.sha256, "hex").toString("base64"),
248
- StorageClass: "STANDARD",
249
- Metadata: { sha256: digest.sha256, "git-blob": digest.gitBlob },
250
- }));
404
+ await call("PUT", async (client) => {
405
+ // A fresh stream per attempt: a refused attempt may have read from the last one.
406
+ const body = fs.createReadStream(file);
407
+ try {
408
+ await client.send(new PutObjectCommand({
409
+ Bucket: bucket,
410
+ Key: key,
411
+ Body: body,
412
+ ContentLength: digest.size,
413
+ ChecksumSHA256: Buffer.from(digest.sha256, "hex").toString("base64"),
414
+ StorageClass: "STANDARD",
415
+ Metadata: { sha256: digest.sha256, "git-blob": digest.gitBlob },
416
+ }));
417
+ }
418
+ finally {
419
+ body.destroy();
420
+ }
421
+ });
251
422
  },
252
423
  async download(key, dest) {
253
- const res = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
424
+ // Only the request is retried: the destination is created by the pipe below.
425
+ const res = await call("GET", async (client) => (await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))));
254
426
  const body = res.Body;
255
427
  if (!body)
256
428
  throw new Error(`Empty download for ${key}.`);
257
- await new Promise((resolve, reject) => {
258
- const out = fs.createWriteStream(dest, { flags: "wx" });
259
- body.on("error", reject);
260
- out.on("error", reject);
261
- out.on("finish", () => resolve());
262
- body.pipe(out);
263
- });
429
+ await pipeToNewFile(body, dest);
264
430
  },
265
431
  };
266
432
  }
267
- export function s3VaultObjectStore(bucket, s3) {
433
+ export function s3VaultObjectStore(bucket, s3, opts = {}) {
434
+ const call = credentialRetry(s3, opts.where ?? `vault '${bucket}'`, opts.revend);
268
435
  return {
269
- async head(key) {
270
- try {
271
- const res = (await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })));
272
- const head = {
273
- size: res.ContentLength ?? -1,
274
- etag: normalizeEtag(res.ETag),
275
- };
276
- const sha = res.Metadata?.[VAULT_SHA256_METADATA_KEY];
277
- if (sha && HEX64.test(sha))
278
- head.sha256 = sha;
279
- return head;
280
- }
281
- catch (err) {
282
- if (isNotFound(err))
283
- return null;
284
- throw err;
285
- }
436
+ head(key) {
437
+ return call("HEAD", async (client) => {
438
+ try {
439
+ const res = (await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key })));
440
+ const head = {
441
+ size: res.ContentLength ?? -1,
442
+ etag: normalizeEtag(res.ETag),
443
+ };
444
+ const sha = res.Metadata?.[VAULT_SHA256_METADATA_KEY];
445
+ if (sha && HEX64.test(sha))
446
+ head.sha256 = sha;
447
+ return head;
448
+ }
449
+ catch (err) {
450
+ if (isNotFound(err))
451
+ return null;
452
+ throw err;
453
+ }
454
+ });
286
455
  },
287
456
  async download(key, dest, etag) {
288
- let res;
289
- try {
290
- res = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key, IfMatch: `"${normalizeEtag(etag)}"` })));
291
- }
292
- catch (err) {
293
- const e = err;
294
- if (e?.name === "PreconditionFailed" || e?.$metadata?.httpStatusCode === 412) {
295
- throw new Error("The cloud copy of this file changed since HQ sync last saw it. Run `hq sync`, then try again.");
457
+ // Only the request is retried: the destination is created by the pipe below.
458
+ const res = await call("GET", async (client) => {
459
+ try {
460
+ return (await client.send(new GetObjectCommand({ Bucket: bucket, Key: key, IfMatch: `"${normalizeEtag(etag)}"` })));
296
461
  }
297
- if (isNotFound(err))
298
- throw new Error(`This file is no longer in the cloud (${key}).`);
299
- throw err;
300
- }
462
+ catch (err) {
463
+ const e = err;
464
+ if (e?.name === "PreconditionFailed" || e?.$metadata?.httpStatusCode === 412) {
465
+ throw new Error("The cloud copy of this file changed since HQ sync last saw it. Run `hq sync`, then try again.");
466
+ }
467
+ if (isNotFound(err))
468
+ throw new Error(`This file is no longer in the cloud (${key}).`);
469
+ throw err;
470
+ }
471
+ });
301
472
  const body = res.Body;
302
473
  if (!body)
303
474
  throw new Error(`Empty download for ${key}.`);
304
- await new Promise((resolve, reject) => {
305
- const out = fs.createWriteStream(dest, { flags: "wx" });
306
- body.on("error", reject);
307
- out.on("error", reject);
308
- out.on("finish", () => resolve());
309
- body.pipe(out);
310
- });
475
+ await pipeToNewFile(body, dest);
311
476
  return normalizeEtag(res.ETag);
312
477
  },
313
478
  };
@@ -879,7 +1044,7 @@ async function runCurrent(opts, deps, result) {
879
1044
  current.freed_bytes += plan.bytes;
880
1045
  }
881
1046
  catch (err) {
882
- result.errors.push(`${plan.path}: ${err.message}`);
1047
+ result.errors.push(`${plan.path}: ${failureMessage(err)}`);
883
1048
  }
884
1049
  }
885
1050
  return "ok";
@@ -1023,7 +1188,7 @@ export async function runOffload(opts, deps) {
1023
1188
  });
1024
1189
  }
1025
1190
  catch (err) {
1026
- result.errors.push(`${blob.paths[0] ?? blob.sha}: ${err.message}`);
1191
+ result.errors.push(`${blob.paths[0] ?? blob.sha}: ${failureMessage(err)}`);
1027
1192
  }
1028
1193
  }
1029
1194
  if (manifest.length === 0)
@@ -1393,7 +1558,4 @@ export function gitListPlaceholders(hqRoot) {
1393
1558
  export function defaultTmpDir() {
1394
1559
  return os.tmpdir();
1395
1560
  }
1396
- export function personalS3Store(input) {
1397
- return s3BlobStore(input.bucket, new S3Client({ region: input.region, credentials: input.credentials }));
1398
- }
1399
1561
  //# sourceMappingURL=storage-offload.js.map
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { Command } from "commander";
37
37
  import { type ReclaimReport } from "./storage-core.js";
38
- import { ListObjectVersionsCommand, DeleteObjectsCommand, type ListObjectVersionsCommandOutput, type DeleteObjectsCommandOutput } from "@aws-sdk/client-s3";
38
+ import { S3Client, ListObjectVersionsCommand, DeleteObjectsCommand, type ListObjectVersionsCommandOutput, type DeleteObjectsCommandOutput } from "@aws-sdk/client-s3";
39
39
  import { type OffloadResult, type OffloadStatus } from "./storage-offload.js";
40
40
  export type TrancheId = "7d" | "30d" | "90d" | "365d" | "older";
41
41
  export declare const TRANCHES: readonly {
@@ -265,6 +265,17 @@ export declare const NEEDS_YES = "No terminal to confirm in. Re-run with --yes t
265
265
  export declare function confirmOnTty(message: string, input?: NodeJS.ReadableStream & {
266
266
  isTTY?: boolean;
267
267
  }, output?: NodeJS.WritableStream): Promise<boolean>;
268
+ /**
269
+ * An S3 client for one vault whose credentials are re-vended before they
270
+ * expire, plus `revend`, which vends now and returns a client on the new
271
+ * credentials (the SDK caches credentials per client, so a forced refresh
272
+ * needs a new client).
273
+ */
274
+ export declare function vendedS3(vend: () => Promise<VendedCredentials>, bucket: string, makeClient?: (credentials: ReturnType<typeof refreshingVendedCredentials>) => S3Client): {
275
+ bucket: string;
276
+ client: S3Client;
277
+ revend: () => Promise<S3Client>;
278
+ };
268
279
  /** STS credentials from one vend, with the time they stop working. */
269
280
  export interface VendedCredentials {
270
281
  accessKeyId: string;
@@ -48,7 +48,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
48
48
  import { findHqRoot } from "../utils/manifest.js";
49
49
  import { maxLength } from "../utils/table-width.js";
50
50
  import { resolveCanonicalPersonUid } from "./cloud.js";
51
- import { DEFAULT_IDLE_DAYS, currentAvailability, defaultTmpDir, getOffloadStatus, gitExportBlob, gitListPlaceholders, personalS3Store, resolveMinBytes, runFetch, runFetchGitBlob, runOffload, s3VaultObjectStore, syncExcludesOffload, } from "./storage-offload.js";
51
+ import { DEFAULT_IDLE_DAYS, currentAvailability, defaultTmpDir, getOffloadStatus, gitExportBlob, gitListPlaceholders, credentialRetryingClient, resolveMinBytes, runFetch, runFetchGitBlob, runOffload, s3BlobStore, s3VaultObjectStore, syncExcludesOffload, } from "./storage-offload.js";
52
52
  export const TRANCHES = [
53
53
  { id: "7d", label: "Last 7 days", maxAgeDays: 7 },
54
54
  { id: "30d", label: "8-30 days", maxAgeDays: 30 },
@@ -1018,8 +1018,14 @@ async function buildOffloadDeps() {
1018
1018
  }
1019
1019
  if (!entity.bucketName)
1020
1020
  throw new Error("Your personal vault has no storage yet. Run `hq onboard`.");
1021
- const vend = await vault.sts.vendSelf({ personUid: entity.uid });
1022
- return { bucket: entity.bucketName, credentials: vend.credentials };
1021
+ const uid = entity.uid;
1022
+ // A run can outlast one STS session, so every client re-vends before the
1023
+ // credentials expire, and once more when S3 refuses them anyway.
1024
+ return vendedS3(async () => {
1025
+ const { vault } = await signedInVault();
1026
+ const vend = await vault.sts.vendSelf({ personUid: uid });
1027
+ return { ...vend.credentials, expiresAt: vend.expiresAt };
1028
+ }, entity.bucketName);
1023
1029
  };
1024
1030
  const activeCompanies = async () => {
1025
1031
  const { token } = await signedInVault();
@@ -1037,7 +1043,13 @@ async function buildOffloadDeps() {
1037
1043
  throw new Error(`You are not an active member of '${slug}'.`);
1038
1044
  const { vault } = await signedInVault();
1039
1045
  const entity = await vault.entity.get(company.uid);
1040
- return { company, bucket: entity.bucketName ?? `hq-vault-cmp-${company.slug}` };
1046
+ const bucket = entity.bucketName ?? `hq-vault-cmp-${company.slug}`;
1047
+ const s3 = vendedS3(async () => {
1048
+ const { vault } = await signedInVault();
1049
+ const vend = await vault.sts.vend({ companyUid: company.uid });
1050
+ return { ...vend.credentials, expiresAt: vend.expiresAt };
1051
+ }, bucket);
1052
+ return { company, ...s3 };
1041
1053
  };
1042
1054
  // Company upload, the ignore explainer and the cloud-only journal row ship
1043
1055
  // in a newer @indigoai-us/hq-cloud. Until the bundled one exports them,
@@ -1059,8 +1071,8 @@ async function buildOffloadDeps() {
1059
1071
  tmpDir: defaultTmpDir,
1060
1072
  syncExcludesOffload: () => syncExcludesOffload(PERSONAL_VAULT_EXCLUDED_TOP_LEVEL),
1061
1073
  openPersonal: async () => {
1062
- const { bucket, credentials } = await personalVault();
1063
- return personalS3Store({ bucket, region: DEFAULT_COGNITO.region, credentials });
1074
+ const { bucket, client, revend } = await personalVault();
1075
+ return s3BlobStore(bucket, client, { where: "personal vault", revend });
1064
1076
  },
1065
1077
  activeCompanySlugs: async () => (await activeCompanies()).map((c) => c.slug),
1066
1078
  // Current-file offload replaces a file with a placeholder only when the
@@ -1086,13 +1098,11 @@ async function buildOffloadDeps() {
1086
1098
  // come from the existing membership-scoped /sts/vend path.
1087
1099
  openVaultObjects: async (target) => {
1088
1100
  if (target.vault === "personal") {
1089
- const { bucket, credentials } = await personalVault();
1090
- return s3VaultObjectStore(bucket, new S3Client({ region: DEFAULT_COGNITO.region, credentials }));
1101
+ const { bucket, client, revend } = await personalVault();
1102
+ return s3VaultObjectStore(bucket, client, { where: "personal vault", revend });
1091
1103
  }
1092
- const { company, bucket } = await companyVault(target.slug);
1093
- const { vault } = await signedInVault();
1094
- const vend = await vault.sts.vend({ companyUid: company.uid });
1095
- return s3VaultObjectStore(bucket, new S3Client({ region: DEFAULT_COGNITO.region, credentials: vend.credentials }));
1104
+ const { company, bucket, client, revend } = await companyVault(target.slug);
1105
+ return s3VaultObjectStore(bucket, client, { where: `company vault '${company.slug}'`, revend });
1096
1106
  },
1097
1107
  syncMaxFileBytes: typeof cloud.SYNC_MAX_FILE_BYTES === "number" ? cloud.SYNC_MAX_FILE_BYTES : undefined,
1098
1108
  syncExclusion: explainIgnore
@@ -1106,16 +1116,13 @@ async function buildOffloadDeps() {
1106
1116
  // since a multi-GB upload can outlast one STS session).
1107
1117
  openCompanyUploader: uploadLarge && multipartClient && recordOffloaded
1108
1118
  ? async (slug) => {
1109
- const { company, bucket } = await companyVault(slug);
1119
+ const { company, bucket, client: s3, revend } = await companyVault(slug);
1110
1120
  if (bucket.startsWith("hq-vault-prs-")) {
1111
1121
  throw new Error(`'${bucket}' is a personal vault, not the company vault for '${slug}'.`);
1112
1122
  }
1113
- const credentials = refreshingVendedCredentials(async () => {
1114
- const { vault } = await signedInVault();
1115
- const vend = await vault.sts.vend({ companyUid: company.uid });
1116
- return { ...vend.credentials, expiresAt: vend.expiresAt };
1117
- });
1118
- const client = multipartClient(new S3Client({ region: DEFAULT_COGNITO.region, credentials }), bucket);
1123
+ // Each multipart request re-vends once if S3 still refuses the
1124
+ // credentials; a part refused twice leaves the upload resumable.
1125
+ const client = credentialRetryingClient(multipartClient(s3, bucket), `company vault '${company.slug}'`, async () => multipartClient(await revend(), bucket));
1119
1126
  return {
1120
1127
  slug: company.slug,
1121
1128
  bucket,
@@ -1133,6 +1140,24 @@ async function buildOffloadDeps() {
1133
1140
  : undefined,
1134
1141
  };
1135
1142
  }
1143
+ /**
1144
+ * An S3 client for one vault whose credentials are re-vended before they
1145
+ * expire, plus `revend`, which vends now and returns a client on the new
1146
+ * credentials (the SDK caches credentials per client, so a forced refresh
1147
+ * needs a new client).
1148
+ */
1149
+ export function vendedS3(vend, bucket, makeClient = (credentials) => new S3Client({ region: DEFAULT_COGNITO.region, credentials })) {
1150
+ return {
1151
+ bucket,
1152
+ client: makeClient(refreshingVendedCredentials(vend)),
1153
+ revend: async () => {
1154
+ const credentials = refreshingVendedCredentials(vend);
1155
+ // Vend now, so a failed vend is reported as one rather than as a refused request.
1156
+ await credentials();
1157
+ return makeClient(credentials);
1158
+ },
1159
+ };
1160
+ }
1136
1161
  /**
1137
1162
  * AWS credential provider that vends fresh credentials on first use and again
1138
1163
  * whenever the previous ones are within `marginMs` of expiring. Each vend calls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.138",
3
+ "version": "5.345.140",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {