@openclaw/msteams 2026.3.1 → 2026.3.2

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.
@@ -1,6 +1,8 @@
1
+ import { lookup } from "node:dns/promises";
1
2
  import {
2
3
  buildHostnameAllowlistPolicyFromSuffixAllowlist,
3
4
  isHttpsUrlAllowedByHostnameSuffixAllowlist,
5
+ isPrivateIpAddress,
4
6
  normalizeHostnameSuffixAllowlist,
5
7
  } from "openclaw/plugin-sdk";
6
8
  import type { SsrFPolicy } from "openclaw/plugin-sdk";
@@ -264,10 +266,194 @@ export function resolveAuthAllowedHosts(input?: string[]): string[] {
264
266
  return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST);
265
267
  }
266
268
 
269
+ export type MSTeamsAttachmentFetchPolicy = {
270
+ allowHosts: string[];
271
+ authAllowHosts: string[];
272
+ };
273
+
274
+ export function resolveAttachmentFetchPolicy(params?: {
275
+ allowHosts?: string[];
276
+ authAllowHosts?: string[];
277
+ }): MSTeamsAttachmentFetchPolicy {
278
+ return {
279
+ allowHosts: resolveAllowedHosts(params?.allowHosts),
280
+ authAllowHosts: resolveAuthAllowedHosts(params?.authAllowHosts),
281
+ };
282
+ }
283
+
267
284
  export function isUrlAllowed(url: string, allowlist: string[]): boolean {
268
285
  return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
269
286
  }
270
287
 
288
+ export function applyAuthorizationHeaderForUrl(params: {
289
+ headers: Headers;
290
+ url: string;
291
+ authAllowHosts: string[];
292
+ bearerToken?: string;
293
+ }): void {
294
+ if (!params.bearerToken) {
295
+ params.headers.delete("Authorization");
296
+ return;
297
+ }
298
+ if (isUrlAllowed(params.url, params.authAllowHosts)) {
299
+ params.headers.set("Authorization", `Bearer ${params.bearerToken}`);
300
+ return;
301
+ }
302
+ params.headers.delete("Authorization");
303
+ }
304
+
271
305
  export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
272
306
  return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
273
307
  }
