@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +18 -0
- package/corpus/core/docs/content/external-agents.mdx +66 -7
- package/corpus/core/docs/content/locales/ar-SA/external-agents.mdx +40 -7
- package/corpus/core/docs/content/locales/de-DE/external-agents.mdx +41 -7
- package/corpus/core/docs/content/locales/es-ES/external-agents.mdx +41 -7
- package/corpus/core/docs/content/locales/fr-FR/external-agents.mdx +41 -7
- package/corpus/core/docs/content/locales/hi-IN/external-agents.mdx +40 -7
- package/corpus/core/docs/content/locales/ja-JP/external-agents.mdx +40 -7
- package/corpus/core/docs/content/locales/ko-KR/external-agents.mdx +40 -7
- package/corpus/core/docs/content/locales/pt-BR/external-agents.mdx +41 -7
- package/corpus/core/docs/content/locales/zh-CN/external-agents.mdx +39 -7
- package/corpus/core/docs/content/locales/zh-TW/external-agents.mdx +39 -7
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/handlers.ts +101 -26
- package/corpus/core/src/a2a/task-store.ts +128 -3
- package/corpus/core/src/client/session-replay.ts +67 -5
- package/corpus/core/src/integrations/adapters/slack.ts +207 -17
- package/corpus/core/src/integrations/identity-links-store.ts +210 -0
- package/corpus/core/src/integrations/identity.ts +196 -0
- package/corpus/core/src/integrations/index.ts +6 -0
- package/corpus/core/src/integrations/plugin.ts +130 -1
- package/corpus/core/src/integrations/types.ts +37 -0
- package/corpus/core/src/integrations/webhook-handler.ts +1 -0
- package/corpus/core/src/mcp/build-server.ts +127 -22
- package/corpus/core/src/mcp/builtin-tools.ts +5 -2
- 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/skills/session-replay/SKILL.md +4 -1
- 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-analytics-uses-the-full-in-app-agent-for-multi-step-incident.md +6 -0
- 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/handlers/session-replay.ts +15 -9
- 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 +72 -22
- package/corpus/templates/analytics/server/lib/session-replay.ts +41 -11
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +7 -0
- package/corpus/templates/analytics/server/plugins/db.ts +15 -0
- package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +8 -5
- package/corpus/templates/analytics/server/routes/api/session-replay/agent-events.json.get.ts +8 -3
- package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +12 -2
- 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 +89 -20
- package/dist/a2a/handlers.js.map +1 -1
- package/dist/a2a/task-store.d.ts +32 -1
- package/dist/a2a/task-store.d.ts.map +1 -1
- package/dist/a2a/task-store.js +111 -4
- package/dist/a2a/task-store.js.map +1 -1
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +53 -8
- package/dist/client/session-replay.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/integrations/adapters/slack.d.ts.map +1 -1
- package/dist/integrations/adapters/slack.js +176 -17
- 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 +28 -0
- package/dist/integrations/identity.d.ts.map +1 -0
- package/dist/integrations/identity.js +142 -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 +93 -1
- package/dist/integrations/plugin.js.map +1 -1
- package/dist/integrations/types.d.ts +31 -0
- package/dist/integrations/types.d.ts.map +1 -1
- package/dist/integrations/types.js.map +1 -1
- package/dist/integrations/webhook-handler.js +1 -0
- package/dist/integrations/webhook-handler.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/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +5 -2
- package/dist/mcp/builtin-tools.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/provider-api/corpus-jobs.d.ts +2 -2
- 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/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
- package/docs/content/external-agents.mdx +66 -7
- package/docs/content/locales/ar-SA/external-agents.mdx +40 -7
- package/docs/content/locales/de-DE/external-agents.mdx +41 -7
- package/docs/content/locales/es-ES/external-agents.mdx +41 -7
- package/docs/content/locales/fr-FR/external-agents.mdx +41 -7
- package/docs/content/locales/hi-IN/external-agents.mdx +40 -7
- package/docs/content/locales/ja-JP/external-agents.mdx +40 -7
- package/docs/content/locales/ko-KR/external-agents.mdx +40 -7
- package/docs/content/locales/pt-BR/external-agents.mdx +41 -7
- package/docs/content/locales/zh-CN/external-agents.mdx +39 -7
- package/docs/content/locales/zh-TW/external-agents.mdx +39 -7
- package/package.json +1 -1
- 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
|
-
|
|
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}`
|