@agent-native/core 0.98.5 → 0.98.7

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 (135) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/docs/content/external-agents.mdx +66 -7
  4. package/corpus/core/docs/content/locales/ar-SA/external-agents.mdx +40 -7
  5. package/corpus/core/docs/content/locales/de-DE/external-agents.mdx +41 -7
  6. package/corpus/core/docs/content/locales/es-ES/external-agents.mdx +41 -7
  7. package/corpus/core/docs/content/locales/fr-FR/external-agents.mdx +41 -7
  8. package/corpus/core/docs/content/locales/hi-IN/external-agents.mdx +40 -7
  9. package/corpus/core/docs/content/locales/ja-JP/external-agents.mdx +40 -7
  10. package/corpus/core/docs/content/locales/ko-KR/external-agents.mdx +40 -7
  11. package/corpus/core/docs/content/locales/pt-BR/external-agents.mdx +41 -7
  12. package/corpus/core/docs/content/locales/zh-CN/external-agents.mdx +39 -7
  13. package/corpus/core/docs/content/locales/zh-TW/external-agents.mdx +39 -7
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/a2a/handlers.ts +101 -26
  16. package/corpus/core/src/a2a/task-store.ts +128 -3
  17. package/corpus/core/src/client/session-replay.ts +67 -5
  18. package/corpus/core/src/integrations/adapters/slack.ts +207 -17
  19. package/corpus/core/src/integrations/identity-links-store.ts +210 -0
  20. package/corpus/core/src/integrations/identity.ts +196 -0
  21. package/corpus/core/src/integrations/index.ts +6 -0
  22. package/corpus/core/src/integrations/plugin.ts +130 -1
  23. package/corpus/core/src/integrations/types.ts +37 -0
  24. package/corpus/core/src/integrations/webhook-handler.ts +1 -0
  25. package/corpus/core/src/mcp/build-server.ts +127 -22
  26. package/corpus/core/src/mcp/builtin-tools.ts +5 -2
  27. package/corpus/core/src/mcp/external-agent-policy.ts +18 -0
  28. package/corpus/core/src/mcp/index.ts +1 -0
  29. package/corpus/core/src/server/agent-chat/plugin-options.ts +12 -1
  30. package/corpus/core/src/server/agent-chat/script-entries.ts +20 -3
  31. package/corpus/core/src/server/agent-chat-plugin.ts +3 -0
  32. package/corpus/core/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
  33. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +4 -1
  34. package/corpus/templates/analytics/AGENTS.md +20 -5
  35. package/corpus/templates/analytics/actions/create-session-replay-agent-link.ts +0 -1
  36. package/corpus/templates/analytics/actions/get-session-replay-events.ts +0 -1
  37. package/corpus/templates/analytics/actions/get-session-replay-timeline.ts +40 -0
  38. package/corpus/templates/analytics/actions/list-error-issues.ts +12 -0
  39. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +4 -1
  40. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +48 -17
  41. package/corpus/templates/analytics/changelog/2026-07-12-analytics-uses-the-full-in-app-agent-for-multi-step-incident.md +6 -0
  42. package/corpus/templates/analytics/changelog/2026-07-12-connected-external-agents-can-now-look-up-sessions-error-iss.md +6 -0
  43. package/corpus/templates/analytics/changelog/2026-07-12-session-identities-stay-visible-when-demo-mode-is-off.md +6 -0
  44. package/corpus/templates/analytics/server/handlers/session-replay.ts +15 -9
  45. package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +2 -2
  46. package/corpus/templates/analytics/server/lib/error-capture.ts +47 -5
  47. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +72 -22
  48. package/corpus/templates/analytics/server/lib/session-replay.ts +41 -11
  49. package/corpus/templates/analytics/server/plugins/agent-chat.ts +7 -0
  50. package/corpus/templates/analytics/server/plugins/db.ts +15 -0
  51. package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +8 -5
  52. package/corpus/templates/analytics/server/routes/api/session-replay/agent-events.json.get.ts +8 -3
  53. package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +12 -2
  54. package/corpus/templates/forms/app/global.css +54 -8
  55. package/corpus/templates/forms/app/pages/AskPage.tsx +35 -13
  56. package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +11 -17
  57. package/corpus/templates/forms/app/pages/ResponsesPage.tsx +8 -5
  58. package/corpus/templates/forms/changelog/2026-07-12-ask-forms-suggestions-now-sit-beneath-the-composer-for-a-tig.md +6 -0
  59. package/corpus/templates/forms/changelog/2026-07-12-builder-response-tables-now-fill-the-view-without-a-redundan.md +6 -0
  60. package/corpus/templates/forms/changelog/2026-07-12-response-tables-now-fill-the-available-pane-with-a-flexible-.md +6 -0
  61. package/corpus/templates/mail/AGENTS.md +3 -1
  62. package/corpus/templates/mail/server/lib/mail-integrations.ts +3 -0
  63. package/dist/a2a/handlers.d.ts.map +1 -1
  64. package/dist/a2a/handlers.js +89 -20
  65. package/dist/a2a/handlers.js.map +1 -1
  66. package/dist/a2a/task-store.d.ts +32 -1
  67. package/dist/a2a/task-store.d.ts.map +1 -1
  68. package/dist/a2a/task-store.js +111 -4
  69. package/dist/a2a/task-store.js.map +1 -1
  70. package/dist/client/session-replay.d.ts.map +1 -1
  71. package/dist/client/session-replay.js +53 -8
  72. package/dist/client/session-replay.js.map +1 -1
  73. package/dist/collab/routes.d.ts +1 -1
  74. package/dist/integrations/adapters/slack.d.ts.map +1 -1
  75. package/dist/integrations/adapters/slack.js +176 -17
  76. package/dist/integrations/adapters/slack.js.map +1 -1
  77. package/dist/integrations/identity-links-store.d.ts +23 -0
  78. package/dist/integrations/identity-links-store.d.ts.map +1 -0
  79. package/dist/integrations/identity-links-store.js +158 -0
  80. package/dist/integrations/identity-links-store.js.map +1 -0
  81. package/dist/integrations/identity.d.ts +28 -0
  82. package/dist/integrations/identity.d.ts.map +1 -0
  83. package/dist/integrations/identity.js +142 -0
  84. package/dist/integrations/identity.js.map +1 -0
  85. package/dist/integrations/index.d.ts +2 -0
  86. package/dist/integrations/index.d.ts.map +1 -1
  87. package/dist/integrations/index.js +2 -0
  88. package/dist/integrations/index.js.map +1 -1
  89. package/dist/integrations/plugin.d.ts.map +1 -1
  90. package/dist/integrations/plugin.js +93 -1
  91. package/dist/integrations/plugin.js.map +1 -1
  92. package/dist/integrations/types.d.ts +31 -0
  93. package/dist/integrations/types.d.ts.map +1 -1
  94. package/dist/integrations/types.js.map +1 -1
  95. package/dist/integrations/webhook-handler.js +1 -0
  96. package/dist/integrations/webhook-handler.js.map +1 -1
  97. package/dist/mcp/build-server.d.ts +9 -2
  98. package/dist/mcp/build-server.d.ts.map +1 -1
  99. package/dist/mcp/build-server.js +97 -20
  100. package/dist/mcp/build-server.js.map +1 -1
  101. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  102. package/dist/mcp/builtin-tools.js +5 -2
  103. package/dist/mcp/builtin-tools.js.map +1 -1
  104. package/dist/mcp/external-agent-policy.d.ts +19 -0
  105. package/dist/mcp/external-agent-policy.d.ts.map +1 -0
  106. package/dist/mcp/external-agent-policy.js +2 -0
  107. package/dist/mcp/external-agent-policy.js.map +1 -0
  108. package/dist/mcp/index.d.ts +1 -0
  109. package/dist/mcp/index.d.ts.map +1 -1
  110. package/dist/mcp/index.js.map +1 -1
  111. package/dist/notifications/routes.d.ts +1 -1
  112. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  113. package/dist/server/agent-chat/plugin-options.d.ts +11 -1
  114. package/dist/server/agent-chat/plugin-options.d.ts.map +1 -1
  115. package/dist/server/agent-chat/plugin-options.js.map +1 -1
  116. package/dist/server/agent-chat/script-entries.d.ts.map +1 -1
  117. package/dist/server/agent-chat/script-entries.js +16 -2
  118. package/dist/server/agent-chat/script-entries.js.map +1 -1
  119. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  120. package/dist/server/agent-chat-plugin.js +3 -0
  121. package/dist/server/agent-chat-plugin.js.map +1 -1
  122. package/dist/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
  123. package/docs/content/external-agents.mdx +66 -7
  124. package/docs/content/locales/ar-SA/external-agents.mdx +40 -7
  125. package/docs/content/locales/de-DE/external-agents.mdx +41 -7
  126. package/docs/content/locales/es-ES/external-agents.mdx +41 -7
  127. package/docs/content/locales/fr-FR/external-agents.mdx +41 -7
  128. package/docs/content/locales/hi-IN/external-agents.mdx +40 -7
  129. package/docs/content/locales/ja-JP/external-agents.mdx +40 -7
  130. package/docs/content/locales/ko-KR/external-agents.mdx +40 -7
  131. package/docs/content/locales/pt-BR/external-agents.mdx +41 -7
  132. package/docs/content/locales/zh-CN/external-agents.mdx +39 -7
  133. package/docs/content/locales/zh-TW/external-agents.mdx +39 -7
  134. package/package.json +1 -1
  135. package/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
