@indigoai-us/hq-cloud 6.14.19 → 6.14.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.19",
3
+ "version": "6.14.20",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -87,6 +87,159 @@ describe("resolveMembershipsForRun — setup-needed reasons", () => {
87
87
  });
88
88
 
89
89
  describe("buildFanoutPlan — setup-needed reasons", () => {
90
+ it("resolves an agent machine identity to only its claimed personal vault", async () => {
91
+ const agentUid = "agt_01J9AGENTSELF0000000000000";
92
+ const listByType = vi.fn((type: string) =>
93
+ Promise.resolve(
94
+ type === "agent"
95
+ ? [
96
+ {
97
+ uid: agentUid,
98
+ slug: "agent-self",
99
+ type: "agent",
100
+ status: "active",
101
+ bucketName: `hq-vault-${agentUid}`,
102
+ },
103
+ ]
104
+ : [],
105
+ ),
106
+ );
107
+ const result = await buildFanoutPlan({
108
+ memberships: [],
109
+ companies: false,
110
+ personal: true,
111
+ skipPersonal: false,
112
+ client: clientStub({
113
+ entity: { get: () => Promise.resolve(undefined), listByType },
114
+ }),
115
+ claims: {
116
+ "custom:entityType": "agent",
117
+ "custom:entityUid": agentUid,
118
+ },
119
+ resolveSkipPersonal: () => false,
120
+ });
121
+
122
+ expect(listByType).toHaveBeenCalledWith("agent");
123
+ expect(result).toEqual({
124
+ status: "plan",
125
+ plan: [
126
+ {
127
+ slug: "personal",
128
+ uid: agentUid,
129
+ bucketName: `hq-vault-${agentUid}`,
130
+ personalMode: true,
131
+ journalSlug: "__hq_personal_vault__",
132
+ },
133
+ ],
134
+ });
135
+ });
136
+
137
+ it("does not let an agent machine identity fall back to a person vault", async () => {
138
+ const listByType = vi.fn(() => Promise.resolve([]));
139
+ const result = await buildFanoutPlan({
140
+ memberships: [],
141
+ companies: false,
142
+ personal: true,
143
+ skipPersonal: false,
144
+ client: clientStub({
145
+ entity: { get: () => Promise.resolve(undefined), listByType },
146
+ }),
147
+ claims: {
148
+ "custom:entityType": "agent",
149
+ "custom:entityUid": "agt_01J9AGENTSELF0000000000000",
150
+ },
151
+ resolveSkipPersonal: () => false,
152
+ });
153
+
154
+ expect(listByType).toHaveBeenCalledWith("agent");
155
+ expect(result).toEqual({ status: "setup-needed", reason: "no-person-entity" });
156
+ });
157
+
158
+ it("folds the same agent personal target into a --companies plan", async () => {
159
+ const agentUid = "agt_01J9AGENTSELF0000000000000";
160
+ const result = await buildFanoutPlan({
161
+ memberships: [{ companyUid: "cmp_team" }],
162
+ companies: true,
163
+ personal: false,
164
+ skipPersonal: false,
165
+ client: clientStub({
166
+ entity: {
167
+ get: () =>
168
+ Promise.resolve({
169
+ uid: "cmp_team",
170
+ slug: "team",
171
+ type: "company",
172
+ status: "active",
173
+ bucketName: "hq-vault-cmp-team",
174
+ }),
175
+ listByType: (type: string) => {
176
+ expect(type).toBe("agent");
177
+ return Promise.resolve([
178
+ {
179
+ uid: agentUid,
180
+ slug: "agent-self",
181
+ type: "agent",
182
+ status: "active",
183
+ bucketName: `hq-vault-${agentUid}`,
184
+ },
185
+ ]);
186
+ },
187
+ },
188
+ }),
189
+ claims: {
190
+ "custom:entityType": "agent",
191
+ "custom:entityUid": agentUid,
192
+ },
193
+ resolveSkipPersonal: () => false,
194
+ });
195
+
196
+ expect(result).toMatchObject({
197
+ status: "plan",
198
+ plan: [
199
+ { uid: "cmp_team", slug: "team" },
200
+ {
201
+ uid: agentUid,
202
+ slug: "personal",
203
+ bucketName: `hq-vault-${agentUid}`,
204
+ personalMode: true,
205
+ },
206
+ ],
207
+ });
208
+ });
209
+
210
+ it("keeps the human personal target on the person collection", async () => {
211
+ const personUid = "prs_01J9PERSONSELF000000000000";
212
+ const listByType = vi.fn(() =>
213
+ Promise.resolve([
214
+ {
215
+ uid: personUid,
216
+ slug: "person-self",
217
+ type: "person",
218
+ status: "active",
219
+ bucketName: `hq-vault-${personUid}`,
220
+ createdAt: "2026-01-01T00:00:00.000Z",
221
+ },
222
+ ]),
223
+ );
224
+ const result = await buildFanoutPlan({
225
+ memberships: [],
226
+ companies: false,
227
+ personal: true,
228
+ skipPersonal: false,
229
+ client: clientStub({
230
+ entity: { get: () => Promise.resolve(undefined), listByType },
231
+ }),
232
+ claims: { sub: "human-sub" },
233
+ resolveSkipPersonal: () => false,
234
+ });
235
+
236
+ expect(listByType).toHaveBeenCalledWith("person");
237
+ expect(result).toMatchObject({
238
+ status: "plan",
239
+ plan: [{ uid: personUid, bucketName: `hq-vault-${personUid}` }],
240
+ });
241
+ });
242
+
90
243
  it("reports no-person-entity when --personal has no resolvable personal target", async () => {
91
244
  const result = await buildFanoutPlan({
92
245
  memberships: [],
@@ -177,26 +177,19 @@ export async function buildFanoutPlan(options: {
177
177
  (options.companies || options.personal) &&
178
178
  !options.resolveSkipPersonal(options.skipPersonal)
179
179
  ) {
180
- // Self-entity listing. For a person identity this returns the caller's
181
- // person entit(ies); for an agent machine identity (idToken
182
- // `custom:entityType=agent`) the server scopes it to the agent's OWN
183
- // entity (type: "agent"). `listByType("person")` is the same orphan-safe
184
- // self-listing call in both cases — the server resolves the caller from
185
- // the JWT, so an agent box gets back its own agt_ entity here.
186
- const persons = await options.client.entity.listByType("person");
187
-
188
- // Agent-aware target selection (US-013, unblocks US-004): when the runner
189
- // is an agent machine identity, the person-only canonical pick drops the
190
- // agent's own `type: "agent"` entity — so resolve the agent's OWN entity
191
- // by its claimed self-uid instead. Falls back to the person-only pick for
192
- // a person identity (or an agent whose self-entity didn't list), so the
193
- // prs_* `--personal` path is unchanged.
180
+ // Self-entity listing. `/entity/by-type/:type` is type-filtered: querying
181
+ // `person` for an agent JWT returns no agent entity in production. Query
182
+ // the caller-scoped agent collection for a machine identity instead, then
183
+ // require the JWT-bound UID below. A machine caller must never fall back
184
+ // to a person entity, which could otherwise select somebody else's vault.
194
185
  const isAgentIdentity = options.claims?.["custom:entityType"] === "agent";
195
186
  const agentSelfUid = options.claims?.["custom:entityUid"] ?? "";
187
+ const selfEntities = await options.client.entity.listByType(
188
+ isAgentIdentity ? "agent" : "person",
189
+ );
196
190
  const pick = isAgentIdentity
197
- ? (pickAgentSelfEntity(persons, agentSelfUid) ??
198
- pickCanonicalPersonEntity(persons))
199
- : pickCanonicalPersonEntity(persons);
191
+ ? pickAgentSelfEntity(selfEntities, agentSelfUid)
192
+ : pickCanonicalPersonEntity(selfEntities);
200
193
  if (pick?.bucketName) {
201
194
  plan.push({
202
195
  slug: "personal",
@@ -6459,6 +6459,77 @@ describe("US-004 — --personal for an AGENT machine identity (HARD GATE)", () =
6459
6459
  "custom:entityUid": AGENT_UID,
6460
6460
  };
6461
6461
 
6462
+ it("uses the newly minted machine ID token for --personal claims when no cache claims are available", async () => {
6463
+ const machineIdToken = `header.${Buffer.from(JSON.stringify(agentClaims)).toString("base64url")}.signature`;
6464
+ const listByType = vi.fn((type: string) =>
6465
+ Promise.resolve(type === "agent" ? [agentSelfEntity] : []),
6466
+ );
6467
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
6468
+ const deps = makeDeps({
6469
+ getAccessToken: vi.fn().mockResolvedValue(machineIdToken),
6470
+ // Simulate a newly minted token before any cache read is available.
6471
+ getIdTokenClaims: () => null,
6472
+ sync: syncSpy as unknown as RunnerDeps["sync"],
6473
+ createVaultClient: () => ({
6474
+ ...makeVaultStub(),
6475
+ entity: { get: () => Promise.resolve(undefined as never), listByType },
6476
+ }),
6477
+ });
6478
+
6479
+ expect(await runRunner(["--personal"], deps)).toBe(0);
6480
+ expect(listByType).toHaveBeenCalledWith("agent");
6481
+ expect(syncSpy).toHaveBeenCalledTimes(1);
6482
+ expect(deps.stderr.events().some((event) => event.type === "auth-error")).toBe(false);
6483
+ });
6484
+
6485
+ it("uses the freshly authenticated machine claims over a stale human cache for --companies", async () => {
6486
+ const machineIdToken = `header.${Buffer.from(JSON.stringify(agentClaims)).toString("base64url")}.signature`;
6487
+ const listByType = vi.fn((type: string) =>
6488
+ Promise.resolve(type === "agent" ? [agentSelfEntity] : []),
6489
+ );
6490
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
6491
+ const deps = makeDeps({
6492
+ getAccessToken: vi.fn().mockResolvedValue(machineIdToken),
6493
+ // A pre-bootstrap human token must not make this machine run select a
6494
+ // person vault (or drop its own personal target altogether).
6495
+ getIdTokenClaims: () => ({ sub: "human-sub" }),
6496
+ sync: syncSpy as unknown as RunnerDeps["sync"],
6497
+ createVaultClient: () => ({
6498
+ ...makeVaultStub({ memberships: [{ companyUid: "cmp_team" }] }),
6499
+ entity: {
6500
+ get: (uid: string) =>
6501
+ Promise.resolve(
6502
+ uid === "cmp_team"
6503
+ ? ({
6504
+ uid,
6505
+ type: "company",
6506
+ slug: "team",
6507
+ bucketName: "hq-vault-cmp-team",
6508
+ status: "active",
6509
+ } as EntityInfo)
6510
+ : (undefined as never),
6511
+ ),
6512
+ listByType,
6513
+ },
6514
+ }),
6515
+ });
6516
+
6517
+ expect(await runRunner(["--companies"], deps)).toBe(0);
6518
+ expect(listByType).toHaveBeenCalledWith("agent");
6519
+ expect(syncSpy).toHaveBeenCalledTimes(2);
6520
+ const plan = deps.stdout.events().find((event) => event.type === "fanout-plan") as Extract<
6521
+ RunnerEvent,
6522
+ { type: "fanout-plan" }
6523
+ >;
6524
+ expect(plan.companies).toContainEqual(
6525
+ expect.objectContaining({
6526
+ uid: AGENT_UID,
6527
+ slug: "personal",
6528
+ personalMode: true,
6529
+ }),
6530
+ );
6531
+ });
6532
+
6462
6533
  // -----------------------------------------------------------------------
6463
6534
  // (1) RESOLUTION — UNBLOCKED by US-013.
6464
6535
  //
@@ -1203,8 +1203,9 @@ export async function runRunner(
1203
1203
  (() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
1204
1204
  const clearSession =
1205
1205
  deps.clearSession ?? invalidateCachedTokensByFingerprint;
1206
+ let initialAuthToken: string;
1206
1207
  try {
1207
- await getAccessToken();
1208
+ initialAuthToken = await getAccessToken();
1208
1209
  } catch (err) {
1209
1210
  if (
1210
1211
  (err instanceof CognitoRefreshError && !err.requiresReauth) ||
@@ -1251,7 +1252,19 @@ export async function runRunner(
1251
1252
  // Resolved here (not inside `parsed.companies`) so single-company runs also
1252
1253
  // get author attribution. `null` is fine — share() simply omits the metadata.
1253
1254
  const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
1254
- const claims = getClaims();
1255
+ // A machine token is minted immediately above and is an ID token. Make its
1256
+ // entity binding authoritative for this run: a stale human token cache can
1257
+ // otherwise make an authenticated agent look like a person during fanout
1258
+ // planning, which drops the agent's own personal-vault target. Human callers
1259
+ // still retain the cache as their primary claims source because their bearer
1260
+ // is normally an access token without the full ID-token profile.
1261
+ const authenticatedClaims = decodeJwtClaims(initialAuthToken);
1262
+ const authenticatedMachineClaims =
1263
+ authenticatedClaims?.["custom:entityType"] === "agent" &&
1264
+ typeof authenticatedClaims["custom:entityUid"] === "string"
1265
+ ? authenticatedClaims
1266
+ : null;
1267
+ const claims = authenticatedMachineClaims ?? getClaims() ?? authenticatedClaims;
1255
1268
  const uploadAuthor: UploadAuthor | undefined =
1256
1269
  claims?.sub && claims?.email
1257
1270
  ? { userSub: claims.sub, email: claims.email }