308
+
309
+ /**
310
+ * Returns true if the given IPv4 or IPv6 address is in a private, loopback,
311
+ * or link-local range that must never be reached from media downloads.
312
+ *
313
+ * Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
314
+ * expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
315
+ * parse errors.
316
+ */
317
+ export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
318
+
319
+ /**
320
+ * Resolve a hostname via DNS and reject private/reserved IPs.
321
+ * Throws if the resolved IP is private or resolution fails.
322
+ */
323
+ export async function resolveAndValidateIP(
324
+ hostname: string,
325
+ resolveFn?: (hostname: string) => Promise<{ address: string }>,
326
+ ): Promise<string> {
327
+ const resolve = resolveFn ?? lookup;
328
+ let resolved: { address: string };
329
+ try {
330
+ resolved = await resolve(hostname);
331
+ } catch {
332
+ throw new Error(`DNS resolution failed for "${hostname}"`);
333
+ }
334
+ if (isPrivateOrReservedIP(resolved.address)) {
335
+ throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
336
+ }
337
+ return resolved.address;
338
+ }
339
+
340
+ /** Maximum number of redirects to follow in safeFetch. */
341
+ const MAX_SAFE_REDIRECTS = 5;
342
+
343
+ /**
344
+ * Fetch a URL with redirect: "manual", validating each redirect target
345
+ * against the hostname allowlist and optional DNS-resolved IP (anti-SSRF).
346
+ *
347
+ * This prevents:
348
+ * - Auto-following redirects to non-allowlisted hosts
349
+ * - DNS rebinding attacks when a lookup function is provided
350
+ */
351
+ export async function safeFetch(params: {
352
+ url: string;
353
+ allowHosts: string[];
354
+ /**
355
+ * Optional allowlist for forwarding Authorization across redirects.
356
+ * When set, Authorization is stripped before following redirects to hosts
357
+ * outside this list.
358
+ */
359
+ authorizationAllowHosts?: string[];
360
+ fetchFn?: typeof fetch;
361
+ requestInit?: RequestInit;
362
+ resolveFn?: (hostname: string) => Promise<{ address: string }>;
363
+ }): Promise<Response> {
364
+ const fetchFn = params.fetchFn ?? fetch;
365
+ const resolveFn = params.resolveFn;
366
+ const hasDispatcher = Boolean(
367
+ params.requestInit &&
368
+ typeof params.requestInit === "object" &&
369
+ "dispatcher" in (params.requestInit as Record<string, unknown>),
370
+ );
371
+ const currentHeaders = new Headers(params.requestInit?.headers);
372
+ let currentUrl = params.url;
373
+
374
+ if (!isUrlAllowed(currentUrl, params.allowHosts)) {
375
+ throw new Error(`Initial download URL blocked: ${currentUrl}`);
376
+ }
377
+
378
+ if (resolveFn) {
379
+ try {
380
+ const initialHost = new URL(currentUrl).hostname;
381
+ await resolveAndValidateIP(initialHost, resolveFn);
382
+ } catch {
383
+ throw new Error(`Initial download URL blocked: ${currentUrl}`);
384
+ }
385
+ }
386
+
387
+ for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
388
+ const res = await fetchFn(currentUrl, {
389
+ ...params.requestInit,
390
+ headers: currentHeaders,
391
+ redirect: "manual",
392
+ });
393
+
394
+ if (![301, 302, 303, 307, 308].includes(res.status)) {
395
+ return res;
396
+ }
397
+
398
+ const location = res.headers.get("location");
399
+ if (!location) {
400
+ return res;
401
+ }
402
+
403
+ let redirectUrl: string;
404
+ try {
405
+ redirectUrl = new URL(location, currentUrl).toString();
406
+ } catch {
407
+ throw new Error(`Invalid redirect URL: ${location}`);
408
+ }
409
+
410
+ // Validate redirect target against hostname allowlist
411
+ if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
412
+ throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
413
+ }
414
+
415
+ // Prevent credential bleed: only keep Authorization on redirect hops that
416
+ // are explicitly auth-allowlisted.
417
+ if (
418
+ currentHeaders.has("authorization") &&
419
+ params.authorizationAllowHosts &&
420
+ !isUrlAllowed(redirectUrl, params.authorizationAllowHosts)
421
+ ) {
422
+ currentHeaders.delete("authorization");
423
+ }
424
+
425
+ // When a pinned dispatcher is already injected by an upstream guard
426
+ // (for example fetchWithSsrFGuard), let that guard own redirect handling
427
+ // after this allowlist validation step.
428
+ if (hasDispatcher) {
429
+ return res;
430
+ }
431
+
432
+ // Validate redirect target's resolved IP
433
+ if (resolveFn) {
434
+ const redirectHost = new URL(redirectUrl).hostname;
435
+ await resolveAndValidateIP(redirectHost, resolveFn);
436
+ }
437
+
438
+ currentUrl = redirectUrl;
439
+ }
440
+
441
+ throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
442
+ }
443
+
444
+ export async function safeFetchWithPolicy(params: {
445
+ url: string;
446
+ policy: MSTeamsAttachmentFetchPolicy;
447
+ fetchFn?: typeof fetch;
448
+ requestInit?: RequestInit;
449
+ resolveFn?: (hostname: string) => Promise<{ address: string }>;
450
+ }): Promise<Response> {
451
+ return await safeFetch({
452
+ url: params.url,
453
+ allowHosts: params.policy.allowHosts,
454
+ authorizationAllowHosts: params.policy.authAllowHosts,
455
+ fetchFn: params.fetchFn,
456
+ requestInit: params.requestInit,
457
+ resolveFn: params.resolveFn,
458
+ });
459
+ }
@@ -1,5 +1,6 @@
1
1
  import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