@@ -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,196 @@
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
+ export type IntegrationIdentityDeclineReason =
11
+ | "unverified"
12
+ | "guest"
13
+ | "unlinked-workspace"
14
+ | "membership-check-failed";
15
+
16
+ /**
17
+ * Thrown when the default Slack DM identity ladder declines to run a message.
18
+ * `reason` is a stable machine-readable discriminator (used e.g. to dedupe
19
+ * decline replies); `userFacingMessage` is safe to send back to the sender as
20
+ * a polite reply; `message` stays log-only.
21
+ */
22
+ export class IntegrationIdentityDeclinedError extends Error {
23
+ readonly reason: IntegrationIdentityDeclineReason;
24
+ readonly userFacingMessage: string;
25
+
26
+ constructor(
27
+ reason: IntegrationIdentityDeclineReason,
28
+ message: string,
29
+ userFacingMessage: string,
30
+ ) {
31
+ super(message);
32
+ this.name = "IntegrationIdentityDeclinedError";
33
+ this.reason = reason;
34
+ this.userFacingMessage = userFacingMessage;
35
+ }
36
+ }
37
+
38
+ function serviceOwner(platform: string): string {
39
+ return `integration@${platform}`;
40
+ }
41
+
42
+ function normalizedEmail(value: unknown): string | null {
43
+ if (typeof value !== "string") return null;
44
+ const email = value.trim().toLowerCase();
45
+ if (!email || email.length > 320 || !email.includes("@")) return null;
46
+ return email;
47
+ }
48
+
49
+ async function resolveSlackInstallation(incoming: IncomingMessage) {
50
+ const teamId =
51
+ typeof incoming.platformContext.teamId === "string"
52
+ ? incoming.platformContext.teamId
53
+ : incoming.tenantId;
54
+ const enterpriseId =
55
+ typeof incoming.platformContext.enterpriseId === "string"
56
+ ? incoming.platformContext.enterpriseId
57
+ : undefined;
58
+ const apiAppId =
59
+ typeof incoming.platformContext.apiAppId === "string"
60
+ ? incoming.platformContext.apiAppId
61
+ : undefined;
62
+ if (apiAppId && (teamId || enterpriseId)) {
63
+ const byKey = await getActiveIntegrationInstallationByKey(
64
+ "slack",
65
+ slackInstallationKey({ teamId, enterpriseId, apiAppId }),
66
+ );
67
+ if (byKey) return byKey;
68
+ }
69
+ return teamId || enterpriseId
70
+ ? getActiveIntegrationInstallationForTenant(
71
+ "slack",
72
+ teamId ?? enterpriseId!,
73
+ )
74
+ : null;
75
+ }
76
+
77
+ async function isMemberOfOrg(email: string, orgId: string): Promise<boolean> {
78
+ const { rows } = await getDbExec().execute({
79
+ sql: `SELECT 1 FROM org_members
80
+ WHERE org_id = ? AND LOWER(email) = ?
81
+ LIMIT 1`,
82
+ args: [orgId, email],
83
+ });
84
+ return rows.length > 0;
85
+ }
86
+
87
+ /**
88
+ * Resolve the default integration principal.
89
+ *
90
+ * Slack DMs become a user principal only after the adapter has verified the
91
+ * sender email and the email is already a member of the managed installation's
92
+ * Agent Native organization. Hydrated full workspace members whose email is
93
+ * missing, unverified, or not an organization member run as an anonymous
94
+ * org-scoped service principal instead — the same visibility shared channels
95
+ * get. Hydration failures, guests/external members, and workspaces without a
96
+ * connected organization are declined with a user-facing message. Shared
97
+ * channels deliberately stay service-scoped so a channel message cannot borrow
98
+ * one participant's private permissions.
99
+ */
100
+ export async function resolveDefaultIntegrationExecutionContext(
101
+ incoming: IncomingMessage,
102
+ ): Promise<IntegrationExecutionContext> {
103
+ const installation =
104
+ incoming.platform === "slack"
105
+ ? await resolveSlackInstallation(incoming)
106
+ : null;
107
+
108
+ if (incoming.platform !== "slack" || incoming.conversationType !== "dm") {
109
+ return {
110
+ ownerEmail: serviceOwner(incoming.platform),
111
+ orgId: installation?.orgId ?? null,
112
+ principalType: "service",
113
+ ...(installation?.id ? { installationId: installation.id } : {}),
114
+ };
115
+ }
116
+
117
+ // Hydration check first: a transient users.info failure must land on the
118
+ // retry decline here, never on the anonymous org-scoped tier below.
119
+ if (incoming.actorTrust?.verified !== true) {
120
+ throw new IntegrationIdentityDeclinedError(
121
+ "unverified",
122
+ "Slack DM sender identity could not be hydrated; declining instead of guessing a principal.",
123
+ "I couldn't verify your Slack identity just now, so I can't run this request. Please try again in a moment.",
124
+ );
125
+ }
126
+ if (
127
+ incoming.actorTrust.memberType === "guest" ||
128
+ incoming.actorTrust.memberType === "external"
129
+ ) {
130
+ throw new IntegrationIdentityDeclinedError(
131
+ "guest",
132
+ "External or guest Slack members cannot use this integration.",
133
+ "This assistant is only available to members of this workspace's organization.",
134
+ );
135
+ }
136
+ if (!installation?.orgId) {
137
+ throw new IntegrationIdentityDeclinedError(
138
+ "unlinked-workspace",
139
+ "Slack workspace is not connected to an Agent Native organization.",
140
+ "This Slack workspace isn't connected to an organization yet.",
141
+ );
142
+ }
143
+
144
+ const email = normalizedEmail(incoming.senderEmail);
145
+ let isOrgMember = false;
146
+ if (
147
+ email &&
148
+ incoming.senderVerified === true &&
149
+ incoming.senderId &&
150
+ incoming.tenantId
151
+ ) {
152
+ try {
153
+ isOrgMember = await isMemberOfOrg(email, installation.orgId);
154
+ } catch {
155
+ // A membership-store outage is not evidence that the sender is merely
156
+ // unlinked. Fail closed instead of widening them to org-wide access.
157
+ throw new IntegrationIdentityDeclinedError(
158
+ "membership-check-failed",
159
+ "Slack DM organization membership could not be verified.",
160
+ "I couldn't verify your organization membership just now, so I can't run this request. Please try again in a moment.",
161
+ );
162
+ }
163
+ }
164
+ if (isOrgMember && email && incoming.senderId && incoming.tenantId) {
165
+ const link = await upsertVerifiedIntegrationIdentity({
166
+ platform: incoming.platform,
167
+ tenantId: incoming.tenantId,
168
+ externalUserId: incoming.senderId,
169
+ userEmail: email,
170
+ orgId: installation.orgId,
171
+ });
172
+ return {
173
+ ownerEmail: link.userEmail,
174
+ orgId: link.orgId,
175
+ principalType: "user",
176
+ installationId: installation.id,
177
+ };
178
+ }
179
+
180
+ // Hydrated full workspace member (member/admin/owner) whose email is
181
+ // unverified, missing (legacy install without users:read.email), or not an
182
+ // organization member: run with the anonymous org-scoped service principal —
183
+ // the same visibility shared channels get. Nothing user-private is
184
+ // accessible. One structured line keeps 100%-anonymous workspaces (legacy
185
+ // scope missing) visible in logs.
186
+ console.warn(
187
+ `[integrations] anonymous org-scoped principal used: platform=${incoming.platform} teamId=${incoming.tenantId ?? "unknown"} emailPresent=${Boolean(email)} memberType=${incoming.actorTrust.memberType}`,
188
+ );
189
+ return {
190
+ ownerEmail: serviceOwner(incoming.platform),
191
+ orgId: installation.orgId,
192
+ principalType: "service",
193
+ installationId: installation.id,
194
+ anonymousMember: true,
195
+ };
196
+ }
@@ -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,
@@ -53,6 +53,10 @@ import {
53
53
  startGoogleDocsPoller,
54
54
  handlePushNotification,
55
55
  } from "./google-docs-poller.js";
