feat(msteams): add OpenClaw User-Agent header to Microsoft HTTP calls (#51568) (#60433)

Co-authored-by: Brad Groux <bradgroux@users.noreply.github.com>
This commit is contained in:
Brad Groux 2026-04-04 02:38:57 -05:00 committed by GitHub
parent dd2faa3764
commit c88d6d67c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 224 additions and 9 deletions

View File

@ -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\/.+$/);
});
});

View File

@ -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<T>(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,

View File

@ -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\/.+$/),
}),
}),
);
});
});

View File

@ -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];

View File

@ -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) {

View File

@ -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<string, unknown>;
@ -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", () => {

View File

@ -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");
});
});

View File

@ -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;
}