@agent-native/dispatch 0.30.1 → 0.30.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/actions/ask_app.d.ts +1 -0
  2. package/dist/actions/ask_app_status.d.ts +1 -0
  3. package/dist/routes/pages/$appId.d.ts.map +1 -1
  4. package/dist/routes/pages/$appId.js +4 -3
  5. package/dist/routes/pages/$appId.js.map +1 -1
  6. package/dist/routes/pages/_index.d.ts.map +1 -1
  7. package/dist/routes/pages/_index.js +7 -2
  8. package/dist/routes/pages/_index.js.map +1 -1
  9. package/dist/routes/pages/_index.test.d.ts +2 -0
  10. package/dist/routes/pages/_index.test.d.ts.map +1 -0
  11. package/dist/routes/pages/_index.test.js +29 -0
  12. package/dist/routes/pages/_index.test.js.map +1 -0
  13. package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
  14. package/dist/server/lib/dispatch-integrations.js +149 -7
  15. package/dist/server/lib/dispatch-integrations.js.map +1 -1
  16. package/dist/server/lib/dispatch-store.d.ts +1 -0
  17. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  18. package/dist/server/lib/dispatch-store.js +4 -0
  19. package/dist/server/lib/dispatch-store.js.map +1 -1
  20. package/dist/server/lib/mcp-gateway.d.ts +8 -2
  21. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  22. package/dist/server/lib/mcp-gateway.js +39 -6
  23. package/dist/server/lib/mcp-gateway.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/routes/pages/$appId.tsx +4 -3
  26. package/src/routes/pages/_index.test.ts +34 -0
  27. package/src/routes/pages/_index.tsx +7 -2
  28. package/src/server/lib/dispatch-integrations.spec.ts +578 -7
  29. package/src/server/lib/dispatch-integrations.ts +186 -9
  30. package/src/server/lib/dispatch-store.ts +7 -0
  31. package/src/server/lib/mcp-gateway.spec.ts +328 -17
  32. package/src/server/lib/mcp-gateway.ts +59 -4
@@ -9,7 +9,7 @@ import {
9
9
  saveIntegrationScope,
10
10
  slackInstallationKey,
11
11
  } from "@agent-native/core/integrations";
12
- import { resolveOrgIdForEmail } from "@agent-native/core/org";
12
+ import { isOrgMember, resolveOrgIdForEmail } from "@agent-native/core/org";
13
13
  import { withConfiguredAppBasePath } from "@agent-native/core/server";
