@agent-native/dispatch 0.13.13 → 0.14.1

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 (74) hide show
  1. package/dist/actions/delete-destination.d.ts +12 -12
  2. package/dist/actions/navigate.js +1 -1
  3. package/dist/actions/navigate.js.map +1 -1
  4. package/dist/actions/provider-api-audit.d.ts +7 -0
  5. package/dist/actions/provider-api-audit.d.ts.map +1 -0
  6. package/dist/actions/provider-api-audit.js +74 -0
  7. package/dist/actions/provider-api-audit.js.map +1 -0
  8. package/dist/actions/provider-api-catalog.d.ts +1 -1
  9. package/dist/actions/provider-api-request.js +10 -0
  10. package/dist/actions/provider-api-request.js.map +1 -1
  11. package/dist/actions/send-platform-message.d.ts +1 -0
  12. package/dist/actions/send-platform-message.js +43 -22
  13. package/dist/actions/send-platform-message.js.map +1 -1
  14. package/dist/actions/view-screen.js +9 -0
  15. package/dist/actions/view-screen.js.map +1 -1
  16. package/dist/components/layout/Layout.d.ts.map +1 -1
  17. package/dist/components/layout/Layout.js +75 -70
  18. package/dist/components/layout/Layout.js.map +1 -1
  19. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  20. package/dist/components/messaging-setup-panel.js +203 -127
  21. package/dist/components/messaging-setup-panel.js.map +1 -1
  22. package/dist/routes/index.d.ts.map +1 -1
  23. package/dist/routes/index.js +1 -0
  24. package/dist/routes/index.js.map +1 -1
  25. package/dist/routes/pages/messaging.js +1 -1
  26. package/dist/routes/pages/messaging.js.map +1 -1
  27. package/dist/routes/pages/operations.d.ts +5 -0
  28. package/dist/routes/pages/operations.d.ts.map +1 -0
  29. package/dist/routes/pages/operations.js +51 -0
  30. package/dist/routes/pages/operations.js.map +1 -0
  31. package/dist/routes/pages/overview.d.ts +2 -2
  32. package/dist/server/lib/dispatch-integrations.d.ts +7 -1
  33. package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
  34. package/dist/server/lib/dispatch-integrations.js +178 -8
  35. package/dist/server/lib/dispatch-integrations.js.map +1 -1
  36. package/dist/server/lib/dispatch-routing.d.ts +6 -0
  37. package/dist/server/lib/dispatch-routing.d.ts.map +1 -0
  38. package/dist/server/lib/dispatch-routing.js +30 -0
  39. package/dist/server/lib/dispatch-routing.js.map +1 -0
  40. package/dist/server/lib/env-config.d.ts.map +1 -1
  41. package/dist/server/lib/env-config.js +50 -0
  42. package/dist/server/lib/env-config.js.map +1 -1
  43. package/dist/server/lib/onboarding-steps.d.ts.map +1 -1
  44. package/dist/server/lib/onboarding-steps.js +25 -0
  45. package/dist/server/lib/onboarding-steps.js.map +1 -1
  46. package/dist/server/lib/provider-api.d.ts +2 -2
  47. package/dist/server/lib/provider-api.d.ts.map +1 -1
  48. package/dist/server/plugins/integrations.d.ts.map +1 -1
  49. package/dist/server/plugins/integrations.js +5 -2
  50. package/dist/server/plugins/integrations.js.map +1 -1
  51. package/package.json +2 -2
  52. package/src/actions/navigate.ts +1 -1
  53. package/src/actions/provider-api-audit.spec.ts +52 -0
  54. package/src/actions/provider-api-audit.ts +88 -0
  55. package/src/actions/provider-api-request.ts +10 -0
  56. package/src/actions/send-platform-message.spec.ts +108 -0
  57. package/src/actions/send-platform-message.ts +73 -25
  58. package/src/actions/view-screen.ts +11 -0
  59. package/src/components/layout/Layout.spec.tsx +154 -0
  60. package/src/components/layout/Layout.tsx +196 -189
  61. package/src/components/messaging-setup-panel.spec.tsx +191 -0
  62. package/src/components/messaging-setup-panel.tsx +472 -193
  63. package/src/routes/index.spec.ts +23 -0
  64. package/src/routes/index.ts +1 -0
  65. package/src/routes/pages/messaging.tsx +1 -1
  66. package/src/routes/pages/operations.tsx +134 -0
  67. package/src/server/lib/dispatch-integrations.spec.ts +113 -0
  68. package/src/server/lib/dispatch-integrations.ts +239 -6
  69. package/src/server/lib/dispatch-routing.spec.ts +44 -0
  70. package/src/server/lib/dispatch-routing.ts +42 -0
  71. package/src/server/lib/env-config.ts +50 -0
  72. package/src/server/lib/onboarding-steps.ts +32 -0
  73. package/src/server/plugins/integrations.ts +5 -2
  74. package/src/styles/dispatch-css.spec.ts +11 -0
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { dispatchRoutes } from "./index.js";
4
+
5
+ describe("Dispatch route registration", () => {
6
+ it("registers chat and operator routes before the workspace-app fallback", () => {
7
+ const routes = dispatchRoutes as Array<{
8
+ path?: string;
9
+ file?: string;
10
+ index?: boolean;
11
+ }>;
12
+ const paths = routes.map((route) => route.path);
13
+
14
+ expect(paths).toContain("chat");
15
+ expect(paths).toContain("chat/:threadId");
16
+ expect(paths).toContain("operations");
17
+ expect(paths.indexOf("chat")).toBeLessThan(paths.indexOf(":appId"));
18
+ expect(paths.indexOf("chat/:threadId")).toBeLessThan(
19
+ paths.indexOf(":appId"),
20
+ );
21
+ expect(paths.indexOf("operations")).toBeLessThan(paths.indexOf(":appId"));
22
+ });
23
+ });
@@ -35,6 +35,7 @@ export const dispatchRoutes: RouteConfig = [
35
35
  route("chat/:threadId", "./pages/chat.js"),
36
36
  route("overview", "./pages/overview.js"),
37
37
  route("metrics", "./pages/metrics.js"),
38
+ route("operations", "./pages/operations.js"),
38
39
  route("apps", "./pages/apps.js"),
39
40
  route("apps/:appId", "./pages/apps.$appId.js"),
40
41
  route("new-app", "./pages/new-app.js"),
@@ -9,7 +9,7 @@ export default function MessagingRoute() {
9
9
  return (
10
10
  <DispatchShell
11
11
  title="Messaging"
12
- description="Connect Slack and Telegram directly in dispatch so inbound conversations come through one place."
12
+ description="Connect Slack, Microsoft Teams, Discord interactions, Telegram, WhatsApp Cloud API, or provider-webhook email so supported inbound conversations reach one Dispatch inbox."
13
13
  >
14
14
  <MessagingSetupPanel />
15
15
  </DispatchShell>
@@ -0,0 +1,134 @@
1
+ import {
2
+ DbAdminPage,
3
+ ObservabilityDashboard,
4
+ useT,
5
+ } from "@agent-native/core/client";
6
+ import {
7
+ IconActivity,
8
+ IconArrowUpRight,
9
+ IconDatabase,
10
+ IconHistory,
11
+ IconMessages,
12
+ IconSend,
13
+ } from "@tabler/icons-react";
14
+ import { Link, useSearchParams } from "react-router";
15
+
16
+ import { DispatchShell } from "../../components/dispatch-shell";
17
+ import {
18
+ Tabs,
19
+ TabsContent,
20
+ TabsList,
21
+ TabsTrigger,
22
+ } from "../../components/ui/tabs";
23
+
24
+ type OperationsView = "monitoring" | "database";
25
+
26
+ function selectedView(value: string | null): OperationsView {
27
+ return value === "database" ? "database" : "monitoring";
28
+ }
29
+
30
+ function OperationsShortcuts() {
31
+ const t = useT();
32
+
33
+ const tools = [
34
+ {
35
+ to: "/thread-debug",
36
+ icon: IconMessages,
37
+ title: t("dispatch.nav.threadDebug", { defaultValue: "Thread debug" }),
38
+ },
39
+ {
40
+ to: "/audit",
41
+ icon: IconHistory,
42
+ title: t("dispatch.nav.audit"),
43
+ },
44
+ {
45
+ to: "/destinations",
46
+ icon: IconSend,
47
+ title: t("dispatch.pages.deliveryQueue"),
48
+ },
49
+ ];
50
+
51
+ return (
52
+ <section className="border-t pt-5">
53
+ <h2 className="text-sm font-semibold text-foreground">
54
+ {t("dispatch.nav.advanced", { defaultValue: "Related tools" })}
55
+ </h2>
56
+ <div className="mt-2 grid gap-x-6 gap-y-1 lg:grid-cols-3">
57
+ {tools.map(({ to, icon: Icon, title }) => (
58
+ <Link
59
+ key={to}
60
+ to={to}
61
+ className="group flex min-w-0 items-start gap-2 rounded-md px-2 py-2 text-sm transition-colors hover:bg-muted"
62
+ >
63
+ <Icon
64
+ size={16}
65
+ className="mt-0.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
66
+ />
67
+ <span className="min-w-0">
68
+ <span className="flex items-center gap-1 font-medium text-foreground">
69
+ <span className="truncate">{title}</span>
70
+ <IconArrowUpRight
71
+ size={13}
72
+ className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
73
+ />
74
+ </span>
75
+ </span>
76
+ </Link>
77
+ ))}
78
+ </div>
79
+ </section>
80
+ );
81
+ }
82
+
83
+ export function meta() {
84
+ return [{ title: "Operations — Dispatch" }];
85
+ }
86
+
87
+ export default function OperationsRoute() {
88
+ const t = useT();
89
+ const [searchParams, setSearchParams] = useSearchParams();
90
+ const view = selectedView(searchParams.get("view"));
91
+
92
+ function setView(nextView: OperationsView) {
93
+ const next = new URLSearchParams(searchParams);
94
+ if (nextView === "monitoring") next.delete("view");
95
+ else next.set("view", nextView);
96
+ setSearchParams(next, { replace: true });
97
+ }
98
+
99
+ return (
100
+ <DispatchShell title={t("dispatch.nav.operations")}>
101
+ <Tabs
102
+ value={view}
103
+ onValueChange={(value) => {
104
+ if (value === "monitoring" || value === "database") setView(value);
105
+ }}
106
+ className="flex min-w-0 flex-col gap-5"
107
+ >
108
+ <TabsList className="w-fit">
109
+ <TabsTrigger value="monitoring">
110
+ <IconActivity size={15} />
111
+ {t("dispatch.pages.monitoring")}
112
+ </TabsTrigger>
113
+ <TabsTrigger value="database">
114
+ <IconDatabase size={15} />
115
+ {t("dispatch.pages.database")}
116
+ </TabsTrigger>
117
+ </TabsList>
118
+
119
+ <TabsContent value="monitoring" className="mt-0 min-w-0">
120
+ <ObservabilityDashboard />
121
+ <div className="mt-8">
122
+ <OperationsShortcuts />
123
+ </div>
124
+ </TabsContent>
125
+
126
+ <TabsContent value="database" className="mt-0 min-w-0">
127
+ <div className="min-h-[620px] overflow-hidden rounded-lg border bg-background">
128
+ <DbAdminPage title={t("dispatch.pages.database")} />
129
+ </div>
130
+ </TabsContent>
131
+ </Tabs>
132
+ </DispatchShell>
133
+ );
134
+ }
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
4
4
  consumeLinkToken: vi.fn(),
5
5
  resolveLinkedOwner: vi.fn(),
6
6
  resolveOrgIdForEmail: vi.fn(),
7
+ resolveSecret: vi.fn(),
7
8
  }));
