@agent-native/core 0.98.5 → 0.98.6
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/docs/content/external-agents.mdx +62 -7
- package/corpus/core/docs/content/locales/ar-SA/external-agents.mdx +38 -7
- package/corpus/core/docs/content/locales/de-DE/external-agents.mdx +39 -7
- package/corpus/core/docs/content/locales/es-ES/external-agents.mdx +39 -7
- package/corpus/core/docs/content/locales/fr-FR/external-agents.mdx +39 -7
- package/corpus/core/docs/content/locales/hi-IN/external-agents.mdx +38 -7
- package/corpus/core/docs/content/locales/ja-JP/external-agents.mdx +38 -7
- package/corpus/core/docs/content/locales/ko-KR/external-agents.mdx +38 -7
- package/corpus/core/docs/content/locales/pt-BR/external-agents.mdx +39 -7
- package/corpus/core/docs/content/locales/zh-CN/external-agents.mdx +37 -7
- package/corpus/core/docs/content/locales/zh-TW/external-agents.mdx +37 -7
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/handlers.ts +95 -21
- package/corpus/core/src/a2a/task-store.ts +65 -3
- package/corpus/core/src/integrations/adapters/slack.ts +123 -13
- package/corpus/core/src/integrations/identity-links-store.ts +210 -0
- package/corpus/core/src/integrations/identity.ts +132 -0
- package/corpus/core/src/integrations/index.ts +6 -0
- package/corpus/core/src/integrations/plugin.ts +58 -1
- package/corpus/core/src/integrations/types.ts +7 -0
- package/corpus/core/src/mcp/build-server.ts +127 -22
- package/corpus/core/src/mcp/external-agent-policy.ts +18 -0
- package/corpus/core/src/mcp/index.ts +1 -0
- package/corpus/core/src/server/agent-chat/plugin-options.ts +12 -1
- package/corpus/core/src/server/agent-chat/script-entries.ts +20 -3
- package/corpus/core/src/server/agent-chat-plugin.ts +3 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
- package/corpus/templates/analytics/AGENTS.md +20 -5
- package/corpus/templates/analytics/actions/create-session-replay-agent-link.ts +0 -1
- package/corpus/templates/analytics/actions/get-session-replay-events.ts +0 -1
- package/corpus/templates/analytics/actions/get-session-replay-timeline.ts +40 -0
- package/corpus/templates/analytics/actions/list-error-issues.ts +12 -0
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +4 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +48 -17
- package/corpus/templates/analytics/changelog/2026-07-12-connected-external-agents-can-now-look-up-sessions-error-iss.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-12-session-identities-stay-visible-when-demo-mode-is-off.md +6 -0
- package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +2 -2
- package/corpus/templates/analytics/server/lib/error-capture.ts +47 -5
- package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +49 -17
- package/corpus/templates/analytics/server/lib/session-replay.ts +16 -7
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +4 -0
- package/corpus/templates/analytics/server/plugins/db.ts +15 -0
- package/corpus/templates/forms/app/global.css +54 -8
- package/corpus/templates/forms/app/pages/AskPage.tsx +35 -13
- package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +11 -17
- package/corpus/templates/forms/app/pages/ResponsesPage.tsx +8 -5
- package/corpus/templates/forms/changelog/2026-07-12-ask-forms-suggestions-now-sit-beneath-the-composer-for-a-tig.md +6 -0
- package/corpus/templates/forms/changelog/2026-07-12-builder-response-tables-now-fill-the-view-without-a-redundan.md +6 -0
- package/corpus/templates/forms/changelog/2026-07-12-response-tables-now-fill-the-available-pane-with-a-flexible-.md +6 -0
- package/corpus/templates/mail/AGENTS.md +3 -1
- package/corpus/templates/mail/server/lib/mail-integrations.ts +3 -0
- package/dist/a2a/handlers.d.ts.map +1 -1
- package/dist/a2a/handlers.js +84 -15
- package/dist/a2a/handlers.js.map +1 -1
- package/dist/a2a/task-store.d.ts +20 -1
- package/dist/a2a/task-store.d.ts.map +1 -1
- package/dist/a2a/task-store.js +57 -4
- package/dist/a2a/task-store.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/adapters/slack.d.ts.map +1 -1
- package/dist/integrations/adapters/slack.js +98 -13
- package/dist/integrations/adapters/slack.js.map +1 -1
- package/dist/integrations/identity-links-store.d.ts +23 -0
- package/dist/integrations/identity-links-store.d.ts.map +1 -0
- package/dist/integrations/identity-links-store.js +158 -0
- package/dist/integrations/identity-links-store.js.map +1 -0
- package/dist/integrations/identity.d.ts +11 -0
- package/dist/integrations/identity.d.ts.map +1 -0
- package/dist/integrations/identity.js +100 -0
- package/dist/integrations/identity.js.map +1 -0
- package/dist/integrations/index.d.ts +2 -0
- package/dist/integrations/index.d.ts.map +1 -1
- package/dist/integrations/index.js +2 -0
- package/dist/integrations/index.js.map +1 -1
- package/dist/integrations/plugin.d.ts.map +1 -1
- package/dist/integrations/plugin.js +43 -1
- package/dist/integrations/plugin.js.map +1 -1
- package/dist/integrations/types.d.ts +6 -0
- package/dist/integrations/types.d.ts.map +1 -1
- package/dist/integrations/types.js.map +1 -1
- package/dist/mcp/build-server.d.ts +9 -2
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +97 -20
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/external-agent-policy.d.ts +19 -0
- package/dist/mcp/external-agent-policy.d.ts.map +1 -0
- package/dist/mcp/external-agent-policy.js +2 -0
- package/dist/mcp/external-agent-policy.js.map +1 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-chat/plugin-options.d.ts +11 -1
- package/dist/server/agent-chat/plugin-options.d.ts.map +1 -1
- package/dist/server/agent-chat/plugin-options.js.map +1 -1
- package/dist/server/agent-chat/script-entries.d.ts.map +1 -1
- package/dist/server/agent-chat/script-entries.js +16 -2
- package/dist/server/agent-chat/script-entries.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +3 -0
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
- package/docs/content/external-agents.mdx +62 -7
- package/docs/content/locales/ar-SA/external-agents.mdx +38 -7
- package/docs/content/locales/de-DE/external-agents.mdx +39 -7
- package/docs/content/locales/es-ES/external-agents.mdx +39 -7
- package/docs/content/locales/fr-FR/external-agents.mdx +39 -7
- package/docs/content/locales/hi-IN/external-agents.mdx +38 -7
- package/docs/content/locales/ja-JP/external-agents.mdx +38 -7
- package/docs/content/locales/ko-KR/external-agents.mdx +38 -7
- package/docs/content/locales/pt-BR/external-agents.mdx +39 -7
- package/docs/content/locales/zh-CN/external-agents.mdx +37 -7
- package/docs/content/locales/zh-TW/external-agents.mdx +37 -7
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
|
@@ -200,11 +200,12 @@ export async function getA2ATaskDispatchState(id: string): Promise<{
|
|
|
200
200
|
statusState: string;
|
|
201
201
|
metadata: Record<string, unknown> | undefined;
|
|
202
202
|
updatedAt: number;
|
|
203
|
+
createdAt: number;
|
|
203
204
|
} | null> {
|
|
204
205
|
await ensureTable();
|
|
205
206
|
const client = getDbExec();
|
|
206
207
|
const { rows } = await client.execute({
|
|
207
|
-
sql: `SELECT id, status_state, metadata, updated_at FROM a2a_tasks WHERE id = ?`,
|
|
208
|
+
sql: `SELECT id, status_state, metadata, created_at, updated_at FROM a2a_tasks WHERE id = ?`,
|
|
208
209
|
args: [id],
|
|
209
210
|
});
|
|
210
211
|
const row = rows[0] as any;
|
|
@@ -214,6 +215,7 @@ export async function getA2ATaskDispatchState(id: string): Promise<{
|
|
|
214
215
|
statusState: row.status_state as string,
|
|
215
216
|
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
|
216
217
|
updatedAt: Number(row.updated_at ?? 0),
|
|
218
|
+
createdAt: Number(row.created_at ?? 0),
|
|
217
219
|
};
|
|
218
220
|
}
|
|
219
221
|
|
|
@@ -269,10 +271,21 @@ export async function resetStuckA2ATaskForRetry(
|
|
|
269
271
|
return affected !== 0;
|
|
270
272
|
}
|
|
271
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Fail a processing task once it is stuck. Two independent conditions can
|
|
276
|
+
* trigger this, either of which alone is sufficient:
|
|
277
|
+
* - `updated_at <= processingCutoff`: no heartbeat/progress touch in a
|
|
278
|
+
* while — the processor likely died.
|
|
279
|
+
* - `created_at <= createdAtCutoff` — a hard wall on total run time. A
|
|
280
|
+
* hung await inside a still-alive process keeps `updated_at` fresh via
|
|
281
|
+
* the liveness heartbeat forever, so staleness alone never trips; age
|
|
282
|
+
* since creation is the only bound that catches it.
|
|
283
|
+
*/
|
|
272
284
|
export async function failStuckA2ATask(
|
|
273
285
|
id: string,
|
|
274
286
|
processingCutoff: number,
|
|
275
287
|
reason: string,
|
|
288
|
+
createdAtCutoff?: number,
|
|
276
289
|
): Promise<boolean> {
|
|
277
290
|
await ensureTable();
|
|
278
291
|
const client = getDbExec();
|
|
@@ -282,6 +295,10 @@ export async function failStuckA2ATask(
|
|
|
282
295
|
role: "agent",
|
|
283
296
|
parts: [{ type: "text", text: reason }],
|
|
284
297
|
};
|
|
298
|
+
const ageCondition =
|
|
299
|
+
createdAtCutoff !== undefined
|
|
300
|
+
? `AND (updated_at <= ? OR created_at <= ?)`
|
|
301
|
+
: `AND updated_at <= ?`;
|
|
285
302
|
const result = await client.execute({
|
|
286
303
|
sql: `UPDATE a2a_tasks
|
|
287
304
|
SET status_state = 'failed',
|
|
@@ -290,8 +307,53 @@ export async function failStuckA2ATask(
|
|
|
290
307
|
updated_at = ?
|
|
291
308
|
WHERE id = ?
|
|
292
309
|
AND status_state = 'processing'
|
|
293
|
-
|
|
294
|
-
args:
|
|
310
|
+
${ageCondition}`,
|
|
311
|
+
args:
|
|
312
|
+
createdAtCutoff !== undefined
|
|
313
|
+
? [
|
|
314
|
+
JSON.stringify(message),
|
|
315
|
+
timestamp,
|
|
316
|
+
now,
|
|
317
|
+
id,
|
|
318
|
+
processingCutoff,
|
|
319
|
+
createdAtCutoff,
|
|
320
|
+
]
|
|
321
|
+
: [JSON.stringify(message), timestamp, now, id, processingCutoff],
|
|
322
|
+
});
|
|
323
|
+
const affected = getAffectedRowCount(result);
|
|
324
|
+
return affected !== 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Fail a queued (submitted/working) task whose age since creation exceeds
|
|
329
|
+
* `createdAtCutoff` — the dispatch-retry loop kept throttling/refiring
|
|
330
|
+
* without ever reaching `processing`. Mirrors `failStuckA2ATask` but is
|
|
331
|
+
* gated on the queued state set and on `created_at` (queued tasks have no
|
|
332
|
+
* heartbeat, so staleness of `updated_at` isn't a meaningful signal here).
|
|
333
|
+
*/
|
|
334
|
+
export async function failStuckQueuedA2ATask(
|
|
335
|
+
id: string,
|
|
336
|
+
createdAtCutoff: number,
|
|
337
|
+
reason: string,
|
|
338
|
+
): Promise<boolean> {
|
|
339
|
+
await ensureTable();
|
|
340
|
+
const client = getDbExec();
|
|
341
|
+
const now = Date.now();
|
|
342
|
+
const timestamp = new Date().toISOString();
|
|
343
|
+
const message: Message = {
|
|
344
|
+
role: "agent",
|
|
345
|
+
parts: [{ type: "text", text: reason }],
|
|
346
|
+
};
|
|
347
|
+
const result = await client.execute({
|
|
348
|
+
sql: `UPDATE a2a_tasks
|
|
349
|
+
SET status_state = 'failed',
|
|
350
|
+
status_message = ?,
|
|
351
|
+
status_timestamp = ?,
|
|
352
|
+
updated_at = ?
|
|
353
|
+
WHERE id = ?
|
|
354
|
+
AND status_state IN ('submitted', 'working')
|
|
355
|
+
AND created_at <= ?`,
|
|
356
|
+
args: [JSON.stringify(message), timestamp, now, id, createdAtCutoff],
|
|
295
357
|
});
|
|
296
358
|
const affected = getAffectedRowCount(result);
|
|
297
359
|
return affected !== 0;
|
|
@@ -38,6 +38,23 @@ const SLACK_PERMALINK_TIMEOUT_MS = 1_000;
|
|
|
38
38
|
const SLACK_CONTEXT_MESSAGE_LIMIT = 15;
|
|
39
39
|
const SLACK_CONTEXT_TEXT_LIMIT = 2_000;
|
|
40
40
|
const SLACK_CALM_PROGRESS_THRESHOLD_SECONDS = 30;
|
|
41
|
+
const SLACK_IDENTITY_TIMEOUT_MS = 1_000;
|
|
42
|
+
const SLACK_IDENTITY_CACHE_TTL_MS = 10 * 60 * 1_000;
|
|
43
|
+
// Failed users.info lookups only get a short negative TTL: a transient Slack
|
|
44
|
+
// API blip must not fail-close a sender's identity (and DMs) for 10 minutes.
|
|
45
|
+
const SLACK_IDENTITY_NEGATIVE_CACHE_TTL_MS = 30 * 1_000;
|
|
46
|
+
const SLACK_IDENTITY_CACHE_MAX_ENTRIES = 1_000;
|
|
47
|
+
|
|
48
|
+
interface SlackUserIdentity {
|
|
49
|
+
email: string | null;
|
|
50
|
+
name: string | null;
|
|
51
|
+
memberType: "owner" | "admin" | "member" | "guest" | "external" | "unknown";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const slackIdentityCache = new Map<
|
|
55
|
+
string,
|
|
56
|
+
{ identity: SlackUserIdentity | null; expiresAt: number }
|
|
57
|
+
>();
|
|
41
58
|
|
|
42
59
|
export interface SlackAdapterOptions {
|
|
43
60
|
/** Resolve the bot token for the exact Slack installation. */
|
|
@@ -356,6 +373,14 @@ export function slackAdapter(
|
|
|
356
373
|
return hydrateSlackContext(token, incoming);
|
|
357
374
|
},
|
|
358
375
|
|
|
376
|
+
async hydrateIncomingIdentity(
|
|
377
|
+
incoming: IncomingMessage,
|
|
378
|
+
): Promise<IncomingMessage> {
|
|
379
|
+
const token = await resolveBotToken(incoming);
|
|
380
|
+
if (!token) return incoming;
|
|
381
|
+
return hydrateSlackIdentity(token, incoming);
|
|
382
|
+
},
|
|
383
|
+
|
|
359
384
|
async startRunProgress(
|
|
360
385
|
incoming: IncomingMessage,
|
|
361
386
|
): Promise<PlatformRunProgress | null> {
|
|
@@ -1070,6 +1095,93 @@ async function slackJson(
|
|
|
1070
1095
|
}
|
|
1071
1096
|
}
|
|
1072
1097
|
|
|
1098
|
+
function slackIdentityCacheKey(incoming: IncomingMessage): string | null {
|
|
1099
|
+
const teamId = incoming.platformContext.teamId;
|
|
1100
|
+
const senderId = incoming.senderId;
|
|
1101
|
+
if (typeof senderId !== "string" || !senderId.trim()) return null;
|
|
1102
|
+
return `${typeof teamId === "string" && teamId ? teamId : "unknown"}:${senderId}`;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function resolveSlackUserIdentity(
|
|
1106
|
+
token: string,
|
|
1107
|
+
incoming: IncomingMessage,
|
|
1108
|
+
): Promise<SlackUserIdentity | null> {
|
|
1109
|
+
const cacheKey = slackIdentityCacheKey(incoming);
|
|
1110
|
+
if (!cacheKey) return null;
|
|
1111
|
+
const cached = slackIdentityCache.get(cacheKey);
|
|
1112
|
+
if (cached && cached.expiresAt > Date.now()) return cached.identity;
|
|
1113
|
+
if (cached) slackIdentityCache.delete(cacheKey);
|
|
1114
|
+
|
|
1115
|
+
const user = await slackJson(
|
|
1116
|
+
token,
|
|
1117
|
+
"users.info",
|
|
1118
|
+
{ user: incoming.senderId! },
|
|
1119
|
+
SLACK_IDENTITY_TIMEOUT_MS,
|
|
1120
|
+
);
|
|
1121
|
+
const profile = user?.user?.profile;
|
|
1122
|
+
const identity: SlackUserIdentity | null = user?.user
|
|
1123
|
+
? {
|
|
1124
|
+
email:
|
|
1125
|
+
typeof profile?.email === "string" && profile.email.trim()
|
|
1126
|
+
? profile.email.trim().toLowerCase()
|
|
1127
|
+
: null,
|
|
1128
|
+
name:
|
|
1129
|
+
typeof profile?.real_name === "string" && profile.real_name.trim()
|
|
1130
|
+
? profile.real_name.trim()
|
|
1131
|
+
: typeof profile?.display_name === "string" &&
|
|
1132
|
+
profile.display_name.trim()
|
|
1133
|
+
? profile.display_name.trim()
|
|
1134
|
+
: typeof user.user.real_name === "string" &&
|
|
1135
|
+
user.user.real_name.trim()
|
|
1136
|
+
? user.user.real_name.trim()
|
|
1137
|
+
: typeof user.user.name === "string" && user.user.name.trim()
|
|
1138
|
+
? user.user.name.trim()
|
|
1139
|
+
: null,
|
|
1140
|
+
memberType: user.user.is_ultra_restricted
|
|
1141
|
+
? "external"
|
|
1142
|
+
: user.user.is_restricted
|
|
1143
|
+
? "guest"
|
|
1144
|
+
: user.user.is_owner
|
|
1145
|
+
? "owner"
|
|
1146
|
+
: user.user.is_admin
|
|
1147
|
+
? "admin"
|
|
1148
|
+
: "member",
|
|
1149
|
+
}
|
|
1150
|
+
: null;
|
|
1151
|
+
if (slackIdentityCache.size >= SLACK_IDENTITY_CACHE_MAX_ENTRIES) {
|
|
1152
|
+
const oldestKey = slackIdentityCache.keys().next().value;
|
|
1153
|
+
if (oldestKey) slackIdentityCache.delete(oldestKey);
|
|
1154
|
+
}
|
|
1155
|
+
slackIdentityCache.set(cacheKey, {
|
|
1156
|
+
identity,
|
|
1157
|
+
expiresAt:
|
|
1158
|
+
Date.now() +
|
|
1159
|
+
(identity
|
|
1160
|
+
? SLACK_IDENTITY_CACHE_TTL_MS
|
|
1161
|
+
: SLACK_IDENTITY_NEGATIVE_CACHE_TTL_MS),
|
|
1162
|
+
});
|
|
1163
|
+
return identity;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
async function hydrateSlackIdentity(
|
|
1167
|
+
token: string,
|
|
1168
|
+
incoming: IncomingMessage,
|
|
1169
|
+
): Promise<IncomingMessage> {
|
|
1170
|
+
const identity = await resolveSlackUserIdentity(token, incoming);
|
|
1171
|
+
if (!identity) return incoming;
|
|
1172
|
+
return {
|
|
1173
|
+
...incoming,
|
|
1174
|
+
...(identity.name ? { senderName: identity.name } : {}),
|
|
1175
|
+
...(identity.email
|
|
1176
|
+
? { senderEmail: identity.email, senderVerified: true }
|
|
1177
|
+
: { senderVerified: false }),
|
|
1178
|
+
actorTrust: {
|
|
1179
|
+
memberType: identity.memberType,
|
|
1180
|
+
verified: true,
|
|
1181
|
+
},
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1073
1185
|
async function hydrateSlackContext(
|
|
1074
1186
|
token: string,
|
|
1075
1187
|
incoming: IncomingMessage,
|
|
@@ -1081,7 +1193,7 @@ async function hydrateSlackContext(
|
|
|
1081
1193
|
return incoming;
|
|
1082
1194
|
}
|
|
1083
1195
|
|
|
1084
|
-
const [thread, history, pins, conversation,
|
|
1196
|
+
const [thread, history, pins, conversation, identity] = await Promise.all([
|
|
1085
1197
|
slackJson(token, "conversations.replies", {
|
|
1086
1198
|
channel: channelId,
|
|
1087
1199
|
ts: threadTs,
|
|
@@ -1095,7 +1207,7 @@ async function hydrateSlackContext(
|
|
|
1095
1207
|
}),
|
|
1096
1208
|
slackJson(token, "pins.list", { channel: channelId }),
|
|
1097
1209
|
slackJson(token, "conversations.info", { channel: channelId }),
|
|
1098
|
-
senderId ?
|
|
1210
|
+
senderId ? resolveSlackUserIdentity(token, incoming) : null,
|
|
1099
1211
|
]);
|
|
1100
1212
|
|
|
1101
1213
|
// Agent View can attach the Slack surface the user is currently viewing to
|
|
@@ -1186,9 +1298,9 @@ async function hydrateSlackContext(
|
|
|
1186
1298
|
})
|
|
1187
1299
|
: [];
|
|
1188
1300
|
|
|
1189
|
-
const profile =
|
|
1190
|
-
const isGuest =
|
|
1191
|
-
const isExternal =
|
|
1301
|
+
const profile = identity;
|
|
1302
|
+
const isGuest = identity?.memberType === "guest";
|
|
1303
|
+
const isExternal = identity?.memberType === "external";
|
|
1192
1304
|
const isPrivate = conversation?.channel?.is_private === true;
|
|
1193
1305
|
const isExternalShared = conversation?.channel?.is_ext_shared === true;
|
|
1194
1306
|
const isMpim = conversation?.channel?.is_mpim === true;
|
|
@@ -1231,17 +1343,15 @@ async function hydrateSlackContext(
|
|
|
1231
1343
|
);
|
|
1232
1344
|
return {
|
|
1233
1345
|
...incoming,
|
|
1234
|
-
...(
|
|
1235
|
-
|
|
1236
|
-
: typeof profile?.real_name === "string" && profile.real_name
|
|
1237
|
-
? { senderName: profile.real_name }
|
|
1238
|
-
: {}),
|
|
1239
|
-
...(typeof profile?.email === "string"
|
|
1346
|
+
...(profile?.name ? { senderName: profile.name } : {}),
|
|
1347
|
+
...(profile?.email
|
|
1240
1348
|
? { senderEmail: profile.email, senderVerified: true }
|
|
1241
|
-
: {}),
|
|
1349
|
+
: { senderVerified: false }),
|
|
1242
1350
|
conversationType,
|
|
1243
1351
|
actorTrust: {
|
|
1244
|
-
memberType:
|
|
1352
|
+
memberType:
|
|
1353
|
+
identity?.memberType ??
|
|
1354
|
+
(isExternal ? "external" : isGuest ? "guest" : "member"),
|
|
1245
1355
|
verified: true,
|
|
1246
1356
|
},
|
|
1247
1357
|
contextMessages: hydratedMessages,
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable links between a verified provider identity and an Agent Native user.
|
|
3
|
+
* Provider credentials and raw provider payloads never belong in this table.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getDbExec,
|
|
10
|
+
intType,
|
|
11
|
+
isPostgres,
|
|
12
|
+
isUniqueViolation,
|
|
13
|
+
retryOnDdlRace,
|
|
14
|
+
} from "../db/client.js";
|
|
15
|
+
import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
|
|
16
|
+
|
|
17
|
+
const TABLE = "integration_identity_links";
|
|
18
|
+
let _initPromise: Promise<void> | undefined;
|
|
19
|
+
|
|
20
|
+
export interface IntegrationIdentityLink {
|
|
21
|
+
id: string;
|
|
22
|
+
platform: string;
|
|
23
|
+
tenantId: string;
|
|
24
|
+
externalUserId: string;
|
|
25
|
+
userEmail: string;
|
|
26
|
+
orgId: string;
|
|
27
|
+
createdAt: number;
|
|
28
|
+
updatedAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createSql(): string {
|
|
32
|
+
const integer = intType();
|
|
33
|
+
return `CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
platform TEXT NOT NULL,
|
|
36
|
+
tenant_id TEXT NOT NULL,
|
|
37
|
+
external_user_id TEXT NOT NULL,
|
|
38
|
+
user_email TEXT NOT NULL,
|
|
39
|
+
org_id TEXT NOT NULL,
|
|
40
|
+
created_at ${integer} NOT NULL,
|
|
41
|
+
updated_at ${integer} NOT NULL
|
|
42
|
+
)`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const INDEXES = [
|
|
46
|
+
[
|
|
47
|
+
"idx_integration_identity_links_external",
|
|
48
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_integration_identity_links_external
|
|
49
|
+
ON ${TABLE}(platform, tenant_id, external_user_id)`,
|
|
50
|
+
],
|
|
51
|
+
[
|
|
52
|
+
"idx_integration_identity_links_account",
|
|
53
|
+
`CREATE INDEX IF NOT EXISTS idx_integration_identity_links_account
|
|
54
|
+
ON ${TABLE}(org_id, user_email)`,
|
|
55
|
+
],
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
async function ensureTable(): Promise<void> {
|
|
59
|
+
if (!_initPromise) {
|
|
60
|
+
_initPromise = (async () => {
|
|
61
|
+
const client = getDbExec();
|
|
62
|
+
if (isPostgres()) {
|
|
63
|
+
await ensureTableExists(TABLE, createSql());
|
|
64
|
+
for (const [name, sql] of INDEXES) {
|
|
65
|
+
await ensureIndexExists(name, sql);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await retryOnDdlRace(() => client.execute(createSql()));
|
|
70
|
+
for (const [, sql] of INDEXES) {
|
|
71
|
+
await retryOnDdlRace(() => client.execute(sql));
|
|
72
|
+
}
|
|
73
|
+
})().catch((error) => {
|
|
74
|
+
_initPromise = undefined;
|
|
75
|
+
throw error;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return _initPromise;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function required(value: string, name: string): string {
|
|
82
|
+
const normalized = value.trim();
|
|
83
|
+
if (!normalized) throw new Error(`${name} is required`);
|
|
84
|
+
if (normalized.length > 512) throw new Error(`${name} is too long`);
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizePlatform(value: string): string {
|
|
89
|
+
return required(value, "platform").toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeEmail(value: string): string {
|
|
93
|
+
const normalized = required(value, "userEmail").toLowerCase();
|
|
94
|
+
if (!normalized.includes("@")) throw new Error("userEmail must be an email");
|
|
95
|
+
return normalized;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rowToLink(row: Record<string, unknown>): IntegrationIdentityLink {
|
|
99
|
+
return {
|
|
100
|
+
id: String(row.id),
|
|
101
|
+
platform: String(row.platform),
|
|
102
|
+
tenantId: String(row.tenant_id),
|
|
103
|
+
externalUserId: String(row.external_user_id),
|
|
104
|
+
userEmail: String(row.user_email),
|
|
105
|
+
orgId: String(row.org_id),
|
|
106
|
+
createdAt: Number(row.created_at ?? 0),
|
|
107
|
+
updatedAt: Number(row.updated_at ?? 0),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function selectByExternalIdentity(
|
|
112
|
+
platform: string,
|
|
113
|
+
tenantId: string,
|
|
114
|
+
externalUserId: string,
|
|
115
|
+
): Promise<IntegrationIdentityLink | null> {
|
|
116
|
+
await ensureTable();
|
|
117
|
+
const { rows } = await getDbExec().execute({
|
|
118
|
+
sql: `SELECT * FROM ${TABLE}
|
|
119
|
+
WHERE platform = ? AND tenant_id = ? AND external_user_id = ?
|
|
120
|
+
LIMIT 1`,
|
|
121
|
+
args: [platform, tenantId, externalUserId],
|
|
122
|
+
});
|
|
123
|
+
return rows[0] ? rowToLink(rows[0] as Record<string, unknown>) : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function matchesVerifiedLink(
|
|
127
|
+
row: IntegrationIdentityLink,
|
|
128
|
+
input: { userEmail: string; orgId: string },
|
|
129
|
+
): boolean {
|
|
130
|
+
return row.userEmail === input.userEmail && row.orgId === input.orgId;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Persist a verified mapping and fail closed if it changes identity later. */
|
|
134
|
+
export async function upsertVerifiedIntegrationIdentity(input: {
|
|
135
|
+
platform: string;
|
|
136
|
+
tenantId: string;
|
|
137
|
+
externalUserId: string;
|
|
138
|
+
userEmail: string;
|
|
139
|
+
orgId: string;
|
|
140
|
+
}): Promise<IntegrationIdentityLink> {
|
|
141
|
+
const platform = normalizePlatform(input.platform);
|
|
142
|
+
const tenantId = required(input.tenantId, "tenantId");
|
|
143
|
+
const externalUserId = required(input.externalUserId, "externalUserId");
|
|
144
|
+
const userEmail = normalizeEmail(input.userEmail);
|
|
145
|
+
const orgId = required(input.orgId, "orgId");
|
|
146
|
+
const existing = await selectByExternalIdentity(
|
|
147
|
+
platform,
|
|
148
|
+
tenantId,
|
|
149
|
+
externalUserId,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (existing) {
|
|
153
|
+
if (!matchesVerifiedLink(existing, { userEmail, orgId })) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"This provider identity is already linked to a different Agent Native account.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const updatedAt = Date.now();
|
|
159
|
+
await getDbExec().execute({
|
|
160
|
+
sql: `UPDATE ${TABLE} SET updated_at = ? WHERE id = ?`,
|
|
161
|
+
args: [updatedAt, existing.id],
|
|
162
|
+
});
|
|
163
|
+
return { ...existing, updatedAt };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const id = randomUUID();
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
try {
|
|
169
|
+
await getDbExec().execute({
|
|
170
|
+
sql: `INSERT INTO ${TABLE} (
|
|
171
|
+
id, platform, tenant_id, external_user_id, user_email, org_id,
|
|
172
|
+
created_at, updated_at
|
|
173
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
174
|
+
args: [
|
|
175
|
+
id,
|
|
176
|
+
platform,
|
|
177
|
+
tenantId,
|
|
178
|
+
externalUserId,
|
|
179
|
+
userEmail,
|
|
180
|
+
orgId,
|
|
181
|
+
now,
|
|
182
|
+
now,
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!isUniqueViolation(error)) throw error;
|
|
187
|
+
const raced = await selectByExternalIdentity(
|
|
188
|
+
platform,
|
|
189
|
+
tenantId,
|
|
190
|
+
externalUserId,
|
|
191
|
+
);
|
|
192
|
+
if (!raced || !matchesVerifiedLink(raced, { userEmail, orgId })) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"This provider identity is already linked to a different Agent Native account.",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return raced;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
id,
|
|
202
|
+
platform,
|
|
203
|
+
tenantId,
|
|
204
|
+
externalUserId,
|
|
205
|
+
userEmail,
|
|
206
|
+
orgId,
|
|
207
|
+
createdAt: now,
|
|
208
|
+
updatedAt: now,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { getDbExec } from "../db/client.js";
|
|
2
|
+
import { upsertVerifiedIntegrationIdentity } from "./identity-links-store.js";
|
|
3
|
+
import {
|
|
4
|
+
getActiveIntegrationInstallationByKey,
|
|
5
|
+
getActiveIntegrationInstallationForTenant,
|
|
6
|
+
} from "./installations-store.js";
|
|
7
|
+
import { slackInstallationKey } from "./slack-oauth.js";
|
|
8
|
+
import type { IncomingMessage, IntegrationExecutionContext } from "./types.js";
|
|
9
|
+
|
|
10
|
+
function serviceOwner(platform: string): string {
|
|
11
|
+
return `integration@${platform}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizedEmail(value: unknown): string | null {
|
|
15
|
+
if (typeof value !== "string") return null;
|
|
16
|
+
const email = value.trim().toLowerCase();
|
|
17
|
+
if (!email || email.length > 320 || !email.includes("@")) return null;
|
|
18
|
+
return email;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function resolveSlackInstallation(incoming: IncomingMessage) {
|
|
22
|
+
const teamId =
|
|
23
|
+
typeof incoming.platformContext.teamId === "string"
|
|
24
|
+
? incoming.platformContext.teamId
|
|
25
|
+
: incoming.tenantId;
|
|
26
|
+
const enterpriseId =
|
|
27
|
+
typeof incoming.platformContext.enterpriseId === "string"
|
|
28
|
+
? incoming.platformContext.enterpriseId
|
|
29
|
+
: undefined;
|
|
30
|
+
const apiAppId =
|
|
31
|
+
typeof incoming.platformContext.apiAppId === "string"
|
|
32
|
+
? incoming.platformContext.apiAppId
|
|
33
|
+
: undefined;
|
|
34
|
+
if (apiAppId && (teamId || enterpriseId)) {
|
|
35
|
+
const byKey = await getActiveIntegrationInstallationByKey(
|
|
36
|
+
"slack",
|
|
37
|
+
slackInstallationKey({ teamId, enterpriseId, apiAppId }),
|
|
38
|
+
);
|
|
39
|
+
if (byKey) return byKey;
|
|
40
|
+
}
|
|
41
|
+
return teamId || enterpriseId
|
|
42
|
+
? getActiveIntegrationInstallationForTenant(
|
|
43
|
+
"slack",
|
|
44
|
+
teamId ?? enterpriseId!,
|
|
45
|
+
)
|
|
46
|
+
: null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function isMemberOfOrg(email: string, orgId: string): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const { rows } = await getDbExec().execute({
|
|
52
|
+
sql: `SELECT 1 FROM org_members
|
|
53
|
+
WHERE org_id = ? AND LOWER(email) = ?
|
|
54
|
+
LIMIT 1`,
|
|
55
|
+
args: [orgId, email],
|
|
56
|
+
});
|
|
57
|
+
return rows.length > 0;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the default integration principal.
|
|
65
|
+
*
|
|
66
|
+
* Slack DMs become a user principal only after the adapter has verified the
|
|
67
|
+
* sender email and the email is already a member of the managed installation's
|
|
68
|
+
* Agent Native organization. Shared channels deliberately stay service-scoped
|
|
69
|
+
* so a channel message cannot borrow one participant's private permissions.
|
|
70
|
+
*/
|
|
71
|
+
export async function resolveDefaultIntegrationExecutionContext(
|
|
72
|
+
incoming: IncomingMessage,
|
|
73
|
+
): Promise<IntegrationExecutionContext> {
|
|
74
|
+
const installation =
|
|
75
|
+
incoming.platform === "slack"
|
|
76
|
+
? await resolveSlackInstallation(incoming)
|
|
77
|
+
: null;
|
|
78
|
+
|
|
79
|
+
if (incoming.platform !== "slack" || incoming.conversationType !== "dm") {
|
|
80
|
+
return {
|
|
81
|
+
ownerEmail: serviceOwner(incoming.platform),
|
|
82
|
+
orgId: installation?.orgId ?? null,
|
|
83
|
+
principalType: "service",
|
|
84
|
+
...(installation?.id ? { installationId: installation.id } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const email = normalizedEmail(incoming.senderEmail);
|
|
89
|
+
if (
|
|
90
|
+
!email ||
|
|
91
|
+
incoming.senderVerified !== true ||
|
|
92
|
+
!incoming.senderId ||
|
|
93
|
+
!incoming.tenantId
|
|
94
|
+
) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"Slack DM sender identity could not be verified; refusing to run with a service principal.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
incoming.actorTrust?.memberType === "guest" ||
|
|
101
|
+
incoming.actorTrust?.memberType === "external"
|
|
102
|
+
) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"External or guest Slack members cannot use personal Agent Native permissions.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (!installation?.orgId) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"Slack workspace is not connected to an Agent Native organization.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!(await isMemberOfOrg(email, installation.orgId))) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"Slack DM sender is not a member of the connected Agent Native organization.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const link = await upsertVerifiedIntegrationIdentity({
|
|
120
|
+
platform: incoming.platform,
|
|
121
|
+
tenantId: incoming.tenantId,
|
|
122
|
+
externalUserId: incoming.senderId,
|
|
123
|
+
userEmail: email,
|
|
124
|
+
orgId: installation.orgId,
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
ownerEmail: link.userEmail,
|
|
128
|
+
orgId: link.orgId,
|
|
129
|
+
principalType: "user",
|
|
130
|
+
installationId: installation.id,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -18,6 +18,12 @@ export type {
|
|
|
18
18
|
} from "./types.js";
|
|
19
19
|
export { assertPlatformCapability } from "./types.js";
|
|
20
20
|
|
|
21
|
+
export { resolveDefaultIntegrationExecutionContext } from "./identity.js";
|
|
22
|
+
export {
|
|
23
|
+
upsertVerifiedIntegrationIdentity,
|
|
24
|
+
type IntegrationIdentityLink,
|
|
25
|
+
} from "./identity-links-store.js";
|
|
26
|
+
|
|
21
27
|
export {
|
|
22
28
|
BUILT_IN_INTEGRATION_CATALOG,
|
|
23
29
|
INTEGRATION_CATEGORIES,
|