14
14
  import type {
15
15
  IncomingMessage,
@@ -122,15 +122,40 @@ function configuredDispatchIdentitiesUrl(): string | null {
122
122
  async function resolveManagedSlackInstallation(incoming: IncomingMessage) {
123
123
  if (incoming.platform !== "slack") return null;
124
124
  const teamId = contextString(incoming.platformContext.teamId);
125
+ const enterpriseId = contextString(incoming.platformContext.enterpriseId);
125
126
  const apiAppId = contextString(incoming.platformContext.apiAppId);
126
- if (!teamId) return null;
127
- try {
128
- return await getActiveIntegrationInstallationByKey(
129
- "slack",
130
- slackInstallationKey({ teamId, apiAppId }),
131
- );
132
- } catch {
127
+ const isEnterpriseInstall =
128
+ incoming.platformContext.isEnterpriseInstall === true;
129
+ if ((!teamId && !enterpriseId) || (isEnterpriseInstall && !enterpriseId))
133
130
  return null;
131
+ return getActiveIntegrationInstallationByKey(
132
+ "slack",
133
+ slackInstallationKey({
134
+ teamId,
135
+ enterpriseId,
136
+ apiAppId,
137
+ isEnterpriseInstall,
138
+ }),
139
+ );
140
+ }
141
+
142
+ class ManagedSlackInstallationLookupError extends Error {
143
+ constructor(options?: ErrorOptions) {
144
+ super(
145
+ "Managed Slack installation identity is temporarily unavailable",
146
+ options,
147
+ );
148
+ this.name = "ManagedSlackInstallationLookupError";
149
+ }
150
+ }
151
+
152
+ async function resolveManagedSlackInstallationOrFail(
153
+ incoming: IncomingMessage,
154
+ ) {
155
+ try {
156
+ return await resolveManagedSlackInstallation(incoming);
157
+ } catch (cause) {
158
+ throw new ManagedSlackInstallationLookupError({ cause });
134
159
  }
135
160
  }
136
161
 
@@ -162,6 +187,14 @@ function formatSlackLinkRequiredMessage(): string {
162
187
  return `Agent Native is ready, but this Slack account is not linked to an Agent Native user yet. ${linkStep}`;
163
188
  }
164
189
 
190
+ function formatSlackIdentityVerificationFailedMessage(): string {
191
+ return "I couldn't verify your Slack identity just now, so I can't run this request. Please try again in a moment.";
192
+ }
193
+
194
+ function formatSlackIdentityDeniedMessage(): string {
195
+ return "This assistant is only available to members of this workspace's organization.";
196
+ }
197
+
165
198
  async function resolveSlackSenderProfile(
166
199
  incoming: IncomingMessage,
167
200
  ): Promise<SlackSenderProfile> {
@@ -202,6 +235,7 @@ async function resolveSlackSenderProfile(
202
235
  };
203
236
  is_restricted?: boolean;
204
237
  is_ultra_restricted?: boolean;
238
+ is_stranger?: boolean;
205
239
  };
206
240
  };
207
241
  const profile = data.ok
@@ -214,6 +248,7 @@ async function resolveSlackSenderProfile(
214
248
  data.user?.name?.trim() ||
215
249
  null,
216
250
  trust:
251
+ data.user?.is_stranger === true ||
217
252
  data.user?.is_ultra_restricted === true
218
253
  ? ("external_shared" as const)
219
254
  : data.user?.is_restricted === true
@@ -250,6 +285,90 @@ async function resolveSlackOwnerFromVerifiedEmail(
250
285
  return orgId ? profile.email : null;
251
286
  }
252
287
 
288
+ async function resolveManagedSlackDmExecutionContext(
289
+ incoming: IncomingMessage,
290
+ installation: NonNullable<
291
+ Awaited<ReturnType<typeof resolveManagedSlackInstallation>>
292
+ >,
293
+ ): Promise<IntegrationExecutionContext> {
294
+ const profile = await resolveSlackSenderProfile(incoming);
295
+ incoming.actorTrust = {
296
+ memberType:
297
+ profile.trust === "guest"
298
+ ? "guest"
299
+ : profile.trust === "external_shared"
300
+ ? "external"
301
+ : profile.trust === "trusted"
302
+ ? "member"
303
+ : "unknown",
304
+ verified: profile.trust !== "unknown",
305
+ };
306
+ incoming.senderVerified = profile.email !== null;
307
+ if (profile.email) {
308
+ incoming.senderEmail = profile.email;
309
+ incoming.platformContext.senderEmail = profile.email;
310
+ }
311
+ if (profile.name) {
312
+ incoming.senderName = profile.name;
313
+ incoming.platformContext.senderName = profile.name;
314
+ }
315
+
316
+ const deniedContext = (): IntegrationExecutionContext => ({
317
+ ownerEmail: fallbackOwnerForIncoming(incoming),
318
+ orgId: null,
319
+ principalType: "user",
320
+ installationId: installation.id,
321
+ });
322
+
323
+ if (profile.trust === "unknown") {
324
+ incoming.platformContext.identityVerificationFailed = true;
325
+ return deniedContext();
326
+ }
327
+ if (profile.trust === "guest" || profile.trust === "external_shared") {
328
+ incoming.platformContext.identityAccessDenied = true;
329
+ return deniedContext();
330
+ }
331
+ if (!installation.orgId) {
332
+ incoming.platformContext.identityLinkRequired = true;
333
+ return deniedContext();
334
+ }
335
+ incoming.platformContext.managedInstallationOrgId = installation.orgId;
336
+
337
+ let linkedOwner: string | null;
338
+ try {
339
+ linkedOwner = await resolveLinkedOwner(
340
+ "slack",
341
+ identityKeyForIncoming(incoming),
342
+ { orgId: installation.orgId },
343
+ );
344
+ } catch {
345
+ incoming.platformContext.identityVerificationFailed = true;
346
+ return deniedContext();
347
+ }
348
+ const candidates = [profile.email, linkedOwner].filter(
349
+ (email, index, values): email is string =>
350
+ !!email && values.indexOf(email) === index,
351
+ );
352
+ for (const ownerEmail of candidates) {
353
+ try {
354
+ if (await isOrgMember(installation.orgId, ownerEmail)) {
355
+ return {
356
+ ownerEmail,
357
+ orgId: installation.orgId,
358
+ principalType: "user",
359
+ installationId: installation.id,
360
+ };
361
+ }
362
+ } catch {
363
+ incoming.platformContext.identityVerificationFailed = true;
364
+ return deniedContext();
365
+ }
366
+ }
367
+
368
+ incoming.platformContext.identityLinkRequired = true;
369
+ return deniedContext();
370
+ }
371
+
253
372
  async function resolveSlackConversationTrust(
254
373
  incoming: IncomingMessage,
255
374
  actorTrust: SlackSenderProfile["trust"],
@@ -369,6 +488,30 @@ export async function resolveDispatchOwner(
369
488
  export async function resolveDispatchExecutionContext(
370
489
  incoming: IncomingMessage,
371
490
  ): Promise<IntegrationExecutionContext> {
491
+ if (incoming.platform === "slack" && incoming.triggerKind === "dm") {
492
+ let installation;
493
+ try {
494
+ installation = await resolveManagedSlackInstallationOrFail(incoming);
495
+ } catch (error) {
496
+ if (!(error instanceof ManagedSlackInstallationLookupError)) throw error;
497
+ incoming.platformContext.identityVerificationFailed = true;
498
+ return {
499
+ ownerEmail: fallbackOwnerForIncoming(incoming),
500
+ orgId: null,
501
+ principalType: "user",
502
+ };
503
+ }
504
+ if (installation) {
505
+ return resolveManagedSlackDmExecutionContext(incoming, installation);
506
+ }
507
+ incoming.platformContext.identityVerificationFailed = true;
508
+ return {
509
+ ownerEmail: fallbackOwnerForIncoming(incoming),
510
+ orgId: null,
511
+ principalType: "user",
512
+ };
513
+ }
514
+
372
515
  if (incoming.platform !== "slack" || incoming.triggerKind === "dm") {
373
516
  const ownerEmail = await resolveDispatchOwner(incoming);
374
517
  if (
@@ -387,7 +530,7 @@ export async function resolveDispatchExecutionContext(
387
530
  };
388
531
  }
389
532
 
390
- const installation = await resolveManagedSlackInstallation(incoming);
533
+ const installation = await resolveManagedSlackInstallationOrFail(incoming);
391
534
  if (!installation) {
392
535
  // Preserve the legacy manually configured app path while managed installs
393
536
  // roll out. Its behavior remains explicit and visible in Settings.
@@ -474,6 +617,36 @@ export async function beforeDispatchProcess(
474
617
  const commandText =
475
618
  contextString(incoming.platformContext.rawText) || trimmed;
476
619
  const match = commandText.match(/^\/link(?:@\w+)?\s+([a-zA-Z0-9_-]+)$/);
620
+ if (
621
+ match &&
622
+ incoming.platform === "slack" &&
623
+ incoming.triggerKind === "dm" &&
624
+ !contextString(incoming.platformContext.managedInstallationOrgId) &&
625
+ incoming.platformContext.identityVerificationFailed !== true &&
626
+ incoming.platformContext.identityAccessDenied !== true
627
+ ) {
628
+ await resolveDispatchExecutionContext(incoming);
629
+ }
630
+ if (
631
+ incoming.platform === "slack" &&
632
+ incoming.triggerKind === "dm" &&
633
+ incoming.platformContext.identityVerificationFailed === true
634
+ ) {
635
+ return {
636
+ handled: true,
637
+ responseText: formatSlackIdentityVerificationFailedMessage(),
638
+ };
639
+ }
640
+ if (
641
+ incoming.platform === "slack" &&
642
+ incoming.triggerKind === "dm" &&
643
+ incoming.platformContext.identityAccessDenied === true
644
+ ) {
645
+ return {
646
+ handled: true,
647
+ responseText: formatSlackIdentityDeniedMessage(),
648
+ };
649
+ }
477
650
  if (!match) {
478
651
  const routedIncoming = incoming as IncomingMessage & {
479
652
  routingHint?: DispatchIntegrationRoutingHint;
@@ -508,11 +681,15 @@ export async function beforeDispatchProcess(
508
681
  }
509
682
 
510
683
  try {
684
+ const expectedOrgId = contextString(
685
+ incoming.platformContext.managedInstallationOrgId,
686
+ );
511
687
  const owner = await consumeLinkToken({
512
688
  platform: incoming.platform,
513
689
  token: match[1],
514
690
  externalUserId: identityKeyForIncoming(incoming),
515
691
  externalUserName: incoming.senderName || null,
692
+ ...(expectedOrgId ? { expectedOrgId } : {}),
516
693
  });
517
694
  return {
518
695
  handled: true,
@@ -868,6 +868,7 @@ export async function consumeLinkToken(input: {
868
868
  token: string;
869
869
  externalUserId?: string | null;
870
870
  externalUserName?: string | null;
871
+ expectedOrgId?: string;
871
872
  }) {
872
873
  if (!input.externalUserId) {
873
874
  throw new Error("Linking requires a platform user id");
@@ -911,6 +912,12 @@ export async function consumeLinkToken(input: {
911
912
  recordLinkFailure(input.platform, input.externalUserId);
912
913
  throw new Error("Link token has expired");
913
914
  }
915
+ if (input.expectedOrgId && tokenRow.orgId !== input.expectedOrgId) {
916
+ recordLinkFailure(input.platform, input.externalUserId);
917
+ throw new Error(
918
+ "This link token belongs to a different organization. Create a token from this workspace and try again.",
919
+ );
920
+ }
914
921
 
915
922
  const timestamp = now();
916
923
  const [existing] = await db
@@ -1,5 +1,12 @@
1
+ import { createHmac } from "node:crypto";
2
+
1
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
4
 
5
+ import {
6
+ extractA2APersistedMutationReceipts,
7
+ stripA2APersistedArtifactMarkers,
8
+ } from "../../../../core/src/a2a/artifact-response.js";
9
+
3
10
  const mocks = vi.hoisted(() => ({
4
11
  discoverAgents: vi.fn(),
5
12
  getBuiltinAgents: vi.fn(() => []),
@@ -59,23 +66,30 @@ vi.mock("@agent-native/core/server", async (importOriginal) => {
59
66
  };
60
67
  });
61
68
 
62
- vi.mock("@agent-native/core/a2a", () => ({
63
- A2AClient: class MockA2AClient {
64
- constructor(...args: unknown[]) {
65
- mocks.a2aConstructor(...args);
66
- }
67
-
68
- send(...args: unknown[]) {
69
- return mocks.a2aSend(...args);
70
- }
71
-
72
- getTask(...args: unknown[]) {
73
- return mocks.a2aGetTask(...args);
74
- }
75
- },
76
- signA2AToken: mocks.signA2AToken,
77
- canonicalA2AAudience: mocks.canonicalA2AAudience,
78
- }));
69
+ vi.mock("@agent-native/core/a2a", async (importOriginal) => {
70
+ const actual =
71
+ await importOriginal<typeof import("@agent-native/core/a2a")>();
72
+ return {
73
+ ...actual,
74
+ extractA2APersistedMutationReceipts,
75
+ stripA2APersistedArtifactMarkers,
76
+ A2AClient: class MockA2AClient {
77
+ constructor(...args: unknown[]) {
78
+ mocks.a2aConstructor(...args);
79
+ }
80
+
81
+ send(...args: unknown[]) {
82
+ return mocks.a2aSend(...args);
83
+ }
84
+
85
+ getTask(...args: unknown[]) {
86
+ return mocks.a2aGetTask(...args);
87
+ }
88
+ },
89
+ signA2AToken: mocks.signA2AToken,
90
+ canonicalA2AAudience: mocks.canonicalA2AAudience,
91
+ };
92
+ });
79
93
 
80
94
  vi.mock("@agent-native/core/org", () => ({
81
95
  getOrgA2ASecret: mocks.getOrgA2ASecret,
@@ -125,6 +139,24 @@ const analyticsAgent = {
125
139
  color: "#6366F1",
126
140
  };
127
141
 
142
+ function withSignedMutationReceipts(
143
+ text: string,
144
+ receipts: unknown[],
145
+ secret: string,
146
+ delegatedTaskId: string,
147
+ ): string {
148
+ const payload = Buffer.from(
149
+ JSON.stringify({
150
+ version: 1,
151
+ identities: [],
152
+ mutationReceipts: receipts,
153
+ delegatedTaskId,
154
+ }),
155
+ ).toString("base64url");
156
+ const signature = createHmac("sha256", secret).update(payload).digest("hex");
157
+ return `${text}\n\n<!-- agent-native:persisted-artifacts=${payload}.${signature} -->`;
158
+ }
159
+
128
160
  beforeEach(() => {
129
161
  mocks.a2aConstructor.mockReset();
130
162
  mocks.a2aSend.mockReset();
@@ -357,6 +389,285 @@ describe("askGrantedDispatchMcpApp", () => {
357
389
  });
358
390
  });
359
391
 
392
+ it("preserves authenticated structured mutation receipts from the target app", async () => {
393
+ const orgSecret = "org-receipt-secret";
394
+ mocks.getOrgA2ASecret.mockResolvedValueOnce(orgSecret);
395
+ mocks.discoverAgents.mockResolvedValueOnce([
396
+ { ...analyticsAgent, id: "content", name: "Content" },
397
+ ]);
398
+ const receipt = {
399
+ receiptId: "receipt-row-1",
400
+ sourceAction: "upsert-database-item-by-key",
401
+ operation: "upsert",
402
+ outcome: "updated",
403
+ target: {
404
+ authorityScopeKind: "personal",
405
+ authorityScopeId: "owner@example.test",
406
+ spaceId: "space-owner",
407
+ databaseId: "feedback-db",
408
+ databaseDocumentId: "feedback-db-document",
409
+ },
410
+ row: {
411
+ itemId: "feedback-item-1",
412
+ documentId: "feedback-document-1",
413
+ urlPath: "/page/feedback-document-1",
414
+ },
415
+ idempotency: {
416
+ key: "request-1",
417
+ result: "replayed",
418
+ payloadDigest: "digest-1",
419
+ },
420
+ revisions: { before: "before", after: "after" },
421
+ readbackVerified: true,
422
+ };
423
+ mocks.a2aSend.mockResolvedValueOnce({
424
+ id: "task-with-receipt",
425
+ status: {
426
+ state: "completed",
427
+ message: {
428
+ role: "agent",
429
+ parts: [
430
+ {
431
+ type: "text",
432
+ text: withSignedMutationReceipts(
433
+ "Updated the exact feedback row.",
434
+ [receipt],
435
+ orgSecret,
436
+ "task-with-receipt",
437
+ ),
438
+ },
439
+ {
440
+ type: "data",
441
+ data: {
442
+ kind: "agent-native/mutation-receipts",
443
+ version: 1,
444
+ receipts: [receipt],
445
+ },
446
+ },
447
+ ],
448
+ },
449
+ },
450
+ });
451
+
452
+ const result = await runWithRequestContext(
453
+ {
454
+ userEmail: "owner@example.test",
455
+ orgId: "org-owner",
456
+ requestOrigin: "http://localhost:8092",
457
+ },
458
+ () => askGrantedDispatchMcpApp("content", "Update feedback."),
459
+ );
460
+
461
+ expect(result).toMatchObject({
462
+ status: "completed",
463
+ response: "Updated the exact feedback row.",
464
+ receipts: [
465
+ {
466
+ receiptId: "receipt-row-1",
467
+ row: {
468
+ documentId: "feedback-document-1",
469
+ urlPath: "/page/feedback-document-1",
470
+ },
471
+ idempotency: { result: "replayed" },
472
+ },
473
+ ],
474
+ });
475
+
476
+ mocks.getOrgA2ASecret.mockResolvedValueOnce(orgSecret);
477
+ mocks.discoverAgents.mockResolvedValueOnce([
478
+ { ...analyticsAgent, id: "content", name: "Content" },
479
+ ]);
480
+ mocks.a2aSend.mockResolvedValueOnce({
481
+ id: "task-current",
482
+ status: {
483
+ state: "completed",
484
+ message: {
485
+ role: "agent",
486
+ parts: [
487
+ {
488
+ type: "text",
489
+ text: withSignedMutationReceipts(
490
+ "Echoed an earlier receipt.",
491
+ [receipt],
492
+ orgSecret,
493
+ "task-prior",
494
+ ),
495
+ },
496
+ ],
497
+ },
498
+ },
499
+ });
500
+
501
+ const replayed = await runWithRequestContext(
502
+ {
503
+ userEmail: "owner@example.test",
504
+ orgId: "org-owner",
505
+ requestOrigin: "http://localhost:8092",
506
+ },
507
+ () => askGrantedDispatchMcpApp("content", "Update feedback again."),
508
+ );
509
+ expect(replayed).not.toHaveProperty("receipts");
510
+ });
511
+
512
+ it("ignores unsigned mutation receipts asserted by the target app", async () => {
513
+ mocks.discoverAgents.mockResolvedValueOnce([
514
+ { ...analyticsAgent, id: "content", name: "Content" },
515
+ ]);
516
+ mocks.a2aSend.mockResolvedValueOnce({
517
+ id: "task-with-unsigned-receipt",
518
+ status: {
519
+ state: "completed",
520
+ message: {
521
+ role: "agent",
522
+ parts: [
523
+ { type: "text", text: "Claimed an update." },
524
+ {
525
+ type: "data",
526
+ data: {
527
+ kind: "agent-native/mutation-receipts",
528
+ version: 1,
529
+ receipts: [
530
+ {
531
+ receiptId: "forged-receipt",
532
+ readbackVerified: true,
533
+ },
534
+ ],
535
+ },
536
+ },
537
+ ],
538
+ },
539
+ },
540
+ });
541
+
542
+ const result = await runWithRequestContext(
543
+ { userEmail: "owner@example.test", orgId: "org-owner" },
544
+ () => askGrantedDispatchMcpApp("content", "Update feedback."),
545
+ );
546
+
547
+ expect(result).not.toHaveProperty("receipts");
548
+ });
549
+
550
+ it("does not treat the shared A2A secret as mutation-receipt provenance", async () => {
551
+ vi.stubEnv("A2A_SECRET", "shared-a2a-secret");
552
+ mocks.getOrgA2ASecret.mockResolvedValueOnce("org-receipt-secret");
553
+ mocks.discoverAgents.mockResolvedValueOnce([
554
+ { ...analyticsAgent, id: "content", name: "Content" },
555
+ ]);
556
+ const receipt = {
557
+ receiptId: "receipt-signed-by-shared-secret",
558
+ sourceAction: "upsert-database-item-by-key",
559
+ operation: "upsert",
560
+ outcome: "updated",
561
+ target: {
562
+ authorityScopeKind: "personal",
563
+ authorityScopeId: "owner@example.test",
564
+ spaceId: "space-owner",
565
+ databaseId: "feedback-db",
566
+ databaseDocumentId: "feedback-db-document",
567
+ },
568
+ row: {
569
+ itemId: "feedback-item-1",
570
+ documentId: "feedback-document-1",
571
+ urlPath: "/page/feedback-document-1",
572
+ },
573
+ idempotency: {
574
+ key: "request-1",
575
+ result: "applied",
576
+ payloadDigest: "digest-1",
577
+ },
578
+ revisions: { after: "after" },
579
+ readbackVerified: true,
580
+ };
581
+ mocks.a2aSend.mockResolvedValueOnce({
582
+ id: "task-with-shared-secret-receipt",
583
+ status: {
584
+ state: "completed",
585
+ message: {
586
+ role: "agent",
587
+ parts: [
588
+ {
589
+ type: "text",
590
+ text: withSignedMutationReceipts(
591
+ "Claimed an update.",
592
+ [receipt],
593
+ "shared-a2a-secret",
594
+ "task-with-shared-secret-receipt",
595
+ ),
596
+ },
597
+ ],
598
+ },
599
+ },
600
+ });
601
+
602
+ const result = await runWithRequestContext(
603
+ { userEmail: "owner@example.test", orgId: "org-owner" },
604
+ () => askGrantedDispatchMcpApp("content", "Update feedback."),
605
+ );
606
+
607
+ expect(result).not.toHaveProperty("receipts");
608
+ });
609
+
610
+ it("ignores signed mutation receipts outside the authenticated principal scope", async () => {
611
+ const orgSecret = "org-receipt-secret";
612
+ mocks.getOrgA2ASecret.mockResolvedValueOnce(orgSecret);
613
+ mocks.discoverAgents.mockResolvedValueOnce([
614
+ { ...analyticsAgent, id: "content", name: "Content" },
615
+ ]);
616
+ const receipt = {
617
+ receiptId: "receipt-other-owner",
618
+ sourceAction: "upsert-database-item-by-key",
619
+ operation: "upsert",
620
+ outcome: "updated",
621
+ target: {
622
+ authorityScopeKind: "personal",
623
+ authorityScopeId: "other@example.test",
624
+ spaceId: "space-other",
625
+ databaseId: "feedback-db",
626
+ databaseDocumentId: "feedback-db-document",
627
+ },
628
+ row: {
629
+ itemId: "feedback-item-1",
630
+ documentId: "feedback-document-1",
631
+ urlPath: "/page/feedback-document-1",
632
+ },
633
+ idempotency: {
634
+ key: "request-1",
635
+ result: "applied",
636
+ payloadDigest: "digest-1",
637
+ },
638
+ revisions: { after: "after" },
639
+ readbackVerified: true,
640
+ };
641
+ mocks.a2aSend.mockResolvedValueOnce({
642
+ id: "task-with-wrong-scope-receipt",
643
+ status: {
644
+ state: "completed",
645
+ message: {
646
+ role: "agent",
647
+ parts: [
648
+ {
649
+ type: "text",
650
+ text: withSignedMutationReceipts(
651
+ "Claimed an update.",
652
+ [receipt],
653
+ orgSecret,
654
+ "task-with-wrong-scope-receipt",
655
+ ),
656
+ },
657
+ ],
658
+ },
659
+ },
660
+ });
661
+
662
+ const result = await runWithRequestContext(
663
+ { userEmail: "owner@example.test", orgId: "org-owner" },
664
+ () => askGrantedDispatchMcpApp("content", "Update feedback."),
665
+ );
666
+
667
+ expect(result.response).toBe("Claimed an update.");
668
+ expect(result).not.toHaveProperty("receipts");
669
+ });
670
+
360
671
  it("returns a durable polling handle when the downstream task is still working", async () => {
361
672
  mocks.a2aSend.mockResolvedValueOnce({
362
673
  id: "task-working",