From c88d6d67c8fa34930039c5f30c5a24448bbb2f3d Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Sat, 4 Apr 2026 02:38:57 -0500 Subject: [PATCH] feat(msteams): add OpenClaw User-Agent header to Microsoft HTTP calls (#51568) (#60433) Co-authored-by: Brad Groux --- .../msteams/src/attachments/graph.test.ts | 125 +++++++++++++++++- extensions/msteams/src/attachments/graph.ts | 9 +- extensions/msteams/src/file-consent.test.ts | 26 ++++ extensions/msteams/src/graph-upload.test.ts | 7 +- extensions/msteams/src/graph-upload.ts | 2 +- extensions/msteams/src/sdk.test.ts | 46 ++++++- extensions/msteams/src/user-agent.test.ts | 10 +- extensions/msteams/src/user-agent.ts | 8 ++ 8 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 extensions/msteams/src/file-consent.test.ts diff --git a/extensions/msteams/src/attachments/graph.test.ts b/extensions/msteams/src/attachments/graph.test.ts index 1e407f2d998..b248c071ff5 100644 --- a/extensions/msteams/src/attachments/graph.test.ts +++ b/extensions/msteams/src/attachments/graph.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; // Mock shared.js to avoid transitive runtime-api imports that pull in uninstalled packages. vi.mock("./shared.js", () => ({ @@ -45,6 +45,8 @@ vi.mock("./remote-media.js", () => ({ })); import { fetchWithSsrFGuard } from "../../runtime-api.js"; +import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js"; +import { safeFetchWithPolicy } from "./shared.js"; import { downloadMSTeamsGraphMedia } from "./graph.js"; function mockFetchResponse(body: unknown, status = 200) { @@ -57,6 +59,10 @@ function mockBinaryResponse(data: Uint8Array, status = 200) { } describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("fetches $value endpoint when contentBytes is null but item.id exists", async () => { const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes @@ -271,4 +277,121 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => { expect(valueCall).toBeUndefined(); expect(result.media.length).toBeGreaterThan(0); }); + + it("adds the OpenClaw User-Agent to guarded Graph attachment fetches", async () => { + vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string; init?: RequestInit }) => { + const url = params.url; + if (url.endsWith("/messages/msg-ua") && !url.includes("hostedContents")) { + return { + response: mockFetchResponse({ body: {}, attachments: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/hostedContents")) { + return { + response: mockFetchResponse({ value: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/attachments")) { + return { + response: mockFetchResponse({ value: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + return { + response: mockFetchResponse({}, 404), + release: async () => {}, + finalUrl: params.url, + }; + }); + + await downloadMSTeamsGraphMedia({ + messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-ua", + tokenProvider: { getAccessToken: vi.fn(async () => "test-token") }, + maxBytes: 10 * 1024 * 1024, + }); + + const guardCalls = vi.mocked(fetchWithSsrFGuard).mock.calls; + for (const [call] of guardCalls) { + const headers = call.init?.headers; + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get("Authorization")).toBe("Bearer test-token"); + expect((headers as Headers).get("User-Agent")).toMatch( + /^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/, + ); + } + }); + + it("adds the OpenClaw User-Agent to Graph shares downloads for reference attachments", async () => { + vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => { + const url = params.url; + if (url.endsWith("/messages/msg-share") && !url.includes("hostedContents")) { + return { + response: mockFetchResponse({ + body: {}, + attachments: [ + { + contentType: "reference", + contentUrl: "https://tenant.sharepoint.com/file.docx", + name: "file.docx", + }, + ], + }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/hostedContents")) { + return { + response: mockFetchResponse({ value: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/attachments")) { + return { + response: mockFetchResponse({ value: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + return { + response: mockFetchResponse({}, 404), + release: async () => {}, + finalUrl: params.url, + }; + }); + vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 })); + vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockImplementation(async (params) => { + if (params.fetchImpl) { + await params.fetchImpl(params.url, {}); + } + return { + path: "/tmp/file.docx", + contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + placeholder: "[file]", + }; + }); + + await downloadMSTeamsGraphMedia({ + messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-share", + tokenProvider: { getAccessToken: vi.fn(async () => "test-token") }, + maxBytes: 10 * 1024 * 1024, + }); + + expect(safeFetchWithPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + requestInit: expect.objectContaining({ + headers: expect.any(Headers), + }), + }), + ); + const requestInit = vi.mocked(safeFetchWithPolicy).mock.calls[0]?.[0]?.requestInit; + const headers = requestInit?.headers as Headers; + expect(headers.get("User-Agent")).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/); + }); }); diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index 30cf689873a..c72a5780562 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -1,5 +1,6 @@ import { fetchWithSsrFGuard, type SsrFPolicy } from "../../runtime-api.js"; import { getMSTeamsRuntime } from "../runtime.js"; +import { ensureUserAgentHeader } from "../user-agent.js"; import { downloadMSTeamsAttachments } from "./download.js"; import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js"; import { @@ -130,7 +131,7 @@ async function fetchGraphCollection(params: { url: params.url, fetchImpl: fetchFn, init: { - headers: { Authorization: `Bearer ${params.accessToken}` }, + headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }), }, policy: params.ssrfPolicy, auditContext: "msteams.graph.collection", @@ -209,7 +210,7 @@ async function downloadGraphHostedContent(params: { url: valueUrl, fetchImpl: params.fetchFn ?? fetch, init: { - headers: { Authorization: `Bearer ${params.accessToken}` }, + headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }), }, policy: params.ssrfPolicy, auditContext: "msteams.graph.hostedContent.value", @@ -297,7 +298,7 @@ export async function downloadMSTeamsGraphMedia(params: { url: messageUrl, fetchImpl: fetchFn, init: { - headers: { Authorization: `Bearer ${accessToken}` }, + headers: ensureUserAgentHeader({ Authorization: `Bearer ${accessToken}` }), }, policy: ssrfPolicy, auditContext: "msteams.graph.message", @@ -340,7 +341,7 @@ export async function downloadMSTeamsGraphMedia(params: { ssrfPolicy, fetchImpl: async (input, init) => { const requestUrl = resolveRequestUrl(input); - const headers = new Headers(init?.headers); + const headers = ensureUserAgentHeader(init?.headers); applyAuthorizationHeaderForUrl({ headers, url: requestUrl, diff --git a/extensions/msteams/src/file-consent.test.ts b/extensions/msteams/src/file-consent.test.ts new file mode 100644 index 00000000000..bf1f84f8608 --- /dev/null +++ b/extensions/msteams/src/file-consent.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { uploadToConsentUrl } from "./file-consent.js"; + +describe("uploadToConsentUrl", () => { + it("sends the OpenClaw User-Agent header with consent uploads", async () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 200 })); + + await uploadToConsentUrl({ + url: "https://upload.example.com/file", + buffer: Buffer.from("hello"), + fetchFn, + }); + + expect(fetchFn).toHaveBeenCalledWith( + "https://upload.example.com/file", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + "Content-Range": "bytes 0-4/5", + "Content-Type": "application/octet-stream", + "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/), + }), + }), + ); + }); +}); diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index d76103d689c..4182d92dbe3 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -34,6 +34,7 @@ describe("graph upload helpers", () => { headers: expect.objectContaining({ Authorization: "Bearer graph-token", "Content-Type": "application/octet-stream", + "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/), }), }), ); @@ -71,6 +72,7 @@ describe("graph upload helpers", () => { headers: expect.objectContaining({ Authorization: "Bearer graph-token", "Content-Type": "application/octet-stream", + "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/), }), }), ); @@ -138,7 +140,10 @@ describe("resolveGraphChatId", () => { expect(fetchFn).toHaveBeenCalledWith( expect.stringContaining("/me/chats"), expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer graph-token" }), + headers: expect.objectContaining({ + Authorization: "Bearer graph-token", + "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/), + }), }), ); const firstCall = fetchFn.mock.calls[0]; diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts index 7e8be062325..3ff9a5fd44a 100644 --- a/extensions/msteams/src/graph-upload.ts +++ b/extensions/msteams/src/graph-upload.ts @@ -327,7 +327,7 @@ export async function resolveGraphChatId(params: { } const res = await fetchFn(`${GRAPH_ROOT}${path}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` }, }); if (!res.ok) { diff --git a/extensions/msteams/src/sdk.test.ts b/extensions/msteams/src/sdk.test.ts index 9cabcd1da89..cc58e198019 100644 --- a/extensions/msteams/src/sdk.test.ts +++ b/extensions/msteams/src/sdk.test.ts @@ -13,6 +13,10 @@ const jwtValidatorState = vi.hoisted(() => ({ calls: [] as Array<{ jwksUri: string; token: string; overrideOptions?: unknown }>, })); +const clientConstructorState = vi.hoisted(() => ({ + calls: [] as Array<{ serviceUrl: string; options: unknown }>, +})); + vi.mock("@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js", () => ({ JwtValidator: class JwtValidator { private readonly config: Record; @@ -38,6 +42,7 @@ const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; + clientConstructorState.calls.length = 0; jwtValidatorState.instances.length = 0; jwtValidatorState.calls.length = 0; jwtValidatorState.behaviorByJwks.clear(); @@ -56,7 +61,9 @@ function createSdkStub(): MSTeamsTeamsSdk { } class ClientStub { - constructor(_serviceUrl: string, _options: unknown) {} + constructor(serviceUrl: string, options: unknown) { + clientConstructorState.calls.push({ serviceUrl, options }); + } conversations = { activities: (_conversationId: string) => ({ @@ -134,6 +141,43 @@ describe("createMSTeamsAdapter", () => { }), ); }); + + it("passes the OpenClaw User-Agent to the Bot Framework connector client", async () => { + const creds = { + appId: "app-id", + appPassword: "secret", + tenantId: "tenant-id", + } satisfies MSTeamsCredentials; + const sdk = createSdkStub(); + const app = new sdk.App({ + clientId: creds.appId, + clientSecret: creds.appPassword, + tenantId: creds.tenantId, + }); + const adapter = createMSTeamsAdapter(app, sdk); + + await adapter.continueConversation( + creds.appId, + { + serviceUrl: "https://service.example.com/", + conversation: { id: "19:conversation@thread.tacv2" }, + channelId: "msteams", + }, + async (ctx) => { + await ctx.sendActivity("hello"); + }, + ); + + expect(clientConstructorState.calls).toHaveLength(1); + expect(clientConstructorState.calls[0]).toMatchObject({ + serviceUrl: "https://service.example.com/", + options: { + headers: { + "User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/), + }, + }, + }); + }); }); describe("createBotFrameworkJwtValidator", () => { diff --git a/extensions/msteams/src/user-agent.test.ts b/extensions/msteams/src/user-agent.test.ts index a99f3a3c4bc..ef8c152a7ed 100644 --- a/extensions/msteams/src/user-agent.test.ts +++ b/extensions/msteams/src/user-agent.test.ts @@ -11,7 +11,7 @@ vi.mock("./runtime.js", () => ({ import { fetchGraphJson } from "./graph.js"; import { getMSTeamsRuntime } from "./runtime.js"; -import { buildUserAgent, resetUserAgentCache } from "./user-agent.js"; +import { buildUserAgent, ensureUserAgentHeader, resetUserAgentCache } from "./user-agent.js"; describe("buildUserAgent", () => { beforeEach(() => { @@ -75,4 +75,12 @@ describe("buildUserAgent", () => { const [, init] = mockFetch.mock.calls[0]; expect(init.headers["User-Agent"]).toBe("custom-agent/1.0"); }); + + it("adds the generated User-Agent to Headers instances without overwriting callers", () => { + const generated = ensureUserAgentHeader(); + expect(generated.get("User-Agent")).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/2026\.3\.19$/); + + const custom = ensureUserAgentHeader({ "User-Agent": "custom-agent/2.0" }); + expect(custom.get("User-Agent")).toBe("custom-agent/2.0"); + }); }); diff --git a/extensions/msteams/src/user-agent.ts b/extensions/msteams/src/user-agent.ts index 05c8ac196b9..6cc6b2423cc 100644 --- a/extensions/msteams/src/user-agent.ts +++ b/extensions/msteams/src/user-agent.ts @@ -43,3 +43,11 @@ export function buildUserAgent(): string { cachedUserAgent = `teams.ts[apps]/${resolveTeamsSdkVersion()} OpenClaw/${resolveOpenClawVersion()}`; return cachedUserAgent; } + +export function ensureUserAgentHeader(headers?: HeadersInit): Headers { + const nextHeaders = new Headers(headers); + if (!nextHeaders.has("User-Agent")) { + nextHeaders.set("User-Agent", buildUserAgent()); + } + return nextHeaders; +}