@agent-native/core 0.98.6 → 0.98.8
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 +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/docs/content/external-agents.mdx +22 -12
- package/corpus/core/docs/content/locales/ar-SA/external-agents.mdx +8 -2
- package/corpus/core/docs/content/locales/de-DE/external-agents.mdx +8 -2
- package/corpus/core/docs/content/locales/es-ES/external-agents.mdx +8 -2
- package/corpus/core/docs/content/locales/fr-FR/external-agents.mdx +8 -2
- package/corpus/core/docs/content/locales/hi-IN/external-agents.mdx +8 -2
- package/corpus/core/docs/content/locales/ja-JP/external-agents.mdx +8 -2
- package/corpus/core/docs/content/locales/ko-KR/external-agents.mdx +8 -2
- package/corpus/core/docs/content/locales/pt-BR/external-agents.mdx +8 -2
- package/corpus/core/docs/content/locales/zh-CN/external-agents.mdx +8 -2
- package/corpus/core/docs/content/locales/zh-TW/external-agents.mdx +8 -2
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/handlers.ts +6 -5
- package/corpus/core/src/a2a/task-store.ts +63 -0
- package/corpus/core/src/client/session-replay.ts +90 -6
- package/corpus/core/src/integrations/adapters/slack.ts +110 -19
- package/corpus/core/src/integrations/identity.ts +103 -39
- package/corpus/core/src/integrations/pending-tasks-retry-job.ts +2 -1
- package/corpus/core/src/integrations/pending-tasks-store.ts +1 -1
- package/corpus/core/src/integrations/plugin.ts +215 -8
- package/corpus/core/src/integrations/types.ts +37 -0
- package/corpus/core/src/integrations/webhook-handler.ts +1 -0
- package/corpus/core/src/mcp/builtin-tools.ts +5 -2
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +4 -1
- package/corpus/templates/analytics/AGENTS.md +9 -9
- 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/server/handlers/session-replay.ts +15 -9
- package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +23 -5
- package/corpus/templates/analytics/server/lib/session-replay.ts +26 -5
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +4 -1
- 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/dist/a2a/handlers.d.ts.map +1 -1
- package/dist/a2a/handlers.js +6 -6
- package/dist/a2a/handlers.js.map +1 -1
- package/dist/a2a/task-store.d.ts +12 -0
- package/dist/a2a/task-store.d.ts.map +1 -1
- package/dist/a2a/task-store.js +54 -0
- 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 +71 -9
- package/dist/client/session-replay.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/adapters/slack.d.ts.map +1 -1
- package/dist/integrations/adapters/slack.js +94 -11
- package/dist/integrations/adapters/slack.js.map +1 -1
- package/dist/integrations/identity.d.ts +19 -2
- package/dist/integrations/identity.d.ts.map +1 -1
- package/dist/integrations/identity.js +76 -34
- package/dist/integrations/identity.js.map +1 -1
- package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
- package/dist/integrations/pending-tasks-retry-job.js +2 -1
- package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
- package/dist/integrations/pending-tasks-store.js +1 -1
- package/dist/integrations/pending-tasks-store.js.map +1 -1
- package/dist/integrations/plugin.d.ts.map +1 -1
- package/dist/integrations/plugin.js +165 -9
- package/dist/integrations/plugin.js.map +1 -1
- package/dist/integrations/types.d.ts +32 -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/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/notifications/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/docs/content/external-agents.mdx +22 -12
- package/docs/content/locales/ar-SA/external-agents.mdx +8 -2
- package/docs/content/locales/de-DE/external-agents.mdx +8 -2
- package/docs/content/locales/es-ES/external-agents.mdx +8 -2
- package/docs/content/locales/fr-FR/external-agents.mdx +8 -2
- package/docs/content/locales/hi-IN/external-agents.mdx +8 -2
- package/docs/content/locales/ja-JP/external-agents.mdx +8 -2
- package/docs/content/locales/ko-KR/external-agents.mdx +8 -2
- package/docs/content/locales/pt-BR/external-agents.mdx +8 -2
- package/docs/content/locales/zh-CN/external-agents.mdx +8 -2
- package/docs/content/locales/zh-TW/external-agents.mdx +8 -2
- package/package.json +1 -1
|
@@ -65,6 +65,8 @@ interface RrwebRecordModule {
|
|
|
65
65
|
interface SessionReplayState {
|
|
66
66
|
active: boolean;
|
|
67
67
|
startPromise: Promise<SessionReplayStartResult> | null;
|
|
68
|
+
/** Invalidates deferred recorder startup when stop is requested mid-start. */
|
|
69
|
+
startGeneration: number;
|
|
68
70
|
replayId: string | null;
|
|
69
71
|
startedAtMs: number | null;
|
|
70
72
|
sequence: number;
|
|
@@ -72,6 +74,8 @@ interface SessionReplayState {
|
|
|
72
74
|
queue: QueuedReplayEvent[];
|
|
73
75
|
queuedBytes: number;
|
|
74
76
|
retryBatches: QueuedReplayEvent[][];
|
|
77
|
+
/** Consecutive retryable 4xx responses for the current recording episode. */
|
|
78
|
+
transientClientErrorFailures: number;
|
|
75
79
|
flushTimer: number | null;
|
|
76
80
|
maxDurationTimer: number | null;
|
|
77
81
|
flushing: boolean;
|
|
@@ -432,12 +436,14 @@ function getState(): SessionReplayState {
|
|
|
432
436
|
g[SESSION_REPLAY_STATE_KEY] = {
|
|
433
437
|
active: false,
|
|
434
438
|
startPromise: null,
|
|
439
|
+
startGeneration: 0,
|
|
435
440
|
replayId: null,
|
|
436
441
|
startedAtMs: null,
|
|
437
442
|
sequence: 0,
|
|
438
443
|
queue: [],
|
|
439
444
|
queuedBytes: 0,
|
|
440
445
|
retryBatches: [],
|
|
446
|
+
transientClientErrorFailures: 0,
|
|
441
447
|
flushTimer: null,
|
|
442
448
|
maxDurationTimer: null,
|
|
443
449
|
flushing: false,
|
|
@@ -460,10 +466,12 @@ function getState(): SessionReplayState {
|
|
|
460
466
|
const state = g[SESSION_REPLAY_STATE_KEY]!;
|
|
461
467
|
// Keep Vite HMR safe when an older recorder state survives a module reload.
|
|
462
468
|
state.resourceNodes ??= new Map();
|
|
469
|
+
state.transientClientErrorFailures ??= 0;
|
|
463
470
|
state.restoreIframeBridge ??= null;
|
|
464
471
|
state.pendingFlushReason ??= null;
|
|
465
472
|
state.pendingFlushWaiters ??= [];
|
|
466
473
|
state.awaitingFullSnapshot ??= false;
|
|
474
|
+
state.startGeneration ??= 0;
|
|
467
475
|
return state;
|
|
468
476
|
}
|
|
469
477
|
|
|
@@ -1452,12 +1460,19 @@ class ReplayUploadHttpError extends Error {
|
|
|
1452
1460
|
}
|
|
1453
1461
|
}
|
|
1454
1462
|
|
|
1455
|
-
/** 4xx statuses where retrying the exact same batch can never succeed
|
|
1456
|
-
*
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1463
|
+
/** 4xx statuses where retrying the exact same batch can never succeed.
|
|
1464
|
+
* Keep this deliberately narrow: 401/403/404 can be temporary during auth or
|
|
1465
|
+
* deploy transitions, so they get a small retry budget before the rejected
|
|
1466
|
+
* episode is stopped. The budget prevents a persistent configuration failure
|
|
1467
|
+
* from pinning retryBatches while rrweb events grow without bound. */
|
|
1459
1468
|
function isDefinitiveReplayUploadClientError(status: number): boolean {
|
|
1460
|
-
return status
|
|
1469
|
+
return status === 400 || status === 409 || status === 413 || status === 422;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const MAX_TRANSIENT_REPLAY_CLIENT_FAILURES = 3;
|
|
1473
|
+
|
|
1474
|
+
function isTransientReplayUploadClientError(status: number): boolean {
|
|
1475
|
+
return status === 401 || status === 403 || status === 404;
|
|
1461
1476
|
}
|
|
1462
1477
|
|
|
1463
1478
|
async function sendReplayUpload(
|
|
@@ -1733,6 +1748,7 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
|
|
|
1733
1748
|
});
|
|
1734
1749
|
if (!reservedSequence) advanceReplaySequence(state, payload);
|
|
1735
1750
|
state.automaticConflictRestartAttempted = false;
|
|
1751
|
+
state.transientClientErrorFailures = 0;
|
|
1736
1752
|
uploaded = true;
|
|
1737
1753
|
} catch (error) {
|
|
1738
1754
|
if (reservedSequence) rollbackReplaySequenceReservation(state, payload);
|
|
@@ -1745,9 +1761,22 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
|
|
|
1745
1761
|
const splitBatch = rejectedStatus === 413 ? splitReplayBatch(events) : null;
|
|
1746
1762
|
const isUnsplittableOversizedBatch =
|
|
1747
1763
|
rejectedStatus === 413 && splitBatch === null;
|
|
1764
|
+
const isTransientClientError =
|
|
1765
|
+
rejectedStatus !== null &&
|
|
1766
|
+
isTransientReplayUploadClientError(rejectedStatus);
|
|
1767
|
+
if (isTransientClientError) {
|
|
1768
|
+
state.transientClientErrorFailures += 1;
|
|
1769
|
+
} else {
|
|
1770
|
+
state.transientClientErrorFailures = 0;
|
|
1771
|
+
}
|
|
1772
|
+
const exhaustedTransientClientRetries =
|
|
1773
|
+
isTransientClientError &&
|
|
1774
|
+
state.transientClientErrorFailures >=
|
|
1775
|
+
MAX_TRANSIENT_REPLAY_CLIENT_FAILURES;
|
|
1748
1776
|
const isDefinitiveClientError =
|
|
1749
1777
|
error instanceof ReplayUploadHttpError &&
|
|
1750
|
-
isDefinitiveReplayUploadClientError(error.status)
|
|
1778
|
+
(isDefinitiveReplayUploadClientError(error.status) ||
|
|
1779
|
+
exhaustedTransientClientRetries) &&
|
|
1751
1780
|
!splitBatch &&
|
|
1752
1781
|
!isUnsplittableOversizedBatch;
|
|
1753
1782
|
if (splitBatch) {
|
|
@@ -1903,6 +1932,31 @@ async function restartSessionReplayAfterConflict(
|
|
|
1903
1932
|
state: SessionReplayState,
|
|
1904
1933
|
options: NormalizedSessionReplayOptions,
|
|
1905
1934
|
sessionId: string,
|
|
1935
|
+
): Promise<SessionReplayStartResult> {
|
|
1936
|
+
// Share the public-start mutex. A consumer may call start in the await gap
|
|
1937
|
+
// after the rejected recorder stops; both paths must converge on one rrweb
|
|
1938
|
+
// instance rather than racing two fresh identities.
|
|
1939
|
+
if (state.startPromise) return state.startPromise;
|
|
1940
|
+
|
|
1941
|
+
const startGeneration = ++state.startGeneration;
|
|
1942
|
+
let startPromise: Promise<SessionReplayStartResult>;
|
|
1943
|
+
startPromise = restartSessionReplayAfterConflictInternal(
|
|
1944
|
+
state,
|
|
1945
|
+
options,
|
|
1946
|
+
sessionId,
|
|
1947
|
+
startGeneration,
|
|
1948
|
+
).finally(() => {
|
|
1949
|
+
if (state.startPromise === startPromise) state.startPromise = null;
|
|
1950
|
+
});
|
|
1951
|
+
state.startPromise = startPromise;
|
|
1952
|
+
return startPromise;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
async function restartSessionReplayAfterConflictInternal(
|
|
1956
|
+
state: SessionReplayState,
|
|
1957
|
+
options: NormalizedSessionReplayOptions,
|
|
1958
|
+
sessionId: string,
|
|
1959
|
+
startGeneration: number,
|
|
1906
1960
|
): Promise<SessionReplayStartResult> {
|
|
1907
1961
|
if (options.shouldStart && !options.shouldStart()) {
|
|
1908
1962
|
return { started: false, reason: "disabled", sessionId, sampled: true };
|
|
@@ -1932,6 +1986,7 @@ async function restartSessionReplayAfterConflict(
|
|
|
1932
1986
|
sessionId,
|
|
1933
1987
|
true,
|
|
1934
1988
|
initialProperties,
|
|
1989
|
+
startGeneration,
|
|
1935
1990
|
);
|
|
1936
1991
|
}
|
|
1937
1992
|
|
|
@@ -2879,6 +2934,12 @@ export async function startSessionReplay(
|
|
|
2879
2934
|
}
|
|
2880
2935
|
if (state.startPromise) return state.startPromise;
|
|
2881
2936
|
|
|
2937
|
+
// This is a new caller-initiated recording episode. A prior episode's
|
|
2938
|
+
// conflict-loop guard must not prevent this one from recovering once.
|
|
2939
|
+
state.automaticConflictRestartAttempted = false;
|
|
2940
|
+
state.transientClientErrorFailures = 0;
|
|
2941
|
+
const startGeneration = ++state.startGeneration;
|
|
2942
|
+
|
|
2882
2943
|
let startPromise: Promise<SessionReplayStartResult>;
|
|
2883
2944
|
startPromise = startSessionReplayRecorder(
|
|
2884
2945
|
state,
|
|
@@ -2886,6 +2947,7 @@ export async function startSessionReplay(
|
|
|
2886
2947
|
sessionId,
|
|
2887
2948
|
sampled,
|
|
2888
2949
|
initialProperties,
|
|
2950
|
+
startGeneration,
|
|
2889
2951
|
).finally(() => {
|
|
2890
2952
|
if (state.startPromise === startPromise) {
|
|
2891
2953
|
state.startPromise = null;
|
|
@@ -2901,6 +2963,7 @@ async function startSessionReplayRecorder(
|
|
|
2901
2963
|
sessionId: string,
|
|
2902
2964
|
sampled: boolean,
|
|
2903
2965
|
initialProperties: Record<string, unknown> | undefined,
|
|
2966
|
+
startGeneration: number,
|
|
2904
2967
|
): Promise<SessionReplayStartResult> {
|
|
2905
2968
|
if (state.active && state.replayId) {
|
|
2906
2969
|
return {
|
|
@@ -2918,6 +2981,9 @@ async function startSessionReplayRecorder(
|
|
|
2918
2981
|
} catch {
|
|
2919
2982
|
return { started: false, reason: "import-failed", sessionId, sampled };
|
|
2920
2983
|
}
|
|
2984
|
+
if (state.startGeneration !== startGeneration) {
|
|
2985
|
+
return { started: false, reason: "disabled", sessionId, sampled };
|
|
2986
|
+
}
|
|
2921
2987
|
if (normalized.shouldStart && !normalized.shouldStart()) {
|
|
2922
2988
|
return { started: false, reason: "disabled", sessionId, sampled };
|
|
2923
2989
|
}
|
|
@@ -2950,6 +3016,20 @@ async function startSessionReplayRecorder(
|
|
|
2950
3016
|
};
|
|
2951
3017
|
}
|
|
2952
3018
|
}
|
|
3019
|
+
// stopSessionReplay may be called while the duplicate-tab probe is waiting.
|
|
3020
|
+
// Recheck both cancellation and the caller's live eligibility before rrweb
|
|
3021
|
+
// is activated so a deferred start cannot escape a route/auth teardown.
|
|
3022
|
+
if (
|
|
3023
|
+
state.startGeneration !== startGeneration ||
|
|
3024
|
+
(normalized.shouldStart && !normalized.shouldStart())
|
|
3025
|
+
) {
|
|
3026
|
+
try {
|
|
3027
|
+
replayChannel?.close();
|
|
3028
|
+
} catch {
|
|
3029
|
+
// best-effort cleanup
|
|
3030
|
+
}
|
|
3031
|
+
return { started: false, reason: "disabled", sessionId, sampled };
|
|
3032
|
+
}
|
|
2953
3033
|
state.options = normalized;
|
|
2954
3034
|
state.replayId = replaySession.replayId;
|
|
2955
3035
|
state.startedAtMs = replaySession.startedAtMs;
|
|
@@ -3049,6 +3129,10 @@ async function startSessionReplayRecorder(
|
|
|
3049
3129
|
|
|
3050
3130
|
export async function stopSessionReplay(reason = "manual"): Promise<void> {
|
|
3051
3131
|
const state = getState();
|
|
3132
|
+
// Invalidate an import/probe that has not set `active` yet. Without this,
|
|
3133
|
+
// stop during the duplicated-tab claim window was a no-op and rrweb started
|
|
3134
|
+
// after the caller believed recording had been disabled.
|
|
3135
|
+
state.startGeneration += 1;
|
|
3052
3136
|
if (!state.active) return;
|
|
3053
3137
|
// Restore console/fetch/XHR before tearing down the recorder: the restore
|
|
3054
3138
|
// flushes any pending collapsed console duplicate, which must still be able
|
|
@@ -56,6 +56,31 @@ const slackIdentityCache = new Map<
|
|
|
56
56
|
{ identity: SlackUserIdentity | null; expiresAt: number }
|
|
57
57
|
>();
|
|
58
58
|
|
|
59
|
+
// Deduped system notices send at most once per key per TTL so senders are
|
|
60
|
+
// informed without being spammed per message. Callers pick the window: the
|
|
61
|
+
// anonymous-tier heads-up uses the default day-long TTL; decline replies pass
|
|
62
|
+
// a short TTL so a persistent condition still reminds the sender occasionally.
|
|
63
|
+
const SLACK_SYSTEM_NOTICE_DEDUPE_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
64
|
+
const SLACK_SYSTEM_NOTICE_CACHE_MAX_ENTRIES = 1_000;
|
|
65
|
+
const slackSystemNoticeCache = new Map<string, number>();
|
|
66
|
+
|
|
67
|
+
/** Returns true when a deduped notice should send now, claiming the slot. */
|
|
68
|
+
function claimSlackSystemNoticeSlot(
|
|
69
|
+
key: string,
|
|
70
|
+
ttlMs = SLACK_SYSTEM_NOTICE_DEDUPE_TTL_MS,
|
|
71
|
+
): boolean {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const expiresAt = slackSystemNoticeCache.get(key);
|
|
74
|
+
if (expiresAt && expiresAt > now) return false;
|
|
75
|
+
if (expiresAt) slackSystemNoticeCache.delete(key);
|
|
76
|
+
if (slackSystemNoticeCache.size >= SLACK_SYSTEM_NOTICE_CACHE_MAX_ENTRIES) {
|
|
77
|
+
const oldestKey = slackSystemNoticeCache.keys().next().value;
|
|
78
|
+
if (oldestKey) slackSystemNoticeCache.delete(oldestKey);
|
|
79
|
+
}
|
|
80
|
+
slackSystemNoticeCache.set(key, now + ttlMs);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
export interface SlackAdapterOptions {
|
|
60
85
|
/** Resolve the bot token for the exact Slack installation. */
|
|
61
86
|
resolveBotToken?: (incoming: IncomingMessage) => Promise<string | undefined>;
|
|
@@ -264,7 +289,10 @@ export function slackAdapter(
|
|
|
264
289
|
triggerKind: isDm ? "dm" : isMention ? "mention" : "thread_reply",
|
|
265
290
|
conversationType: isDm ? "dm" : "unknown",
|
|
266
291
|
tenantId: teamId,
|
|
267
|
-
|
|
292
|
+
// The signed Slack envelope authenticates the workspace, not the
|
|
293
|
+
// sender's membership tier. `users.info` hydration below is the
|
|
294
|
+
// authority that may promote this to a verified member.
|
|
295
|
+
actorTrust: { memberType: "unknown", verified: false },
|
|
268
296
|
platformContext: {
|
|
269
297
|
channelId: e.channel,
|
|
270
298
|
channelType: e.channel_type,
|
|
@@ -493,6 +521,48 @@ export function slackAdapter(
|
|
|
493
521
|
}
|
|
494
522
|
},
|
|
495
523
|
|
|
524
|
+
async sendSystemNotice(
|
|
525
|
+
incoming: IncomingMessage,
|
|
526
|
+
text: string,
|
|
527
|
+
opts?: { dedupeKey?: string; dedupeTtlMs?: number },
|
|
528
|
+
): Promise<void> {
|
|
529
|
+
if (!text.trim()) return;
|
|
530
|
+
const dedupeKey = opts?.dedupeKey;
|
|
531
|
+
if (
|
|
532
|
+
dedupeKey &&
|
|
533
|
+
!claimSlackSystemNoticeSlot(dedupeKey, opts?.dedupeTtlMs)
|
|
534
|
+
) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
const token = await resolveBotToken(incoming);
|
|
539
|
+
if (!token) {
|
|
540
|
+
if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
|
|
541
|
+
throw new Error("Slack bot token not configured for system notice");
|
|
542
|
+
}
|
|
543
|
+
const channelId = incoming.platformContext.channelId;
|
|
544
|
+
if (typeof channelId !== "string" || !channelId) {
|
|
545
|
+
if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
|
|
546
|
+
throw new Error("Slack channel id missing for system notice");
|
|
547
|
+
}
|
|
548
|
+
const threadTs =
|
|
549
|
+
typeof incoming.platformContext.threadTs === "string"
|
|
550
|
+
? incoming.platformContext.threadTs
|
|
551
|
+
: undefined;
|
|
552
|
+
await postFresh(token, channelId, threadTs, {
|
|
553
|
+
text,
|
|
554
|
+
unfurl_links: false,
|
|
555
|
+
unfurl_media: false,
|
|
556
|
+
mrkdwn: true,
|
|
557
|
+
});
|
|
558
|
+
} catch (error) {
|
|
559
|
+
// A failed delivery did not inform the sender, so release the slot and
|
|
560
|
+
// allow the next message to retry instead of suppressing notices for a day.
|
|
561
|
+
if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
|
|
496
566
|
async sendMessageToTarget(
|
|
497
567
|
message: OutgoingMessage,
|
|
498
568
|
target: OutboundTarget,
|
|
@@ -1137,15 +1207,18 @@ async function resolveSlackUserIdentity(
|
|
|
1137
1207
|
: typeof user.user.name === "string" && user.user.name.trim()
|
|
1138
1208
|
? user.user.name.trim()
|
|
1139
1209
|
: null,
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1210
|
+
// is_stranger marks Slack Connect DM participants from another
|
|
1211
|
+
// workspace; they must map to "external", never "member".
|
|
1212
|
+
memberType:
|
|
1213
|
+
user.user.is_stranger || user.user.is_ultra_restricted
|
|
1214
|
+
? "external"
|
|
1215
|
+
: user.user.is_restricted
|
|
1216
|
+
? "guest"
|
|
1217
|
+
: user.user.is_owner
|
|
1218
|
+
? "owner"
|
|
1219
|
+
: user.user.is_admin
|
|
1220
|
+
? "admin"
|
|
1221
|
+
: "member",
|
|
1149
1222
|
}
|
|
1150
1223
|
: null;
|
|
1151
1224
|
if (slackIdentityCache.size >= SLACK_IDENTITY_CACHE_MAX_ENTRIES) {
|
|
@@ -1168,7 +1241,24 @@ async function hydrateSlackIdentity(
|
|
|
1168
1241
|
incoming: IncomingMessage,
|
|
1169
1242
|
): Promise<IncomingMessage> {
|
|
1170
1243
|
const identity = await resolveSlackUserIdentity(token, incoming);
|
|
1171
|
-
if (!identity)
|
|
1244
|
+
if (!identity) {
|
|
1245
|
+
// Context hydration can call users.info again after the webhook identity
|
|
1246
|
+
// pass. A transient failure on that second lookup is not evidence that a
|
|
1247
|
+
// previously verified sender became unverified; preserve the stronger
|
|
1248
|
+
// identity instead of replacing it with a negative-cache result.
|
|
1249
|
+
if (
|
|
1250
|
+
incoming.senderVerified === true &&
|
|
1251
|
+
incoming.senderEmail?.trim() &&
|
|
1252
|
+
incoming.actorTrust?.verified === true
|
|
1253
|
+
) {
|
|
1254
|
+
return incoming;
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
...incoming,
|
|
1258
|
+
senderVerified: false,
|
|
1259
|
+
actorTrust: { memberType: "unknown", verified: false },
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1172
1262
|
return {
|
|
1173
1263
|
...incoming,
|
|
1174
1264
|
...(identity.name ? { senderName: identity.name } : {}),
|
|
@@ -1299,8 +1389,6 @@ async function hydrateSlackContext(
|
|
|
1299
1389
|
: [];
|
|
1300
1390
|
|
|
1301
1391
|
const profile = identity;
|
|
1302
|
-
const isGuest = identity?.memberType === "guest";
|
|
1303
|
-
const isExternal = identity?.memberType === "external";
|
|
1304
1392
|
const isPrivate = conversation?.channel?.is_private === true;
|
|
1305
1393
|
const isExternalShared = conversation?.channel?.is_ext_shared === true;
|
|
1306
1394
|
const isMpim = conversation?.channel?.is_mpim === true;
|
|
@@ -1348,12 +1436,15 @@ async function hydrateSlackContext(
|
|
|
1348
1436
|
? { senderEmail: profile.email, senderVerified: true }
|
|
1349
1437
|
: { senderVerified: false }),
|
|
1350
1438
|
conversationType,
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
verified: true
|
|
1356
|
-
|
|
1439
|
+
// Conversation hydration must not promote a caller when users.info was
|
|
1440
|
+
// unavailable. Preserve the result of the earlier identity-only hydration
|
|
1441
|
+
// unless this request independently resolved the Slack user.
|
|
1442
|
+
actorTrust: identity
|
|
1443
|
+
? { memberType: identity.memberType, verified: true }
|
|
1444
|
+
: (incoming.actorTrust ?? {
|
|
1445
|
+
memberType: "unknown",
|
|
1446
|
+
verified: false,
|
|
1447
|
+
}),
|
|
1357
1448
|
contextMessages: hydratedMessages,
|
|
1358
1449
|
...(hydratedFiles.length ? { files: hydratedFiles.slice(0, 20) } : {}),
|
|
1359
1450
|
platformContext: {
|
|
@@ -7,6 +7,34 @@ import {
|
|
|
7
7
|
import { slackInstallationKey } from "./slack-oauth.js";
|
|
8
8
|
import type { IncomingMessage, IntegrationExecutionContext } from "./types.js";
|
|
9
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
|
+
|
|
10
38
|
function serviceOwner(platform: string): string {
|
|
11
39
|
return `integration@${platform}`;
|
|
12
40
|
}
|
|
@@ -47,17 +75,13 @@ async function resolveSlackInstallation(incoming: IncomingMessage) {
|
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
async function isMemberOfOrg(email: string, orgId: string): Promise<boolean> {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
sql: `SELECT 1 FROM org_members
|
|
78
|
+
const { rows } = await getDbExec().execute({
|
|
79
|
+
sql: `SELECT 1 FROM org_members
|
|
53
80
|
WHERE org_id = ? AND LOWER(email) = ?
|
|
54
81
|
LIMIT 1`,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
} catch {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
82
|
+
args: [orgId, email],
|
|
83
|
+
});
|
|
84
|
+
return rows.length > 0;
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
/**
|
|
@@ -65,8 +89,13 @@ async function isMemberOfOrg(email: string, orgId: string): Promise<boolean> {
|
|
|
65
89
|
*
|
|
66
90
|
* Slack DMs become a user principal only after the adapter has verified the
|
|
67
91
|
* sender email and the email is already a member of the managed installation's
|
|
68
|
-
* Agent Native organization.
|
|
69
|
-
*
|
|
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.
|
|
70
99
|
*/
|
|
71
100
|
export async function resolveDefaultIntegrationExecutionContext(
|
|
72
101
|
incoming: IncomingMessage,
|
|
@@ -85,48 +114,83 @@ export async function resolveDefaultIntegrationExecutionContext(
|
|
|
85
114
|
};
|
|
86
115
|
}
|
|
87
116
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
throw new Error(
|
|
96
|
-
"Slack DM sender identity could not be verified; refusing to run with a service principal.",
|
|
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.",
|
|
97
124
|
);
|
|
98
125
|
}
|
|
99
126
|
if (
|
|
100
|
-
incoming.actorTrust
|
|
101
|
-
incoming.actorTrust
|
|
127
|
+
incoming.actorTrust.memberType === "guest" ||
|
|
128
|
+
incoming.actorTrust.memberType === "external"
|
|
102
129
|
) {
|
|
103
|
-
throw new
|
|
104
|
-
"
|
|
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.",
|
|
105
134
|
);
|
|
106
135
|
}
|
|
107
136
|
if (!installation?.orgId) {
|
|
108
|
-
throw new
|
|
137
|
+
throw new IntegrationIdentityDeclinedError(
|
|
138
|
+
"unlinked-workspace",
|
|
109
139
|
"Slack workspace is not connected to an Agent Native organization.",
|
|
140
|
+
"This Slack workspace isn't connected to an organization yet.",
|
|
110
141
|
);
|
|
111
142
|
}
|
|
112
143
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
};
|
|
117
178
|
}
|
|
118
179
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
);
|
|
126
189
|
return {
|
|
127
|
-
ownerEmail:
|
|
128
|
-
orgId:
|
|
129
|
-
principalType: "
|
|
190
|
+
ownerEmail: serviceOwner(incoming.platform),
|
|
191
|
+
orgId: installation.orgId,
|
|
192
|
+
principalType: "service",
|
|
130
193
|
installationId: installation.id,
|
|
194
|
+
anonymousMember: true,
|
|
131
195
|
};
|
|
132
196
|
}
|
|
@@ -115,7 +115,8 @@ export async function retryStuckPendingTasks(
|
|
|
115
115
|
SET status = 'failed',
|
|
116
116
|
updated_at = ?,
|
|
117
117
|
error_message = COALESCE(error_message, ?),
|
|
118
|
-
payload = '{}'
|
|
118
|
+
payload = '{}',
|
|
119
|
+
external_event_key = NULL
|
|
119
120
|
WHERE id = ?
|
|
120
121
|
AND status = ?
|
|
121
122
|
`,
|
|
@@ -364,7 +364,7 @@ export async function markTaskFailed(
|
|
|
364
364
|
const now = Date.now();
|
|
365
365
|
await client.execute({
|
|
366
366
|
sql: `UPDATE integration_pending_tasks
|
|
367
|
-
SET status = ?, updated_at = ?, error_message = ?, payload =
|
|
367
|
+
SET status = ?, updated_at = ?, error_message = ?, payload = ?, external_event_key = NULL
|
|
368
368
|
WHERE id = ?`,
|
|
369
369
|
args: ["failed", now, errorMessage.slice(0, 2000), "{}", id],
|
|
370
370
|
});
|