@indigoai-us/hq-cli 5.47.6 → 5.47.8

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.
@@ -42,11 +42,13 @@ import {
42
42
  pinFilePath,
43
43
  runSharedWithMe,
44
44
  formatSharedWithMeTable,
45
+ createCompanyPresignClient,
45
46
  type BrowseVendResult,
46
47
  type FilesBrowseS3Client,
47
48
  type FilesBrowseVaultClient,
48
49
  type FilesSharedWithMeVaultClient,
49
50
  type S3ClientFactory,
51
+ type CompanyBrowseClientFactory,
50
52
  } from "./files-browse.js";
51
53
  import type { ExplicitGrant } from "@indigoai-us/hq-cloud";
52
54
  import {
@@ -143,6 +145,14 @@ interface StubS3Opts {
143
145
 
144
146
  function makeStubS3Factory(opts: StubS3Opts): {
145
147
  factory: S3ClientFactory;
148
+ /**
149
+ * The SAME stub typed as a company-mode client factory. Company browse/cat/
150
+ * get/search now inject `companyClient` (the presign/list seam) instead of
151
+ * `s3Factory`; the stub ignores its factory arg either way, so one stub
152
+ * serves both. Personal-mode tests use `factory`; company-mode tests use
153
+ * `companyFactory`.
154
+ */
155
+ companyFactory: CompanyBrowseClientFactory;
146
156
  sendSpy: ReturnType<typeof vi.fn>;
147
157
  factorySpy: ReturnType<typeof vi.fn>;
148
158
  } {
@@ -163,7 +173,12 @@ function makeStubS3Factory(opts: StubS3Opts): {
163
173
  send: sendSpy,
164
174
  }) as unknown as FilesBrowseS3Client,
165
175
  );
166
- return { factory: factorySpy as unknown as S3ClientFactory, sendSpy, factorySpy };
176
+ return {
177
+ factory: factorySpy as unknown as S3ClientFactory,
178
+ companyFactory: factorySpy as unknown as CompanyBrowseClientFactory,
179
+ sendSpy,
180
+ factorySpy,
181
+ };
167
182
  }
168
183
 
169
184
  // ── parseCompanySlugFromPath ────────────────────────────────────────────────