8
9
 
9
10
  vi.mock("./dispatch-store.js", () => ({
@@ -15,6 +16,16 @@ vi.mock("@agent-native/core/org", () => ({
15
16
  resolveOrgIdForEmail: mocks.resolveOrgIdForEmail,
16
17
  }));
17
18
 
19
+ vi.mock("@agent-native/core/server", async () => {
20
+ const actual = await vi.importActual<
21
+ typeof import("@agent-native/core/server")
22
+ >("@agent-native/core/server");
23
+ return {
24
+ ...actual,
25
+ resolveSecret: mocks.resolveSecret,
26
+ };
27
+ });
28
+
18
29
  import type {
19
30
  IncomingMessage,
20
31
  PlatformAdapter,
@@ -24,6 +35,7 @@ import {
24
35
  beforeDispatchProcess,
25
36
  identityKeyForIncoming,
26
37
  resolveDispatchOwner,
38
+ resolveDispatchExecutionContext,
27
39
  } from "./dispatch-integrations.js";
28
40
 
29
41
  const originalFetch = globalThis.fetch;
@@ -94,6 +106,9 @@ beforeEach(() => {
94
106
  mocks.resolveLinkedOwner.mockResolvedValue(null);
95
107
  mocks.consumeLinkToken.mockResolvedValue("owner@example.test");
96
108
  mocks.resolveOrgIdForEmail.mockResolvedValue(null);
109
+ mocks.resolveSecret.mockImplementation(
110
+ async (key: string) => process.env[key] ?? null,
111
+ );
97
112
  vi.stubGlobal(
98
113
  "fetch",
99
114
  vi.fn(async () => new Response(JSON.stringify({ ok: false }))),
@@ -130,6 +145,7 @@ describe("resolveDispatchOwner", () => {
130
145
 
131
146
  it("uses the verified Slack email for org members", async () => {
132
147
  vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token");
148
+ mocks.resolveSecret.mockResolvedValueOnce(null);
133
149
  mocks.resolveOrgIdForEmail.mockResolvedValueOnce("org_123");
134
150
  vi.mocked(globalThis.fetch).mockResolvedValueOnce(
135
151
  new Response(
@@ -153,6 +169,37 @@ describe("resolveDispatchOwner", () => {
153
169
  expect(incoming.platformContext.senderEmail).toBe("user@example.test");
154
170
  });
155
171
 
172
+ it("uses the request-scoped Slack token when no env token exists", async () => {
173
+ mocks.resolveSecret.mockResolvedValueOnce("configured-slack-token");
174
+ mocks.resolveOrgIdForEmail.mockResolvedValueOnce("org_123");
175
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce(
176
+ new Response(
177
+ JSON.stringify({
178
+ ok: true,
179
+ user: {
180
+ profile: { email: "member@example.test", display_name: "Member" },
181
+ },
182
+ }),
183
+ ),
184
+ );
185
+
186
+ await expect(
187
+ resolveDispatchOwner(
188
+ slackIncoming({
189
+ senderId: "U999",
190
+ platformContext: { teamId: "T999", channelId: "C1" },
191
+ }),
192
+ ),
193
+ ).resolves.toBe("member@example.test");
194
+ expect(mocks.resolveSecret).toHaveBeenCalledWith("SLACK_BOT_TOKEN");
195
+ expect(globalThis.fetch).toHaveBeenCalledWith(
196
+ "https://slack.com/api/users.info?user=U999",
197
+ {
198
+ headers: { Authorization: "Bearer configured-slack-token" },
199
+ },
200
+ );
201
+ });
202
+
156
203
  it("falls back to the configured Slack owner when the sender is not an org member", async () => {
157
204
  vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token");
158
205
  vi.stubEnv("DISPATCH_DEFAULT_OWNER_EMAIL", "default@example.test");
@@ -226,6 +273,20 @@ describe("resolveDispatchOwner", () => {
226
273
  });
227
274
 
228
275
  describe("beforeDispatchProcess", () => {
276
+ it("attaches capability-based guidance for structured intake", async () => {
277
+ const incoming = slackIncoming({
278
+ text: "File this review request using our intake form",
279
+ });
280
+
281
+ await expect(beforeDispatchProcess(incoming, noopAdapter)).resolves.toEqual(
282
+ { handled: false },
283
+ );
284
+ expect((incoming as any).routingHint.targetAgent).toBeUndefined();
285
+ expect((incoming as any).routingHint.instruction).toContain(
286
+ "workspace instructions/resources",
287
+ );
288
+ });
289
+
229
290
  it("asks unlinked Telegram users to link before using org context", async () => {
230
291
  vi.stubEnv("APP_URL", "https://dispatch.agent-native.test");
231
292
 
@@ -274,4 +335,56 @@ describe("beforeDispatchProcess", () => {
274
335
  externalUserName: "Steve",
275
336
  });
276
337
  });
338
+
339
+ it("replies with linking guidance instead of silently dropping an unlinked Slack DM", async () => {
340
+ vi.stubEnv("APP_URL", "https://dispatch.agent-native.test");
341
+ const incoming = slackIncoming({
342
+ triggerKind: "dm",
343
+ conversationType: "dm",
344
+ platformContext: {
345
+ teamId: "T123",
346
+ channelId: "D123",
347
+ channelType: "im",
348
+ },
349
+ });
350
+
351
+ const execution = await resolveDispatchExecutionContext(incoming);
352
+ const result = await beforeDispatchProcess(incoming, noopAdapter);
353
+
354
+ expect(execution.ownerEmail).toMatch(/@integration\.local$/);
355
+ expect(incoming.platformContext.identityLinkRequired).toBe(true);
356
+ expect(result).toEqual({
357
+ handled: true,
358
+ responseText:
359
+ "Agent Native is ready, but this Slack account is not linked to an Agent Native user yet. Open https://dispatch.agent-native.test/identities, create a Slack link token, then send `/link <token>` in this DM.",
360
+ });
361
+ });
362
+
363
+ it("lets an unlinked Slack DM consume a link token before the agent gate", async () => {
364
+ const incoming = slackIncoming({
365
+ text: "/link token-123",
366
+ triggerKind: "dm",
367
+ conversationType: "dm",
368
+ platformContext: {
369
+ teamId: "T123",
370
+ channelId: "D123",
371
+ channelType: "im",
372
+ },
373
+ });
374
+
375
+ await resolveDispatchExecutionContext(incoming);
376
+ const result = await beforeDispatchProcess(incoming, noopAdapter);
377
+
378
+ expect(result).toEqual({
379
+ handled: true,
380
+ responseText:
381
+ "Linked successfully. Future slack messages will use owner@example.test's personal dispatch context.",
382
+ });
383
+ expect(mocks.consumeLinkToken).toHaveBeenCalledWith({
384
+ platform: "slack",
385
+ token: "token-123",
386
+ externalUserId: "T123:U123",
387
+ externalUserName: "U123",
388
+ });
389
+ });
277
390
  });
@@ -1,18 +1,36 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
+ import {
4
+ evaluateIntegrationScopePolicy,
5
+ getActiveIntegrationInstallationByKey,
6
+ getIntegrationScope,
7
+ resolveIntegrationTokenBundle,
8
+ saveIntegrationScope,
9
+ slackInstallationKey,
10
+ } from "@agent-native/core/integrations";
3
11
  import { resolveOrgIdForEmail } from "@agent-native/core/org";
4
- import { withConfiguredAppBasePath } from "@agent-native/core/server";
12
+ import {
13
+ readDeployCredentialEnv,
14
+ resolveSecret,
15
+ withConfiguredAppBasePath,
16
+ } from "@agent-native/core/server";
5
17
  import type {
6
18
  IncomingMessage,
19
+ IntegrationExecutionContext,
7
20
  PlatformAdapter,
8
21
  } from "@agent-native/core/server";
9
22
 
10
23
  import { handleRemoteCodeCommand } from "./dispatch-remote-commands.js";
24
+ import {
25
+ dispatchIntegrationRoutingHint,
26
+ type DispatchIntegrationRoutingHint,
27
+ } from "./dispatch-routing.js";
11
28
  import { consumeLinkToken, resolveLinkedOwner } from "./dispatch-store.js";
12
29
 
13
30
  type SlackSenderProfile = {
14
31
  email: string | null;
15
32
  name: string | null;
33
+ trust: "trusted" | "guest" | "external_shared" | "unknown";
16
34
  };
17
35
 
18
36
  const slackProfileCache = new Map<
@@ -104,6 +122,33 @@ function configuredDispatchIdentitiesUrl(): string | null {
104
122
  }
105
123
  }
106
124
 
125
+ async function resolveManagedSlackInstallation(incoming: IncomingMessage) {
126
+ if (incoming.platform !== "slack") return null;
127
+ const teamId = contextString(incoming.platformContext.teamId);
128
+ const apiAppId = contextString(incoming.platformContext.apiAppId);
129
+ if (!teamId) return null;
130
+ try {
131
+ return await getActiveIntegrationInstallationByKey(
132
+ "slack",
133
+ slackInstallationKey({ teamId, apiAppId }),
134
+ );
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ async function resolveManagedSlackToken(
141
+ incoming: IncomingMessage,
142
+ ): Promise<string | null> {
143
+ const installation = await resolveManagedSlackInstallation(incoming);
144
+ if (!installation) return null;
145
+ const bundle = await resolveIntegrationTokenBundle(
146
+ "slack",
147
+ installation.installationKey,
148
+ );
149
+ return bundle?.accessToken ?? null;
150
+ }
151
+
107
152
  function formatTelegramLinkRequiredMessage(): string {
108
153
  const identitiesUrl = configuredDispatchIdentitiesUrl();
109
154
  const linkStep = identitiesUrl
@@ -112,14 +157,30 @@ function formatTelegramLinkRequiredMessage(): string {
112
157
  return `Telegram is connected, but this Telegram account is not linked to an Agent-Native user yet. ${linkStep} After that I can use your Builder.io org and connected apps.`;
113
158
  }
114
159
 
160
+ function formatSlackLinkRequiredMessage(): string {
161
+ const identitiesUrl = configuredDispatchIdentitiesUrl();
162
+ const linkStep = identitiesUrl
163
+ ? `Open ${identitiesUrl}, create a Slack link token, then send \`/link <token>\` in this DM.`
164
+ : "Open Dispatch while signed in, create a Slack link token, then send `/link <token>` in this DM.";
165
+ return `Agent Native is ready, but this Slack account is not linked to an Agent Native user yet. ${linkStep}`;
166
+ }
167
+
115
168
  async function resolveSlackSenderProfile(
116
169
  incoming: IncomingMessage,
117
170
  ): Promise<SlackSenderProfile> {
118
- if (incoming.platform !== "slack") return { email: null, name: null };
119
- const token = process.env.SLACK_BOT_TOKEN;
171
+ if (incoming.platform !== "slack") {
172
+ return { email: null, name: null, trust: "unknown" };
173
+ }
174
+ const managedToken = await resolveManagedSlackToken(incoming);
175
+ const token =
176
+ managedToken ??
177
+ (await resolveSecret("SLACK_BOT_TOKEN")) ??
178
+ readDeployCredentialEnv("SLACK_BOT_TOKEN");
120
179
  const userId = contextString(incoming.senderId);
121
180
  const teamId = contextString(incoming.platformContext.teamId);
122
- if (!token || !userId) return { email: null, name: null };
181
+ if (!token || !userId) {
182
+ return { email: null, name: null, trust: "unknown" };
183
+ }
123
184
 
124
185
  // Slack user IDs are scoped per workspace, so without a teamId we can't
125
186
  // safely cache: two installs of the bot in different workspaces could
@@ -146,6 +207,8 @@ async function resolveSlackSenderProfile(
146
207
  real_name?: string;
147
208
  display_name?: string;
148
209
  };
210
+ is_restricted?: boolean;
211
+ is_ultra_restricted?: boolean;
149
212
  };
150
213
  };
151
214
  const profile = data.ok
@@ -157,8 +220,14 @@ async function resolveSlackSenderProfile(
157
220
  data.user?.real_name?.trim() ||
158
221
  data.user?.name?.trim() ||
159
222
  null,
223
+ trust:
224
+ data.user?.is_ultra_restricted === true
225
+ ? ("external_shared" as const)
226
+ : data.user?.is_restricted === true
227
+ ? ("guest" as const)
228
+ : ("trusted" as const),
160
229
  }
161
- : { email: null, name: null };
230
+ : { email: null, name: null, trust: "unknown" as const };
162
231
  if (cacheKey) {
163
232
  slackProfileCache.set(cacheKey, {
164
233
  profile,
@@ -167,7 +236,7 @@ async function resolveSlackSenderProfile(
167
236
  }
168
237
  return profile;
169
238
  } catch {
170
- return { email: null, name: null };
239
+ return { email: null, name: null, trust: "unknown" };
171
240
  }
172
241
  }
173
242
 
@@ -188,6 +257,51 @@ async function resolveSlackOwnerFromVerifiedEmail(
188
257
  return orgId ? profile.email : null;
189
258
  }
190
259
 
260
+ async function resolveSlackConversationTrust(
261
+ incoming: IncomingMessage,
262
+ actorTrust: SlackSenderProfile["trust"],
263
+ ): Promise<{
264
+ trust: SlackSenderProfile["trust"];
265
+ conversationType: "channel" | "direct_message" | "group_direct_message";
266
+ }> {
267
+ if (incoming.triggerKind === "dm") {
268
+ return { trust: actorTrust, conversationType: "direct_message" };
269
+ }
270
+ const token = await resolveManagedSlackToken(incoming);
271
+ const channel = contextString(incoming.platformContext.channelId);
272
+ if (!token || !channel) {
273
+ return { trust: "unknown", conversationType: "channel" };
274
+ }
275
+ try {
276
+ const params = new URLSearchParams({ channel });
277
+ const response = await fetch(
278
+ `https://slack.com/api/conversations.info?${params}`,
279
+ { headers: { Authorization: `Bearer ${token}` } },
280
+ );
281
+ const data = (await response.json()) as {
282
+ ok?: boolean;
283
+ channel?: {
284
+ is_ext_shared?: boolean;
285
+ is_im?: boolean;
286
+ is_mpim?: boolean;
287
+ };
288
+ };
289
+ if (!data.ok) {
290
+ return { trust: "unknown", conversationType: "channel" };
291
+ }
292
+ return {
293
+ trust: data.channel?.is_ext_shared ? "external_shared" : actorTrust,
294
+ conversationType: data.channel?.is_im
295
+ ? "direct_message"
296
+ : data.channel?.is_mpim
297
+ ? "group_direct_message"
298
+ : "channel",
299
+ };
300
+ } catch {
301
+ return { trust: "unknown", conversationType: "channel" };
302
+ }
303
+ }
304
+
191
305
  export async function resolveDispatchOwner(
192
306
  incoming: IncomingMessage,
193
307
  ): Promise<string> {
@@ -254,6 +368,111 @@ export async function resolveDispatchOwner(
254
368
  }
255
369
  }
256
370
 
371
+ /**
372
+ * Resolve a personal DM user or a workspace-qualified channel service
373
+ * principal. Managed Slack channels never silently inherit one requester's
374
+ * personal credentials or memory.
375
+ */
376
+ export async function resolveDispatchExecutionContext(
377
+ incoming: IncomingMessage,
378
+ ): Promise<IntegrationExecutionContext> {
379
+ if (incoming.platform !== "slack" || incoming.triggerKind === "dm") {
380
+ const ownerEmail = await resolveDispatchOwner(incoming);
381
+ if (
382
+ incoming.platform === "slack" &&
383
+ ownerEmail.endsWith("@integration.local")
384
+ ) {
385
+ // Preserve a credential-less principal long enough for beforeProcess to
386
+ // deliver linking guidance or consume `/link <token>`. Never run the
387
+ // agent under this synthetic owner.
388
+ incoming.platformContext.identityLinkRequired = true;
389
+ }
390
+ return {
391
+ ownerEmail,
392
+ orgId: (await resolveOrgIdForEmail(ownerEmail)) ?? null,
393
+ principalType: "user",
394
+ };
395
+ }
396
+
397
+ const installation = await resolveManagedSlackInstallation(incoming);
398
+ if (!installation) {
399
+ // Preserve the legacy manually configured app path while managed installs
400
+ // roll out. Its behavior remains explicit and visible in Settings.
401
+ const ownerEmail = await resolveDispatchOwner(incoming);
402
+ return {
403
+ ownerEmail,
404
+ orgId: (await resolveOrgIdForEmail(ownerEmail)) ?? null,
405
+ principalType: "user",
406
+ };
407
+ }
408
+
409
+ const teamId = contextString(incoming.platformContext.teamId);
410
+ const channelId = contextString(incoming.platformContext.channelId);
411
+ if (!teamId || !channelId) {
412
+ throw new Error("Slack channel identity is incomplete");
413
+ }
414
+ const profile = await resolveSlackSenderProfile(incoming);
415
+ const conversation = await resolveSlackConversationTrust(
416
+ incoming,
417
+ profile.trust,
418
+ );
419
+ const access = {
420
+ ownerEmail: installation.ownerEmail,
421
+ orgId: installation.orgId,
422
+ };
423
+ const key = {
424
+ platform: "slack",
425
+ tenantId: teamId,
426
+ conversationId: channelId,
427
+ };
428
+ let scope = await getIntegrationScope(key, access);
429
+ if (!scope && incoming.triggerKind === "mention") {
430
+ scope = await saveIntegrationScope(
431
+ {
432
+ ...key,
433
+ conversationType: conversation.conversationType,
434
+ trust: conversation.trust,
435
+ orgId: installation.orgId,
436
+ installationId: installation.id,
437
+ },
438
+ access,
439
+ );
440
+ }
441
+ if (!scope) throw new Error("Slack channel is not enabled for Agent Native");
442
+ const decision = evaluateIntegrationScopePolicy(scope, {
443
+ // Thread replies reached this point only after the adapter's active-task
444
+ // gate, so they are continuation steering rather than ambient chatter.
445
+ mentioned:
446
+ incoming.triggerKind === "mention" ||
447
+ incoming.triggerKind === "thread_reply",
448
+ });
449
+ if (!decision.allowed) {
450
+ throw new Error(`Slack channel policy denied: ${decision.reason}`);
451
+ }
452
+ incoming.integrationScopeId = scope.id;
453
+ if (scope.defaultModel) {
454
+ incoming.platformContext.defaultModel = scope.defaultModel;
455
+ }
456
+ incoming.actorTrust = {
457
+ memberType:
458
+ conversation.trust === "guest"
459
+ ? "guest"
460
+ : conversation.trust === "external_shared"
461
+ ? "external"
462
+ : conversation.trust === "trusted"
463
+ ? "member"
464
+ : "unknown",
465
+ verified: conversation.trust !== "unknown",
466
+ };
467
+ return {
468
+ ownerEmail: scope.serviceOwnerEmail,
469
+ orgId: scope.orgId,
470
+ principalType: "service",
471
+ installationId: installation.id,
472
+ scopeId: scope.id,
473
+ };
474
+ }
475
+
257
476
  export async function beforeDispatchProcess(
258
477
  incoming: IncomingMessage,
259
478
  adapter: PlatformAdapter,
@@ -263,6 +482,20 @@ export async function beforeDispatchProcess(
263
482
  contextString(incoming.platformContext.rawText) || trimmed;
264
483
  const match = commandText.match(/^\/link(?:@\w+)?\s+([a-zA-Z0-9_-]+)$/);
265
484
  if (!match) {
485
+ const routedIncoming = incoming as IncomingMessage & {
486
+ routingHint?: DispatchIntegrationRoutingHint;
487
+ };
488
+ routedIncoming.routingHint ??= dispatchIntegrationRoutingHint(trimmed);
489
+ if (
490
+ incoming.platform === "slack" &&
491
+ incoming.triggerKind === "dm" &&
492
+ incoming.platformContext.identityLinkRequired === true
493
+ ) {
494
+ return {
495
+ handled: true,
496
+ responseText: formatSlackLinkRequiredMessage(),
497
+ };
498
+ }
266
499
  if (platformRequiresExplicitLink(incoming)) {
267
500
  const owner = await resolveLinkedOwner(
268
501
  incoming.platform,