mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
dc0285366e
* fix(msteams): bound probe token acquisition to request deadline probeMSTeams() at extensions/msteams/src/probe.ts:75 and :89 awaited tokenProvider.getAccessToken(...) for the Bot Framework and Microsoft Graph token endpoints with no surrounding deadline. The Microsoft Teams SDK does not carry an inherent timeout on these calls, so a stalled Azure AD token endpoint pinned the probe indefinitely. Wrap both awaits with withMSTeamsRequestDeadline (default MSTEAMS_REQUEST_TIMEOUT_MS = 30_000), matching the pattern already used by six other MS Teams call sites: attachments/bot-framework.ts:252, attachments/graph.ts:258, monitor-handler/message-handler.ts:594/654/685/692, attachments/download.ts:167, team-identity.ts:37. The probe was the one missing site. No new helper, no SDK change. The existing outer catch at probe.ts:138 and inner catch at probe.ts:110 convert the timeout into a ProbeMSTeamsResult with ok: false and a structured error field. Added probe.timeout.test.ts: real probeMSTeams() with vi.mock injected never-resolving getBotToken/getGraphToken; asserts the call returns within the 30s bound instead of hanging to the proof budget. * test(msteams): drive probe timeout test with vi.useFakeTimers The original probe.timeout.test.ts waited 90 seconds of wall-clock per focused run (3 stalled cases racing against a real setTimeout budget). Per ClawSweeper P2 (automation), this material deterministic CI cost can slow or time out test shards. Drive the withTimeout race (from @openclaw/fs-safe/dist/timing.js, uses setTimeout + clearTimeout) via vi.useFakeTimers() so each stalled case resolves in milliseconds. Add one new case that spies on withTimeout's timeoutMs argument to assert the production default deadline is exactly MSTEAMS_REQUEST_TIMEOUT_MS = 30_000, so the production contract is not silently weakened by the fake-timer change. Per-case wall-clock: 25ms / 3ms / 2ms / 1ms / 2ms (was: 30s / 30s / 30s / 2ms / n/a). Co-Authored-By: Claude <noreply@anthropic.com> * fix(msteams): bound remaining token acquisition * test(msteams): keep credential fixture unchanged --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
// Msteams plugin module implements probe behavior.
|
|
import { isFutureDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
|
|
import {
|
|
normalizeStringEntries,
|
|
type BaseProbeResult,
|
|
type MSTeamsConfig,
|
|
} from "../runtime-api.js";
|
|
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
|
import { formatUnknownError } from "./errors.js";
|
|
import { withMSTeamsRequestDeadline } from "./request-timeout.js";
|
|
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
|
import { readAccessToken } from "./token-response.js";
|
|
import { loadDelegatedTokens, resolveMSTeamsCredentials } from "./token.js";
|
|
|
|
export type ProbeMSTeamsResult = BaseProbeResult<string> & {
|
|
appId?: string;
|
|
graph?: {
|
|
ok: boolean;
|
|
error?: string;
|
|
roles?: string[];
|
|
scopes?: string[];
|
|
};
|
|
delegatedAuth?: {
|
|
ok: boolean;
|
|
error?: string;
|
|
scopes?: string[];
|
|
userPrincipalName?: string;
|
|
};
|
|
};
|
|
|
|
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
const parts = token.split(".");
|
|
if (parts.length < 2) {
|
|
return null;
|
|
}
|
|
const payload = parts[1] ?? "";
|
|
const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
|
|
const normalized = padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
try {
|
|
const decoded = Buffer.from(normalized, "base64").toString("utf8");
|
|
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
|
return parsed && typeof parsed === "object" ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readStringArray(value: unknown): string[] | undefined {
|
|
if (!Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
const out = normalizeStringEntries(value);
|
|
return out.length > 0 ? out : undefined;
|
|
}
|
|
|
|
function readScopes(value: unknown): string[] | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const out = normalizeStringEntries(value.split(/\s+/));
|
|
return out.length > 0 ? out : undefined;
|
|
}
|
|
|
|
export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsResult> {
|
|
const creds = resolveMSTeamsCredentials(cfg);
|
|
if (!creds) {
|
|
return {
|
|
ok: false,
|
|
error: "missing credentials (appId, appPassword, tenantId)",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const { app } = await loadMSTeamsSdkWithAuth(creds, resolveMSTeamsSdkCloudOptions(cfg));
|
|
const tokenProvider = createMSTeamsTokenProvider(app);
|
|
// Token-manager calls can outlive the SDK HTTP timeout, so keep both probe
|
|
// phases bounded by the shared Teams request deadline.
|
|
const botTokenValue = await withMSTeamsRequestDeadline({
|
|
label: "MS Teams Bot Framework probe token",
|
|
work: () => tokenProvider.getAccessToken("https://api.botframework.com"),
|
|
});
|
|
if (!botTokenValue) {
|
|
throw new Error("Failed to acquire bot token");
|
|
}
|
|
|
|
let graph:
|
|
| {
|
|
ok: boolean;
|
|
error?: string;
|
|
roles?: string[];
|
|
scopes?: string[];
|
|
}
|
|
| undefined;
|
|
try {
|
|
const graphTokenValue = await withMSTeamsRequestDeadline({
|
|
label: "MS Teams Graph probe token",
|
|
work: () => tokenProvider.getAccessToken("https://graph.microsoft.com"),
|
|
});
|
|
const accessToken = readAccessToken(graphTokenValue);
|
|
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
|
|
graph = {
|
|
ok: true,
|
|
roles: readStringArray(payload?.roles),
|
|
scopes: readScopes(payload?.scp),
|
|
};
|
|
} catch (err) {
|
|
graph = { ok: false, error: formatUnknownError(err) };
|
|
}
|
|
let delegatedAuth: ProbeMSTeamsResult["delegatedAuth"];
|
|
if (cfg?.delegatedAuth?.enabled) {
|
|
try {
|
|
const tokens = loadDelegatedTokens();
|
|
if (tokens) {
|
|
const isExpired = !isFutureDateTimestampMs(tokens.expiresAt);
|
|
delegatedAuth = {
|
|
ok: !isExpired,
|
|
scopes: tokens.scopes,
|
|
userPrincipalName: tokens.userPrincipalName,
|
|
...(isExpired ? { error: "token expired (will auto-refresh on next use)" } : {}),
|
|
};
|
|
} else {
|
|
delegatedAuth = { ok: false, error: "no delegated tokens found (run setup wizard)" };
|
|
}
|
|
} catch {
|
|
delegatedAuth = { ok: false, error: "failed to load delegated tokens" };
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
appId: creds.appId,
|
|
...(graph ? { graph } : {}),
|
|
...(delegatedAuth ? { delegatedAuth } : {}),
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
appId: creds.appId,
|
|
error: formatUnknownError(err),
|
|
};
|
|
}
|
|
}
|