56
+ import {
57
+ IntegrationIdentityDeclinedError,
58
+ resolveDefaultIntegrationExecutionContext,
59
+ } from "./identity.js";
56
60
  import {
57
61
  disconnectIntegrationInstallation,
58
62
  listIntegrationInstallations,
@@ -251,6 +255,11 @@ type IntegrationCredentialContext = {
251
255
 
252
256
  const REMOTE_DEVICE_ONLINE_MS = 90_000;
253
257
 
258
+ // One decline reply per sender + decline reason per window: during a Slack
259
+ // API outage every message would otherwise get another identical "try again"
260
+ // reply. Short enough that a persistent condition still reminds the sender.
261
+ const DECLINE_NOTICE_DEDUPE_TTL_MS = 5 * 60 * 1_000;
262
+
254
263
  export async function enqueueRemoteCommand(
255
264
  envelope: RemoteCodeCommandEnvelope,
256
265
  ): Promise<Record<string, unknown>> {
@@ -2343,13 +2352,87 @@ export function createIntegrationsPlugin(
2343
2352
  return { error: `Integration ${platform} is not enabled` };
2344
2353
  }
2345
2354
 
2346
- const incoming = await withCredentialContext(credentialContext, () =>
2355
+ let incoming = await withCredentialContext(credentialContext, () =>
2347
2356
  adapter.parseIncomingMessage(event),
2348
2357
  );
2349
2358
  if (!incoming) {
2350
2359
  setResponseStatus(event, 200);
2351
2360
  return "ok";
2352
2361
  }
2362
+ if (adapter.hydrateIncomingIdentity) {
2363
+ try {
2364
+ incoming = await withCredentialContext(credentialContext, () =>
2365
+ adapter.hydrateIncomingIdentity!(incoming!),
2366
+ );
2367
+ } catch (err) {
2368
+ // Identity hydration is best-effort for platforms that have an
2369
+ // app-specific resolver. Slack's default DM resolver below will
2370
+ // still fail closed when the identity is absent or unverified.
2371
+ console.warn(
2372
+ `[integrations] Could not hydrate ${platform} sender identity:`,
2373
+ err instanceof Error ? err.message : err,
2374
+ );
2375
+ }
2376
+ }
2377
+ let defaultExecutionContext: IntegrationExecutionContext | null =
2378
+ null;
2379
+ if (
2380
+ incoming.platform === "slack" &&
2381
+ incoming.conversationType === "dm"
2382
+ ) {
2383
+ try {
2384
+ defaultExecutionContext = await withCredentialContext(
2385
+ credentialContext,
2386
+ () => resolveDefaultIntegrationExecutionContext(incoming!),
2387
+ );
2388
+ } catch (err) {
2389
+ // Only an explicit execution-context resolver may override the
2390
+ // default Slack identity ladder. The legacy owner-only resolver
2391
+ // predates org-bound identities and must not turn a rejected DM
2392
+ // into an authenticated owner run.
2393
+ if (!options?.resolveExecutionContext) {
2394
+ const declined =
2395
+ err instanceof IntegrationIdentityDeclinedError ? err : null;
2396
+ if (declined) {
2397
+ console.warn(
2398
+ `[integrations] default Slack DM identity declined message:`,
2399
+ declined.message,
2400
+ );
2401
+ // Best-effort polite reply back to the DM, deduped per
2402
+ // sender + reason so an outage doesn't produce an identical
2403
+ // reply for every message. Do not await Slack's API: webhook
2404
+ // acknowledgement has a stricter deadline than notice
2405
+ // delivery and must not be delayed by a slow postMessage.
2406
+ void withCredentialContext(credentialContext, () =>
2407
+ adapter.sendSystemNotice
2408
+ ? adapter.sendSystemNotice(
2409
+ incoming!,
2410
+ declined.userFacingMessage,
2411
+ {
2412
+ dedupeKey: `decline:${incoming!.tenantId ?? "unknown"}:${incoming!.senderId ?? "unknown"}:${declined.reason}`,
2413
+ dedupeTtlMs: DECLINE_NOTICE_DEDUPE_TTL_MS,
2414
+ },
2415
+ )
2416
+ : Promise.resolve(),
2417
+ ).catch((noticeErr) => {
2418
+ console.warn(
2419
+ `[integrations] decline notice failed:`,
2420
+ noticeErr instanceof Error
2421
+ ? noticeErr.message
2422
+ : noticeErr,
2423
+ );
2424
+ });
2425
+ } else {
2426
+ console.error(
2427
+ `[integrations] default Slack DM identity denied message:`,
2428
+ err,
2429
+ );
2430
+ }
2431
+ setResponseStatus(event, 200);
2432
+ return "ok";
2433
+ }
2434
+ }
2435
+ }
2353
2436
  let executionContext: IntegrationExecutionContext = {
2354
2437
  ownerEmail: `integration@${platform}`,
2355
2438
  orgId: null as string | null,
@@ -2370,6 +2453,38 @@ export function createIntegrationsPlugin(
2370
2453
  setResponseStatus(event, 200);
2371
2454
  return "ok";
2372
2455
  }
2456
+ } else if (defaultExecutionContext) {
2457
+ executionContext = defaultExecutionContext;
2458
+ if (defaultExecutionContext.anonymousMember) {
2459
+ // The anonymous tier must never be silent. (1) The agent run
2460
+ // can tell: the note rides the serialized `incoming` into the
2461
+ // queued task and surfaces via <integration-context>.
2462
+ incoming.identityNote =
2463
+ "Caller is an unlinked Slack workspace member running with organization-wide visibility only; personal or privately-shared data is not accessible. They can get personal access by having an admin add their Slack email to the organization (or by reconnecting Slack with the users:read.email scope).";
2464
+ // (2) The sender gets a one-time heads-up (adapter-throttled per
2465
+ // sender). Fire-and-forget so it never delays or replaces the
2466
+ // normal agent run.
2467
+ if (adapter.sendSystemNotice) {
2468
+ const senderEmail =
2469
+ typeof incoming.senderEmail === "string" &&
2470
+ incoming.senderEmail.trim()
2471
+ ? incoming.senderEmail.trim()
2472
+ : null;
2473
+ const noticeText = senderEmail
2474
+ ? `Heads up: I couldn't match your Slack account to an organization member, so I can only use org-wide data. Ask an admin to add ${senderEmail} to the organization for personal access.`
2475
+ : "Heads up: I couldn't verify your Slack account's email, so I can only use org-wide data. Ask an admin to update the Slack connection with the users:read.email scope for personal access.";
2476
+ void withCredentialContext(credentialContext, () =>
2477
+ adapter.sendSystemNotice!(incoming!, noticeText, {
2478
+ dedupeKey: `anonymous-tier:${incoming.tenantId ?? "unknown"}:${incoming.senderId ?? "unknown"}`,
2479
+ }),
2480
+ ).catch((noticeErr) => {
2481
+ console.warn(
2482
+ `[integrations] anonymous-tier notice failed:`,
2483
+ noticeErr instanceof Error ? noticeErr.message : noticeErr,
2484
+ );
2485
+ });
2486
+ }
2487
+ }
2373
2488
  } else if (options?.resolveOwner) {
2374
2489
  try {
2375
2490
  executionContext.ownerEmail = await withCredentialContext(
@@ -2382,6 +2497,20 @@ export function createIntegrationsPlugin(
2382
2497
  err,
2383
2498
  );
2384
2499
  }
2500
+ } else {
2501
+ try {
2502
+ executionContext = await withCredentialContext(
2503
+ credentialContext,
2504
+ () => resolveDefaultIntegrationExecutionContext(incoming!),
2505
+ );
2506
+ } catch (err) {
2507
+ console.error(
2508
+ `[integrations] default execution identity denied message:`,
2509
+ err,
2510
+ );
2511
+ setResponseStatus(event, 200);
2512
+ return "ok";
2513
+ }
2385
2514
  }
2386
2515
  if (executionContext.scopeId) {
2387
2516
  incoming.integrationScopeId = executionContext.scopeId;
@@ -104,6 +104,13 @@ export interface IncomingMessage {
104
104
  targetAgent?: string;
105
105
  instruction: string;
106
106
  };
107
+ /**
108
+ * Trusted app-side note about the caller's identity/visibility tier, set
109
+ * after execution-context resolution (e.g. an anonymous org-scoped Slack
110
+ * member). Surfaced to the agent as integration context. Adapters must not
111
+ * copy this from user-controlled webhook payload fields.
112
+ */
113
+ identityNote?: string;
107
114
  /** Provider-native message/activity reference for contextual replies. */
108
115
  replyRef?: string;
109
116
  /** Message timestamp (epoch ms) */
@@ -274,6 +281,13 @@ export interface PlatformAdapter {
274
281
  */
275
282
  hydrateIncomingMessage?(incoming: IncomingMessage): Promise<IncomingMessage>;
276
283
 
284
+ /**
285
+ * Hydrate only verified sender identity before execution-context selection.
286
+ * This runs on the provider acknowledgement path and must avoid fetching
287
+ * conversation history or file bodies.
288
+ */
289
+ hydrateIncomingIdentity?(incoming: IncomingMessage): Promise<IncomingMessage>;
290
+
277
291
  /**
278
292
  * Provider-specific response returned only after a message is verified and
279
293
  * durably enqueued. Discord uses this to return a type-5 deferred response
@@ -304,6 +318,23 @@ export interface PlatformAdapter {
304
318
  opts?: { placeholderRef?: string },
305
319
  ): Promise<void>;
306
320
 
321
+ /**
322
+ * Send a short best-effort system notice to the conversation the incoming
323
+ * message arrived on (polite identity declines, one-time access guidance).
324
+ * Bypasses agent formatting. When `dedupeKey` is provided, the adapter may
325
+ * drop the notice if the same key was sent recently. Callers must treat
326
+ * failures as non-fatal.
327
+ */
328
+ sendSystemNotice?(
329
+ incoming: IncomingMessage,
330
+ text: string,
331
+ opts?: {
332
+ dedupeKey?: string;
333
+ /** Dedupe window for `dedupeKey`. Adapters pick a default when omitted. */
334
+ dedupeTtlMs?: number;
335
+ },
336
+ ): Promise<void>;
337
+
307
338
  /**
308
339
  * Optionally post a "working on it…" placeholder message immediately when a
309
340
  * webhook arrives, before the agent loop runs. Adapters that support
@@ -429,4 +460,10 @@ export interface IntegrationExecutionContext {
429
460
  principalType: "user" | "service";
430
461
  installationId?: string;
431
462
  scopeId?: string;
463
+ /**
464
+ * True when a hydrated full workspace member could not be matched to an
465
+ * organization member and runs with the anonymous org-scoped service
466
+ * principal (org-wide visibility only, nothing user-private).
467
+ */
468
+ anonymousMember?: boolean;
432
469
  }
@@ -745,6 +745,7 @@ async function processIncomingMessage(
745
745
  incoming.senderName ? `Sender name: ${incoming.senderName}` : null,
746
746
  incoming.senderEmail ? `Sender email: ${incoming.senderEmail}` : null,
747
747
  incoming.senderId ? `Sender ID: ${incoming.senderId}` : null,
748
+ incoming.identityNote ? `Caller identity: ${incoming.identityNote}` : null,
748
749
  incoming.sourceUrl ? `Source thread: ${incoming.sourceUrl}` : null,
749
750
  incoming.routingHint?.targetAgent
750
751
  ? `Required target agent: ${incoming.routingHint.targetAgent}`