@@ -359,22 +374,22 @@ describe("formatBrowseTable", () => {
359
374
  // ── runBrowse ───────────────────────────────────────────────────────────────
360
375
 
361
376
  describe("runBrowse", () => {
362
- it("vends via /sts/vend (company) and lists the company-relative prefix", async () => {
377
+ it("company mode lists via the presign/list client (no STS vend) on the company-relative prefix", async () => {
363
378
  const { client, spies } = makeStubVaultClient({});
364
- const { factory, sendSpy } = makeStubS3Factory({
379
+ const { companyFactory, sendSpy } = makeStubS3Factory({
365
380
  listResponses: [{ Contents: [] }],
366
381
  });
367
382
  await runBrowse({
368
383
  pathPrefix: "companies/indigo/scratch/",
369
384
  vaultClient: client,
370
- s3Factory: factory,
385
+ companyClient: companyFactory,
371
386
  region: "us-east-1",
372
387
  });
373
- // Vend through the multi-tenant STS routeNOT the legacy POST /vend.
374
- expect(spies.stsVend).toHaveBeenCalledTimes(1);
375
- expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
388
+ // HQ-59: company browse no longer vends STS credsit goes through the
389
+ // server-side list API, so NEITHER vend route is touched.
390
+ expect(spies.stsVend).not.toHaveBeenCalled();
376
391
  expect(spies.vendSelf).not.toHaveBeenCalled();
377
- // S3 list prefix is company-relative (the bug: was anchored → 0 results).
392
+ // List prefix is company-relative (the bug: was anchored → 0 results).
378
393
  const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
379
394
  expect(listCmd.input.Prefix).toBe("scratch/");
380
395
  });
@@ -384,8 +399,8 @@ describe("runBrowse", () => {
384
399
  // Real grants are glob/anchored; normalization folds this to "scratch/".
385
400
  grants: [fakeGrant("companies/indigo/scratch/*")],
386
401
  });
387
- const { factory, sendSpy } = makeStubS3Factory({
388
- // S3 keys are company-relative (no companies/<slug>/ prefix).
402
+ const { companyFactory, sendSpy } = makeStubS3Factory({
403
+ // List objects are company-relative (no companies/<slug>/ prefix).
389
404
  listResponses: [
390
405
  {
391
406
  Contents: [
@@ -422,7 +437,7 @@ describe("runBrowse", () => {
422
437
  const result = await runBrowse({
423
438
  pathPrefix: "companies/indigo/scratch/",
424
439
  vaultClient: client,
425
- s3Factory: factory,
440
+ companyClient: companyFactory,
426
441
  region: "us-east-1",
427
442
  });
428
443
  expect(sendSpy).toHaveBeenCalledTimes(2); // pagination
@@ -600,9 +615,9 @@ describe("runBrowse", () => {
600
615
  // ── runCat ──────────────────────────────────────────────────────────────────
601
616
 
602
617
  describe("runCat", () => {
603
- it("vends via /sts/vend and GetObjects the company-relative key", async () => {
618
+ it("company mode fetches the company-relative key via presign (no STS vend)", async () => {
604
619
  const { client, spies } = makeStubVaultClient({});
605
- const { factory, sendSpy } = makeStubS3Factory({
620
+ const { companyFactory, sendSpy } = makeStubS3Factory({
606
621
  getResponse: {
607
622
  Body: Readable.from(Buffer.from("hello world")),
608
623
  } as GetObjectCommandOutput,
@@ -612,25 +627,25 @@ describe("runCat", () => {
612
627
  await runCat({
613
628
  key: "companies/indigo/scratch/a.txt",
614
629
  vaultClient: client,
615
- s3Factory: factory,
630
+ companyClient: companyFactory,
616
631
  region: "us-east-1",
617
632
  hqRoot: tmpRoot,
618
633
  stdout: sink,
619
634
  });
620
- expect(spies.stsVend).toHaveBeenCalledTimes(1);
621
- expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
635
+ // HQ-59: company cat goes through the presign API — no STS vend.
636
+ expect(spies.stsVend).not.toHaveBeenCalled();
637
+ expect(spies.vendSelf).not.toHaveBeenCalled();
622
638
  // GetObject key is company-relative (anchor stripped).
623
639
  const getCmd = sendSpy.mock.calls.find(
624
640
  (c) => c[0] instanceof GetObjectCommand,
625
641
  )?.[0] as GetObjectCommand;
626
- expect(getCmd.input.Bucket).toBe("hq-vault-cmp-indigo");
627
642
  expect(getCmd.input.Key).toBe("scratch/a.txt");
628
643
  });
629
644
 
630
645
  it("writes to --out when outside the companies tree and reports byte count", async () => {
631
646
  const { client } = makeStubVaultClient({});
632
647
  const payload = Buffer.from("safe-payload");
633
- const { factory } = makeStubS3Factory({
648
+ const { companyFactory } = makeStubS3Factory({
634
649
  getResponse: {
635
650
  Body: Readable.from(payload),
636
651
  } as GetObjectCommandOutput,
@@ -640,7 +655,7 @@ describe("runCat", () => {
640
655
  key: "companies/indigo/scratch/a.txt",
641
656
  out: outFile,
642
657
  vaultClient: client,
643
- s3Factory: factory,
658
+ companyClient: companyFactory,
644
659
  region: "us-east-1",
645
660
  hqRoot: tmpRoot,
646
661
  });
@@ -651,7 +666,7 @@ describe("runCat", () => {
651
666
 
652
667
  it("refuses --out under <hqRoot>/companies/ BEFORE vending (no leak)", async () => {
653
668
  const { client, spies } = makeStubVaultClient({});
654
- const { factory, sendSpy } = makeStubS3Factory({
669
+ const { companyFactory, sendSpy } = makeStubS3Factory({
655
670
  getResponse: { Body: Readable.from(Buffer.from("nope")) } as GetObjectCommandOutput,
656
671
  });
657
672
  const badOut = path.join(tmpRoot, "companies", "indigo", "leaked.txt");
@@ -660,13 +675,13 @@ describe("runCat", () => {
660
675
  key: "companies/indigo/scratch/a.txt",
661
676
  out: badOut,
662
677
  vaultClient: client,
663
- s3Factory: factory,
678
+ companyClient: companyFactory,
664
679
  region: "us-east-1",
665
680
  hqRoot: tmpRoot,
666
681
  }),
667
682
  ).rejects.toThrow(/Refusing to write/);
668
- // Critically: no vend was issued (guard runs first) and no S3 call
669
- // was made failing closed is the whole point of the guard.
683
+ // Critically: the guard runs FIRST no vend, and no list/presign/S3 call
684
+ // was made. Failing closed is the whole point of the guard.
670
685
  expect(spies.stsVend).not.toHaveBeenCalled();
671
686
  expect(spies.vendSelf).not.toHaveBeenCalled();
672
687
  expect(sendSpy).not.toHaveBeenCalled();
@@ -843,7 +858,7 @@ describe("runSharedWithMe", () => {
843
858
  describe("runSearch", () => {
844
859
  it("filters the company listing by case-insensitive substring on the key", async () => {
845
860
  const { client, spies } = makeStubVaultClient({});
846
- const { factory } = makeStubS3Factory({
861
+ const { companyFactory } = makeStubS3Factory({
847
862
  listResponses: [
848
863
  {
849
864
  Contents: [
@@ -858,20 +873,21 @@ describe("runSearch", () => {
858
873
  query: "roadmap", // lower-case query matches mixed-case key
859
874
  companySlug: "indigo",
860
875
  vaultClient: client,
861
- s3Factory: factory,
876
+ companyClient: companyFactory,
862
877
  region: "us-east-1",
863
878
  });
864
879
  // Keys are re-anchored for display; only the matching one survives.
865
880
  expect(rows.map((r) => r.key)).toEqual([
866
881
  "companies/indigo/knowledge/Roadmap.md",
867
882
  ]);
868
- // Vends via the multi-tenant route (inherited from runBrowse).
869
- expect(spies.stsVend).toHaveBeenCalledTimes(1);
883
+ // HQ-59: search lists via the presign/list API (inherited from runBrowse)
884
+ // no STS vend.
885
+ expect(spies.stsVend).not.toHaveBeenCalled();
870
886
  });
871
887
 
872
888
  it("returns an empty array when nothing matches", async () => {
873
889
  const { client } = makeStubVaultClient({});
874
- const { factory } = makeStubS3Factory({
890
+ const { companyFactory } = makeStubS3Factory({
875
891
  listResponses: [
876
892
  { Contents: [{ Key: "knowledge/a.md", Size: 1, LastModified: new Date() }] },
877
893
  ],
@@ -880,7 +896,7 @@ describe("runSearch", () => {
880
896
  query: "zzz-no-match",
881
897
  companySlug: "indigo",
882
898
  vaultClient: client,
883
- s3Factory: factory,
899
+ companyClient: companyFactory,
884
900
  region: "us-east-1",
885
901
  });
886
902
  expect(rows).toEqual([]);
@@ -922,7 +938,7 @@ describe("pin set", () => {
922
938
  function makeGetStubS3(opts: {
923
939
  listKeys: Array<{ Key: string; Size: number }>;
924
940
  bodyFor: (key: string) => Buffer;
925
- }): { factory: S3ClientFactory; sendSpy: ReturnType<typeof vi.fn> } {
941
+ }): { companyFactory: CompanyBrowseClientFactory; sendSpy: ReturnType<typeof vi.fn> } {
926
942
  const sendSpy = vi.fn(async (cmd: unknown) => {
927
943
  if (cmd instanceof ListObjectsV2Command) {
928
944
  return {
@@ -937,16 +953,17 @@ function makeGetStubS3(opts: {
937
953
  }
938
954
  throw new Error(`unexpected command: ${cmd}`);
939
955
  });
940
- const factory = vi.fn(
956
+ // `get` is company-only now, so the stub is the presign/list seam.
957
+ const companyFactory = vi.fn(
941
958
  () => ({ send: sendSpy }) as unknown as FilesBrowseS3Client,
942
- ) as unknown as S3ClientFactory;
943
- return { factory, sendSpy };
959
+ ) as unknown as CompanyBrowseClientFactory;
960
+ return { companyFactory, sendSpy };
944
961
  }
945
962
 
946
963
  describe("runGet", () => {
947
964
  it("materializes a prefix in-place under companies/<slug>/ and registers a pin", async () => {
948
965
  const { client, spies } = makeStubVaultClient({});
949
- const { factory, sendSpy } = makeGetStubS3({
966
+ const { companyFactory, sendSpy } = makeGetStubS3({
950
967
  listKeys: [
951
968
  { Key: "knowledge/a.md", Size: 3 },
952
969
  { Key: "knowledge/sub/b.md", Size: 3 },
@@ -959,12 +976,12 @@ describe("runGet", () => {
959
976
  hqRoot: tmpRoot,
960
977
  companySlug: "indigo",
961
978
  vaultClient: client,
962
- s3Factory: factory,
979
+ companyClient: companyFactory,
963
980
  region: "us-east-1",
964
981
  });
965
982
 
966
- // Vends via the multi-tenant company route.
967
- expect(spies.stsVend).toHaveBeenCalledWith({ companyUid: "cmp_indigo" });
983
+ // HQ-59: get materializes through the presign/list API — no STS vend.
984
+ expect(spies.stsVend).not.toHaveBeenCalled();
968
985
  expect(result.filesWritten).toBe(2);
969
986
 
970
987
  // Files landed in place under companies/<slug>/.
@@ -995,7 +1012,7 @@ describe("runGet", () => {
995
1012
  it("--into writes outside companies/ and registers NO pin", async () => {
996
1013
  const into = path.join(tmpRoot, "extract");
997
1014
  const { client } = makeStubVaultClient({});
998
- const { factory } = makeGetStubS3({
1015
+ const { companyFactory } = makeGetStubS3({
999
1016
  listKeys: [{ Key: "knowledge/a.md", Size: 3 }],
1000
1017
  bodyFor: () => Buffer.from("AAA"),
1001
1018
  });
@@ -1006,7 +1023,7 @@ describe("runGet", () => {
1006
1023
  hqRoot: tmpRoot,
1007
1024
  companySlug: "indigo",
1008
1025
  vaultClient: client,
1009
- s3Factory: factory,
1026
+ companyClient: companyFactory,
1010
1027
  region: "us-east-1",
1011
1028
  });
1012
1029
 
@@ -1019,7 +1036,7 @@ describe("runGet", () => {
1019
1036
 
1020
1037
  it("throws when no objects exist under the path", async () => {
1021
1038
  const { client } = makeStubVaultClient({});
1022
- const { factory } = makeGetStubS3({
1039
+ const { companyFactory } = makeGetStubS3({
1023
1040
  listKeys: [],
1024
1041
  bodyFor: () => Buffer.from(""),
1025
1042
  });
@@ -1029,7 +1046,7 @@ describe("runGet", () => {
1029
1046
  hqRoot: tmpRoot,
1030
1047
  companySlug: "indigo",
1031
1048
  vaultClient: client,
1032
- s3Factory: factory,
1049
+ companyClient: companyFactory,
1033
1050
  region: "us-east-1",
1034
1051
  }),
1035
1052
  ).rejects.toThrow(/No objects under/);
@@ -1083,3 +1100,150 @@ describe("--company parent binding (optsWithGlobals)", () => {
1083
1100
  expect(local).toBeUndefined();
1084
1101
  });
1085
1102
  });
1103
+
1104
+ // ── createCompanyPresignClient (HQ-59 list/presign translation) ─────────────
1105
+ //
1106
+ // The company-mode browse client. Verifies it translates the S3-shaped
1107
+ // commands the orchestrators issue into the server-side list/presign API:
1108
+ // - ListObjectsV2Command → GET /v1/files/list (company-relative objects)
1109
+ // - GetObjectCommand → POST /v1/files/presign → fetch(url) → Body
1110
+ // fetch is the single network seam (vaultApiFetch uses it; the presigned
1111
+ // download uses it), so we spy on globalThis.fetch and route by URL.
1112
+
1113
+ describe("createCompanyPresignClient", () => {
1114
+ let fetchSpy: ReturnType<typeof vi.spyOn>;
1115
+
1116
+ beforeEach(() => {
1117
+ fetchSpy = vi.spyOn(globalThis, "fetch");
1118
+ });
1119
+ afterEach(() => {
1120
+ fetchSpy.mockRestore();
1121
+ });
1122
+
1123
+ function json(status: number, body: unknown): Response {
1124
+ return new Response(JSON.stringify(body), {
1125
+ status,
1126
+ headers: { "content-type": "application/json" },
1127
+ });
1128
+ }
1129
+
1130
+ it("ListObjectsV2 → GET /v1/files/list, mapping objects + cursor onto the S3 shape", async () => {
1131
+ fetchSpy.mockResolvedValueOnce(
1132
+ json(200, {
1133
+ prefix: "scratch/",
1134
+ objects: [
1135
+ {
1136
+ key: "scratch/a.txt",
1137
+ size: 12,
1138
+ lastModified: "2026-01-01T00:00:00.000Z",
1139
+ etag: "abc123",
1140
+ permission: "read",
1141
+ },
1142
+ ],
1143
+ cursor: "next-page",
1144
+ truncated: true,
1145
+ }),
1146
+ );
1147
+
1148
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1149
+ const out = await client.send(
1150
+ new ListObjectsV2Command({
1151
+ Bucket: "ignored",
1152
+ Prefix: "scratch/",
1153
+ ContinuationToken: undefined,
1154
+ }),
1155
+ );
1156
+
1157
+ // Request: GET to /v1/files/list with company + prefix query, bearer token.
1158
+ const [url, init] = fetchSpy.mock.calls[0];
1159
+ expect(String(url)).toContain("/v1/files/list");
1160
+ expect(String(url)).toContain("company=cmp_indigo");
1161
+ expect(String(url)).toContain("prefix=scratch%2F");
1162
+ expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer tok");
1163
+
1164
+ // Response mapped onto the ListObjectsV2 shape the orchestrators read.
1165
+ expect(out.Contents).toHaveLength(1);
1166
+ expect(out.Contents?.[0].Key).toBe("scratch/a.txt");
1167
+ expect(out.Contents?.[0].Size).toBe(12);
1168
+ expect(out.Contents?.[0].ETag).toBe('"abc123"');
1169
+ expect(out.Contents?.[0].LastModified?.toISOString()).toBe("2026-01-01T00:00:00.000Z");
1170
+ expect(out.NextContinuationToken).toBe("next-page");
1171
+ expect(out.IsTruncated).toBe(true);
1172
+ });
1173
+
1174
+ it("ListObjectsV2 forwards the ContinuationToken as the cursor query param", async () => {
1175
+ fetchSpy.mockResolvedValueOnce(json(200, { objects: [], cursor: null, truncated: false }));
1176
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1177
+ await client.send(new ListObjectsV2Command({ Bucket: "b", Prefix: "k/", ContinuationToken: "page2" }));
1178
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("cursor=page2");
1179
+ });
1180
+
1181
+ it("ListObjectsV2 throws on a non-2xx list response", async () => {
1182
+ fetchSpy.mockResolvedValueOnce(json(403, { error: "Forbidden", code: "X" }));
1183
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1184
+ await expect(
1185
+ client.send(new ListObjectsV2Command({ Bucket: "b", Prefix: "k/" })),
1186
+ ).rejects.toThrow(/Forbidden/);
1187
+ });
1188
+
1189
+ it("GetObject → POST /v1/files/presign then fetches the URL and returns a Body", async () => {
1190
+ // 1) presign POST → returns a URL; 2) the URL fetch → returns the bytes.
1191
+ fetchSpy
1192
+ .mockResolvedValueOnce(
1193
+ json(200, { results: [{ key: "scratch/a.txt", op: "get", url: "https://s3.example/presigned" }] }),
1194
+ )
1195
+ .mockResolvedValueOnce(new Response("file-bytes-here", { status: 200 }));
1196
+
1197
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1198
+ const out = await client.send(
1199
+ new GetObjectCommand({ Bucket: "ignored", Key: "scratch/a.txt" }),
1200
+ );
1201
+
1202
+ // First call: POST presign with the right body.
1203
+ const [presignUrl, presignInit] = fetchSpy.mock.calls[0];
1204
+ expect(String(presignUrl)).toContain("/v1/files/presign");
1205
+ expect(presignInit?.method).toBe("POST");
1206
+ expect(JSON.parse(presignInit?.body as string)).toEqual({
1207
+ company: "cmp_indigo",
1208
+ key: "scratch/a.txt",
1209
+ op: "get",
1210
+ });
1211
+ // Second call: fetch the presigned URL.
1212
+ expect(String(fetchSpy.mock.calls[1][0])).toBe("https://s3.example/presigned");
1213
+
1214
+ // Body is a readable stream of the downloaded bytes.
1215
+ const chunks: Buffer[] = [];
1216
+ for await (const c of out.Body as unknown as AsyncIterable<Buffer>) {
1217
+ chunks.push(Buffer.from(c));
1218
+ }
1219
+ expect(Buffer.concat(chunks).toString("utf-8")).toBe("file-bytes-here");
1220
+ });
1221
+
1222
+ it("GetObject throws the per-key error when presign denies the key (no URL)", async () => {
1223
+ fetchSpy.mockResolvedValueOnce(
1224
+ json(200, {
1225
+ results: [
1226
+ { key: "secret/x", op: "get", error: "Caller lacks read permission on 'secret/x'", code: "FILES_PRESIGN_FORBIDDEN" },
1227
+ ],
1228
+ }),
1229
+ );
1230
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1231
+ await expect(
1232
+ client.send(new GetObjectCommand({ Bucket: "b", Key: "secret/x" })),
1233
+ ).rejects.toThrow(/lacks read permission/);
1234
+ // The denial short-circuits BEFORE any download fetch.
1235
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
1236
+ });
1237
+
1238
+ it("GetObject throws when the presigned download itself fails", async () => {
1239
+ fetchSpy
1240
+ .mockResolvedValueOnce(
1241
+ json(200, { results: [{ key: "k", op: "get", url: "https://s3.example/x" }] }),
1242
+ )
1243
+ .mockResolvedValueOnce(new Response("nope", { status: 500 }));
1244
+ const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
1245
+ await expect(
1246
+ client.send(new GetObjectCommand({ Bucket: "b", Key: "k" })),
1247
+ ).rejects.toThrow(/Failed to download 'k'/);
1248
+ });
1249
+ });