@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.
- package/dist/actions/ask_app.d.ts +1 -0
- package/dist/actions/ask_app_status.d.ts +1 -0
- package/dist/routes/pages/$appId.d.ts.map +1 -1
- package/dist/routes/pages/$appId.js +4 -3
- package/dist/routes/pages/$appId.js.map +1 -1
- package/dist/routes/pages/_index.d.ts.map +1 -1
- package/dist/routes/pages/_index.js +7 -2
- package/dist/routes/pages/_index.js.map +1 -1
- package/dist/routes/pages/_index.test.d.ts +2 -0
- package/dist/routes/pages/_index.test.d.ts.map +1 -0
- package/dist/routes/pages/_index.test.js +29 -0
- package/dist/routes/pages/_index.test.js.map +1 -0
- package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
- package/dist/server/lib/dispatch-integrations.js +149 -7
- package/dist/server/lib/dispatch-integrations.js.map +1 -1
- package/dist/server/lib/dispatch-store.d.ts +1 -0
- package/dist/server/lib/dispatch-store.d.ts.map +1 -1
- package/dist/server/lib/dispatch-store.js +4 -0
- package/dist/server/lib/dispatch-store.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts +8 -2
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +39 -6
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/package.json +2 -2
- package/src/routes/pages/$appId.tsx +4 -3
- package/src/routes/pages/_index.test.ts +34 -0
- package/src/routes/pages/_index.tsx +7 -2
- package/src/server/lib/dispatch-integrations.spec.ts +578 -7
- package/src/server/lib/dispatch-integrations.ts +186 -9
- package/src/server/lib/dispatch-store.ts +7 -0
- package/src/server/lib/mcp-gateway.spec.ts +328 -17
- 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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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",
|