3
4
  import {
4
5
  buildMSTeamsAttachmentPlaceholder,
5
6
  buildMSTeamsGraphMessageUrls,
@@ -46,7 +47,9 @@ type RemoteMediaFetchParams = {
46
47
 
47
48
  const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
48
49
  const saveMediaBufferMock = vi.fn(async () => ({
50
+ id: "saved.png",
49
51
  path: SAVED_PNG_PATH,
52
+ size: Buffer.byteLength(PNG_BUFFER),
50
53
  contentType: CONTENT_TYPE_IMAGE_PNG,
51
54
  }));
52
55
  const readRemoteMediaResponse = async (
@@ -106,19 +109,17 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
106
109
  throw new Error("too many redirects");
107
110
  });
108
111
 
109
- const runtimeStub = {
112
+ const runtimeStub: PluginRuntime = createPluginRuntimeMock({
110
113
  media: {
111
- detectMime: detectMimeMock as unknown as PluginRuntime["media"]["detectMime"],
114
+ detectMime: detectMimeMock,
112
115
  },
113
116
  channel: {
114
117
  media: {
115
- fetchRemoteMedia:
116
- fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
117
- saveMediaBuffer:
118
- saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
118
+ fetchRemoteMedia: fetchRemoteMediaMock,
119
+ saveMediaBuffer: saveMediaBufferMock,
119
120
  },
120
121
  },
121
- } as unknown as PluginRuntime;
122
+ });
122
123
 
123
124
  type DownloadAttachmentsParams = Parameters<typeof downloadMSTeamsAttachments>[0];
124
125
  type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
@@ -164,7 +165,13 @@ const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST
164
165
  const PNG_BUFFER = Buffer.from("png");
165
166
  const PNG_BASE64 = PNG_BUFFER.toString("base64");
166
167
  const PDF_BUFFER = Buffer.from("pdf");
167
- const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") });
168
+ const createTokenProvider = (
169
+ tokenOrResolver: string | ((scope: string) => string | Promise<string>) = "token",
170
+ ) => ({
171
+ getAccessToken: vi.fn(async (scope: string) =>
172
+ typeof tokenOrResolver === "function" ? await tokenOrResolver(scope) : tokenOrResolver,
173
+ ),
174
+ });
168
175
  const asSingleItemArray = <T>(value: T) => [value];
169
176
  const withLabel = <T extends object>(label: string, fields: T): T & LabeledCase => ({
170
177
  label,
@@ -434,7 +441,9 @@ const ATTACHMENT_DOWNLOAD_SUCCESS_CASES: AttachmentDownloadSuccessCase[] = [
434
441
  beforeDownload: () => {
435
442
  detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
436
443
  saveMediaBufferMock.mockResolvedValueOnce({
444
+ id: "saved.pdf",
437
445
  path: SAVED_PDF_PATH,
446
+ size: Buffer.byteLength(PDF_BUFFER),
438
447
  contentType: CONTENT_TYPE_APPLICATION_PDF,
439
448
  });
440
449
  },
@@ -694,6 +703,121 @@ describe("msteams attachments", () => {
694
703
  runAttachmentAuthRetryCase,
695
704
  );
696
705
 
706
+ it("preserves auth fallback when dispatcher-mode fetch returns a redirect", async () => {
707
+ const redirectedUrl = createTestUrl("redirected.png");
708
+ const tokenProvider = createTokenProvider();
709
+ const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
710
+ const hasAuth = Boolean(new Headers(opts?.headers).get("Authorization"));
711
+ if (url === TEST_URL_IMAGE) {
712
+ return hasAuth
713
+ ? createRedirectResponse(redirectedUrl)
714
+ : createTextResponse("unauthorized", 401);
715
+ }
716
+ if (url === redirectedUrl) {
717
+ return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
718
+ }
719
+ return createNotFoundResponse();
720
+ });
721
+
722
+ fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
723
+ const fetchFn = params.fetchImpl ?? fetch;
724
+ let currentUrl = params.url;
725
+ for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
726
+ const res = await fetchFn(currentUrl, {
727
+ redirect: "manual",
728
+ dispatcher: {},
729
+ } as RequestInit);
730
+ if (REDIRECT_STATUS_CODES.includes(res.status)) {
731
+ const location = res.headers.get("location");
732
+ if (!location) {
733
+ throw new Error("redirect missing location");
734
+ }
735
+ currentUrl = new URL(location, currentUrl).toString();
736
+ continue;
737
+ }
738
+ return readRemoteMediaResponse(res, params);
739
+ }
740
+ throw new Error("too many redirects");
741
+ });
742
+
743
+ const media = await downloadAttachmentsWithFetch(
744
+ createImageAttachments(TEST_URL_IMAGE),
745
+ fetchMock,
746
+ { tokenProvider, authAllowHosts: [TEST_HOST] },
747
+ );
748
+
749
+ expectAttachmentMediaLength(media, 1);
750
+ expect(tokenProvider.getAccessToken).toHaveBeenCalledOnce();
751
+ expect(fetchMock.mock.calls.map(([calledUrl]) => String(calledUrl))).toContain(redirectedUrl);
752
+ });
753
+
754
+ it("continues scope fallback after non-auth failure and succeeds on later scope", async () => {
755
+ let authAttempt = 0;
756
+ const tokenProvider = createTokenProvider((scope) => `token:${scope}`);
757
+ const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
758
+ const auth = new Headers(opts?.headers).get("Authorization");
759
+ if (!auth) {
760
+ return createTextResponse("unauthorized", 401);
761
+ }
762
+ authAttempt += 1;
763
+ if (authAttempt === 1) {
764
+ return createTextResponse("upstream transient", 500);
765
+ }
766
+ return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
767
+ });
768
+
769
+ const media = await downloadAttachmentsWithFetch(
770
+ createImageAttachments(TEST_URL_IMAGE),
771
+ fetchMock,
772
+ { tokenProvider, authAllowHosts: [TEST_HOST] },
773
+ );
774
+
775
+ expectAttachmentMediaLength(media, 1);
776
+ expect(tokenProvider.getAccessToken).toHaveBeenCalledTimes(2);
777
+ });
778
+
779
+ it("does not forward Authorization to redirects outside auth allowlist", async () => {
780
+ const tokenProvider = createTokenProvider("top-secret-token");
781
+ const graphFileUrl = createUrlForHost(GRAPH_HOST, "file");
782
+ const seen: Array<{ url: string; auth: string }> = [];
783
+ const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
784
+ const auth = new Headers(opts?.headers).get("Authorization") ?? "";
785
+ seen.push({ url, auth });
786
+ if (url === graphFileUrl && !auth) {
787
+ return new Response("unauthorized", { status: 401 });
788
+ }
789
+ if (url === graphFileUrl && auth) {
790
+ return new Response("", {
791
+ status: 302,
792
+ headers: { location: "https://attacker.azureedge.net/collect" },
793
+ });
794
+ }
795
+ if (url === "https://attacker.azureedge.net/collect") {
796
+ return new Response(Buffer.from("png"), {
797
+ status: 200,
798
+ headers: { "content-type": CONTENT_TYPE_IMAGE_PNG },
799
+ });
800
+ }
801
+ return createNotFoundResponse();
802
+ });
803
+
804
+ const media = await downloadMSTeamsAttachments(
805
+ buildDownloadParams([{ contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: graphFileUrl }], {
806
+ tokenProvider,
807
+ allowHosts: [GRAPH_HOST, AZUREEDGE_HOST],
808
+ authAllowHosts: [GRAPH_HOST],
809
+ fetchFn: asFetchFn(fetchMock),
810
+ }),
811
+ );
812
+
813
+ expectSingleMedia(media);
814
+ const redirected = seen.find(
815
+ (entry) => entry.url === "https://attacker.azureedge.net/collect",
816
+ );
817
+ expect(redirected).toBeDefined();
818
+ expect(redirected?.auth).toBe("");
819
+ });
820
+
697
821
  it("skips urls outside the allowlist", async () => {
698
822
  const fetchMock = vi.fn();
699
823
  const media = await downloadAttachmentsWithFetch(
@@ -744,6 +868,49 @@ describe("msteams attachments", () => {
744
868
  describe("downloadMSTeamsGraphMedia", () => {
745
869
  it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
746
870
 
871
+ it("does not forward Authorization for SharePoint redirects outside auth allowlist", async () => {
872
+ const tokenProvider = createTokenProvider("top-secret-token");
873
+ const escapedUrl = "https://example.com/collect";
874
+ const seen: Array<{ url: string; auth: string }> = [];
875
+ const referenceAttachment = createReferenceAttachment();
876
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
877
+ const url = String(input);
878
+ const auth = new Headers(init?.headers).get("Authorization") ?? "";
879
+ seen.push({ url, auth });
880
+
881
+ if (url === DEFAULT_MESSAGE_URL) {
882
+ return createJsonResponse({ attachments: [referenceAttachment] });
883
+ }
884
+ if (url === `${DEFAULT_MESSAGE_URL}/hostedContents`) {
885
+ return createGraphCollectionResponse([]);
886
+ }
887
+ if (url === `${DEFAULT_MESSAGE_URL}/attachments`) {
888
+ return createGraphCollectionResponse([referenceAttachment]);
889
+ }
890
+ if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
891
+ return createRedirectResponse(escapedUrl);
892
+ }
893
+ if (url === escapedUrl) {
894
+ return createPdfResponse();
895
+ }
896
+ return createNotFoundResponse();
897
+ });
898
+
899
+ const media = await downloadMSTeamsGraphMedia({
900
+ messageUrl: DEFAULT_MESSAGE_URL,
901
+ tokenProvider,
902
+ maxBytes: DEFAULT_MAX_BYTES,
903
+ allowHosts: [...DEFAULT_SHAREPOINT_ALLOW_HOSTS, "example.com"],
904
+ authAllowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
905
+ fetchFn: asFetchFn(fetchMock),
906
+ });
907
+
908
+ expectAttachmentMediaLength(media.media, 1);
909
+ const redirected = seen.find((entry) => entry.url === escapedUrl);
910
+ expect(redirected).toBeDefined();
911
+ expect(redirected?.auth).toBe("");
912
+ });
913
+
747
914
  it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
748
915
  const escapedUrl = "https://evil.example/internal.pdf";
749
916
  const { fetchMock, media } = await downloadGraphMediaWithMockOptions(
@@ -3,6 +3,7 @@ import {
3
3
  classifyMSTeamsSendError,
4
4
  formatMSTeamsSendErrorHint,
5
5
  formatUnknownError,
6
+ isRevokedProxyError,
6
7
  } from "./errors.js";
7
8
 
8
9
  describe("msteams errors", () => {
@@ -42,4 +43,28 @@ describe("msteams errors", () => {
42
43
  expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
43
44
  expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled");
44
45
  });
46
+
47
+ describe("isRevokedProxyError", () => {
48
+ it("returns true for revoked proxy TypeError", () => {
49
+ expect(
50
+ isRevokedProxyError(new TypeError("Cannot perform 'set' on a proxy that has been revoked")),
51
+ ).toBe(true);
52
+ expect(
53
+ isRevokedProxyError(new TypeError("Cannot perform 'get' on a proxy that has been revoked")),
54
+ ).toBe(true);
55
+ });
56
+
57
+ it("returns false for non-TypeError errors", () => {
58
+ expect(isRevokedProxyError(new Error("proxy that has been revoked"))).toBe(false);
59
+ });
60
+
61
+ it("returns false for unrelated TypeErrors", () => {
62
+ expect(isRevokedProxyError(new TypeError("undefined is not a function"))).toBe(false);
63
+ });
64
+
65
+ it("returns false for non-error values", () => {
66
+ expect(isRevokedProxyError(null)).toBe(false);
67
+ expect(isRevokedProxyError("proxy that has been revoked")).toBe(false);
68
+ });
69
+ });
45
70
  });
package/src/errors.ts CHANGED
@@ -174,6 +174,21 @@ export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassifi
174
174
  };
175
175
  }
176
176
 
177
+ /**
178
+ * Detect whether an error is caused by a revoked Proxy.
179
+ *
180
+ * The Bot Framework SDK wraps TurnContext in a Proxy that is revoked once the
181
+ * turn handler returns. Any later access (e.g. from a debounced callback)
182
+ * throws a TypeError whose message contains the distinctive "proxy that has
183
+ * been revoked" string.
184
+ */
185
+ export function isRevokedProxyError(err: unknown): boolean {
186
+ if (!(err instanceof TypeError)) {
187
+ return false;
188
+ }
189
+ return /proxy that has been revoked/i.test(err.message);
190
+ }
191
+
177
192
  export function formatMSTeamsSendErrorHint(
178
193
  classification: MSTeamsSendErrorClassification,
179
194
  ): string | undefined {
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk";
5
5
  import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
6
7
  import type { StoredConversationReference } from "./conversation-store.js";
7
8
  const graphUploadMockState = vi.hoisted(() => ({
8
9
  uploadAndShareOneDrive: vi.fn(),
@@ -38,7 +39,7 @@ const chunkMarkdownText = (text: string, limit: number) => {
38
39
  return chunks;
39
40
  };
40
41
 
41
- const runtimeStub = {
42
+ const runtimeStub: PluginRuntime = createPluginRuntimeMock({
42
43
  channel: {
43
44
  text: {
44
45
  chunkMarkdownText,
@@ -47,7 +48,7 @@ const runtimeStub = {
47
48
  convertMarkdownTables: (text: string) => text,
48
49
  },
49
50
  },
50
- } as unknown as PluginRuntime;
51
+ });
51
52
 
52
53
  const createNoopAdapter = (): MSTeamsAdapter => ({
53
54
  continueConversation: async () => {},
@@ -291,6 +292,79 @@ describe("msteams messenger", () => {
291
292
  ).rejects.toMatchObject({ statusCode: 400 });
292
293
  });
293
294
 
295
+ it("falls back to proactive messaging when thread context is revoked", async () => {
296
+ const proactiveSent: string[] = [];
297
+
298
+ const ctx = {
299
+ sendActivity: async () => {
300
+ throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
301
+ },
302
+ };
303
+
304
+ const adapter: MSTeamsAdapter = {
305
+ continueConversation: async (_appId, _reference, logic) => {
306
+ await logic({
307
+ sendActivity: createRecordedSendActivity(proactiveSent),
308
+ });
309
+ },
310
+ process: async () => {},
311
+ };
312
+
313
+ const ids = await sendMSTeamsMessages({
314
+ replyStyle: "thread",
315
+ adapter,
316
+ appId: "app123",
317
+ conversationRef: baseRef,
318
+ context: ctx,
319
+ messages: [{ text: "hello" }],
320
+ });
321
+
322
+ // Should have fallen back to proactive messaging
323
+ expect(proactiveSent).toEqual(["hello"]);
324
+ expect(ids).toEqual(["id:hello"]);
325
+ });
326
+
327
+ it("falls back only for remaining thread messages after context revocation", async () => {
328
+ const threadSent: string[] = [];
329
+ const proactiveSent: string[] = [];
330
+ let attempt = 0;
331
+
332
+ const ctx = {
333
+ sendActivity: async (activity: unknown) => {
334
+ const { text } = activity as { text?: string };
335
+ const content = text ?? "";
336
+ attempt += 1;
337
+ if (attempt === 1) {
338
+ threadSent.push(content);
339
+ return { id: `id:${content}` };
340
+ }
341
+ throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
342
+ },
343
+ };
344
+
345
+ const adapter: MSTeamsAdapter = {
346
+ continueConversation: async (_appId, _reference, logic) => {
347
+ await logic({
348
+ sendActivity: createRecordedSendActivity(proactiveSent),
349
+ });
350
+ },
351
+ process: async () => {},
352
+ };
353
+
354
+ const ids = await sendMSTeamsMessages({
355
+ replyStyle: "thread",
356
+ adapter,
357
+ appId: "app123",
358
+ conversationRef: baseRef,
359
+ context: ctx,
360
+ messages: [{ text: "one" }, { text: "two" }, { text: "three" }],
361
+ });
362
+
363
+ expect(threadSent).toEqual(["one"]);
364
+ expect(proactiveSent).toEqual(["two", "three"]);
365
+ expect(ids).toEqual(["id:one", "id:two", "id:three"]);
366
+ });
367
+
294
368
  it("retries top-level sends on transient (5xx)", async () => {
295
369
  const attempts: string[] = [];
296
370