fix(media): remove express from media host (#71436)

* fix(media): remove express from media host

* fix(media): harden media host responses

* fix(msteams): stage express runtime dependency

* fix(browser): align profile facade exports

* fix(msteams): keep setup entry narrow

* fix(types): satisfy extension setup gates

* fix(msteams): use generic setup config type
This commit is contained in:
Vincent Koc
2026-04-25 01:39:42 -07:00
committed by GitHub
parent 3169886a21
commit 01bf61fcfd
13 changed files with 316 additions and 62 deletions

View File

@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
- Browser/config: support per-profile `browser.profiles.<name>.headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu.
- Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc.
- Dependencies/memory: stop installing `node-llama-cpp` by default; local embeddings now load it only when operators install the optional runtime package. Thanks @vincentkoc.
- Dependencies/media: replace the tiny core media host's Express server with `node:http`, so Express is no longer a root runtime dependency. Thanks @vincentkoc.
- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras.
- WebChat/sessions: keep runtime-only prompt context out of visible transcript history and scrub legacy wrappers from session history surfaces. Thanks @91wan.
- Gradium: add a bundled text-to-speech provider with voice-note and telephony output support. (#64958) Thanks @LaurentMazare.

View File

@@ -7,7 +7,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"commander": "^14.0.3",
"express": "^5.2.1",
"express": "5.2.1",
"playwright-core": "1.59.1",
"typebox": "1.1.31",
"undici": "8.1.0",

View File

@@ -4,12 +4,12 @@
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {
"@azure/identity": "^4.13.1",
"@azure/identity": "4.13.1",
"@microsoft/teams.api": "2.0.8",
"@microsoft/teams.apps": "2.0.8",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.0.1",
"express": "5.2.1",
"jsonwebtoken": "9.0.3",
"jwks-rsa": "4.0.1",
"typebox": "1.1.31"
},
"devDependencies": {
@@ -59,6 +59,9 @@
"build": {
"openclawVersion": "2026.4.20"
},
"bundle": {
"stageRuntimeDependencies": true
},
"release": {
"publishToClawHub": true,
"publishToNpm": true

View File

@@ -3,8 +3,8 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "msteamsPlugin",
specifier: "./setup-plugin-api.js",
exportName: "msteamsSetupPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Teams channel plugin surface.
export { msteamsSetupPlugin } from "./src/channel.setup.js";

View File

@@ -0,0 +1,77 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { MSTeamsChannelConfigSchema } from "./config-schema.js";
import { msteamsSetupAdapter } from "./setup-core.js";
import { msteamsSetupWizard } from "./setup-surface.js";
import { resolveMSTeamsCredentials } from "./token.js";
type ResolvedMSTeamsAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
};
const meta = {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
docsLabel: "msteams",
blurb: "Teams SDK; enterprise support.",
aliases: ["teams"],
order: 60,
} as const;
const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({
allowFrom: cfg.channels?.msteams?.allowFrom,
defaultTo: cfg.channels?.msteams?.defaultTo,
});
const msteamsConfigAdapter = createTopLevelChannelConfigAdapter<
ResolvedMSTeamsAccount,
{
allowFrom?: Array<string | number>;
defaultTo?: string;
}
>({
sectionKey: "msteams",
resolveAccount: (cfg) => ({
accountId: "default",
enabled: cfg.channels?.msteams?.enabled !== false,
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
}),
resolveAccessorAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg),
resolveAllowFrom: (account) => account.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account) => account.defaultTo,
});
export const msteamsSetupPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
id: "msteams",
meta: {
...meta,
aliases: [...meta.aliases],
},
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.msteams"] },
configSchema: MSTeamsChannelConfigSchema,
config: {
...msteamsConfigAdapter,
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
}),
},
setupWizard: msteamsSetupWizard,
setup: msteamsSetupAdapter,
};

1
extensions/qa-lab/web/src/assets.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -1615,7 +1615,6 @@
"commander": "^14.0.3",
"croner": "^10.0.1",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"file-type": "22.0.1",
"https-proxy-agent": "^9.0.0",
"ipaddr.js": "^2.3.0",

13
pnpm-lock.yaml generated
View File

@@ -84,9 +84,6 @@ importers:
dotenv:
specifier: ^17.4.2
version: 17.4.2
express:
specifier: ^5.2.1
version: 5.2.1
file-type:
specifier: 22.0.1
version: 22.0.1
@@ -333,7 +330,7 @@ importers:
specifier: ^14.0.3
version: 14.0.3
express:
specifier: ^5.2.1
specifier: 5.2.1
version: 5.2.1
playwright-core:
specifier: 1.59.1
@@ -893,7 +890,7 @@ importers:
extensions/msteams:
dependencies:
'@azure/identity':
specifier: ^4.13.1
specifier: 4.13.1
version: 4.13.1
'@microsoft/teams.api':
specifier: 2.0.8
@@ -902,13 +899,13 @@ importers:
specifier: 2.0.8
version: 2.0.8
express:
specifier: ^5.2.1
specifier: 5.2.1
version: 5.2.1
jsonwebtoken:
specifier: ^9.0.3
specifier: 9.0.3
version: 9.0.3
jwks-rsa:
specifier: ^4.0.1
specifier: 4.0.1
version: 4.0.1
typebox:
specifier: 1.1.31

View File

@@ -71,7 +71,6 @@ describe("tsdown config", () => {
"plugin-sdk/compat",
"plugin-sdk/index",
bundledEntry("openai"),
bundledEntry("msteams"),
"bundled/boot-md/handler",
]),
);
@@ -88,6 +87,7 @@ describe("tsdown config", () => {
true,
);
expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/discord")).toBe(true);
expect(stagedGraphs.some((config) => config.outDir === "dist/extensions/msteams")).toBe(true);
});
it("does not emit plugin-sdk or hooks from a separate dist graph", () => {

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import { request } from "node:http";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
@@ -57,6 +58,10 @@ describe("media server", () => {
await expect(fs.stat(filePath)).rejects.toThrow();
}
async function expectExistingMediaFile(filePath: string) {
await expect(fs.stat(filePath)).resolves.toEqual(expect.anything());
}
function expectFetchedResponse(
response: Awaited<ReturnType<MediaServerTestHarness["fetch"]>>,
expected: { status: number; noSniff?: boolean },
@@ -107,6 +112,23 @@ describe("media server", () => {
}
}
async function requestAndAbort(url: string) {
await new Promise<void>((resolve, reject) => {
const req = request(url, (res) => {
res.destroy();
resolve();
});
req.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "ECONNRESET") {
resolve();
return;
}
reject(error);
});
req.end();
});
}
beforeAll(async () => {
({ MEDIA_MAX_BYTES } = await import("./store.js"));
mediaHarness = await startMediaServerTestHarness({
@@ -152,6 +174,64 @@ describe("media server", () => {
await expectMediaFileLifecycleCase(testCase);
});
it("sets safe fallback headers for untyped media bytes", async () => {
if (mediaHarness?.listenBlocked) {
return;
}
await writeMediaFile("raw", "hello");
const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () => mediaHarness!.fetch(mediaUrl("raw")));
expectFetchedResponse(res, { status: 200, noSniff: true });
expect(res.headers.get("content-type")).toBe("application/octet-stream");
expect(res.headers.get("content-length")).toBe("5");
expect(await res.text()).toBe("hello");
});
it("answers HEAD media probes without consuming the media file", async () => {
if (mediaHarness?.listenBlocked) {
return;
}
const file = await writeMediaFile("head-probe", "hello");
const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () =>
mediaHarness!.fetch(mediaUrl("head-probe"), { method: "HEAD" }),
);
expectFetchedResponse(res, { status: 200, noSniff: true });
expect(res.headers.get("content-type")).toBe("application/octet-stream");
expect(res.headers.get("content-length")).toBe("5");
expect(await res.text()).toBe("");
await expectExistingMediaFile(file);
});
it("forces active text media to download as opaque bytes", async () => {
if (mediaHarness?.listenBlocked) {
return;
}
await writeMediaFile("page.html", "<script>alert(1)</script>");
const res = await withEnvAsync(LOOPBACK_FETCH_ENV, () =>
mediaHarness!.fetch(mediaUrl("page.html")),
);
expectFetchedResponse(res, { status: 200, noSniff: true });
expect(res.headers.get("content-type")).toBe("application/octet-stream");
expect(res.headers.get("content-disposition")).toBe('attachment; filename="page.html"');
expect(await res.text()).toBe("<script>alert(1)</script>");
});
it("cleans up served media when the client aborts the response", async () => {
if (mediaHarness?.listenBlocked) {
return;
}
const file = await writeMediaFile("abort", "hello");
await withEnvAsync(LOOPBACK_FETCH_ENV, () => requestAndAbort(mediaUrl("abort")));
await waitForFileRemoval(file);
});
it.each([
{
testName: "blocks path traversal attempts",

View File

@@ -1,6 +1,5 @@
import fs from "node:fs/promises";
import type { Server } from "node:http";
import express, { type Express, type RequestHandler } from "express";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { danger } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { detectMime } from "./mime.js";
@@ -16,12 +15,15 @@ const DEFAULT_TTL_MS = 2 * 60 * 1000;
const MAX_MEDIA_ID_CHARS = 200;
const MEDIA_ID_PATTERN = /^[\p{L}\p{N}._-]+$/u;
const MAX_MEDIA_BYTES = MEDIA_MAX_BYTES;
function asyncMediaRoute(handler: RequestHandler): RequestHandler {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
const DEFAULT_MEDIA_CONTENT_TYPE = "application/octet-stream";
const ACTIVE_CONTENT_MIME_TYPES = new Set([
"application/xhtml+xml",
"application/xml",
"image/svg+xml",
"text/html",
"text/javascript",
"text/xml",
]);
const isValidMediaId = (id: string) => {
if (!id) {
@@ -36,20 +38,106 @@ const isValidMediaId = (id: string) => {
return MEDIA_ID_PATTERN.test(id);
};
export function attachMediaRoutes(
app: Express,
ttlMs = DEFAULT_TTL_MS,
_runtime: RuntimeEnv = defaultRuntime,
) {
function sendText(res: ServerResponse, statusCode: number, body: string): void {
const data = Buffer.from(body);
res.statusCode = statusCode;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.setHeader("Content-Length", String(data.byteLength));
res.end(data);
}
function resolveMediaId(req: IncomingMessage): {
routeMatched: boolean;
id?: string;
method?: string;
} {
if (req.method !== "GET" && req.method !== "HEAD") {
return { routeMatched: false };
}
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const prefix = "/media/";
if (!url.pathname.startsWith(prefix)) {
return { routeMatched: false };
}
const encodedId = url.pathname.slice(prefix.length);
if (!encodedId || encodedId.includes("/")) {
return { routeMatched: false };
}
try {
return { routeMatched: true, id: decodeURIComponent(encodedId), method: req.method };
} catch {
return { routeMatched: true, id: "", method: req.method };
}
}
function isActiveContentMime(mime?: string): boolean {
const normalized = mime?.split(";")[0]?.trim().toLowerCase();
return normalized ? ACTIVE_CONTENT_MIME_TYPES.has(normalized) : false;
}
function sanitizeAttachmentFilename(id: string): string {
const name = id.replace(/["\\\r\n]/g, "_").trim();
return name || "media";
}
function setMediaHeaders(
res: ServerResponse,
params: { id: string; mime?: string; bytes: number },
): void {
const activeContent = isActiveContentMime(params.mime);
res.setHeader(
"Content-Type",
activeContent ? DEFAULT_MEDIA_CONTENT_TYPE : (params.mime ?? DEFAULT_MEDIA_CONTENT_TYPE),
);
res.setHeader("Content-Length", String(params.bytes));
if (activeContent) {
res.setHeader(
"Content-Disposition",
`attachment; filename="${sanitizeAttachmentFilename(params.id)}"`,
);
}
}
function scheduleMediaCleanup(realPath: string): void {
const cleanup = () => {
void fs.rm(realPath).catch(() => {});
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {
queueMicrotask(cleanup);
return;
}
setTimeout(cleanup, 50);
}
function cleanupAfterGetResponse(res: ServerResponse, realPath: string): void {
let scheduled = false;
const scheduleOnce = () => {
if (scheduled) {
return;
}
scheduled = true;
scheduleMediaCleanup(realPath);
};
res.once("finish", scheduleOnce);
res.once("close", scheduleOnce);
res.once("error", scheduleOnce);
}
export function createMediaRequestHandler(ttlMs = DEFAULT_TTL_MS) {
const mediaDir = getMediaDir();
app.get(
"/media/:id",
asyncMediaRoute(async (req, res) => {
return (req: IncomingMessage, res: ServerResponse) => {
const route = resolveMediaId(req);
if (!route.routeMatched) {
sendText(res, 404, "not found");
return;
}
void (async () => {
res.setHeader("X-Content-Type-Options", "nosniff");
const id = typeof req.params.id === "string" ? req.params.id : "";
const id = route.id ?? "";
if (!isValidMediaId(id)) {
res.status(400).send("invalid path");
sendText(res, 400, "invalid path");
return;
}
try {
@@ -64,50 +152,54 @@ export function attachMediaRoutes(
});
if (Date.now() - stat.mtimeMs > ttlMs) {
await fs.rm(realPath).catch(() => {});
res.status(410).send("expired");
sendText(res, 410, "expired");
return;
}
const mime = await detectMime({ buffer: data, filePath: realPath });
if (mime) {
res.type(mime);
setMediaHeaders(res, { id, mime, bytes: data.byteLength });
res.statusCode = 200;
if (route.method === "HEAD") {
res.end();
return;
}
res.send(data);
// best-effort single-use cleanup after response ends
res.on("finish", () => {
const cleanup = () => {
void fs.rm(realPath).catch(() => {});
};
// Tests should not pay for time-based cleanup delays.
if (process.env.VITEST || process.env.NODE_ENV === "test") {
queueMicrotask(cleanup);
return;
}
setTimeout(cleanup, 50);
});
cleanupAfterGetResponse(res, realPath);
if (req.aborted || res.destroyed || res.writableEnded) {
scheduleMediaCleanup(realPath);
return;
}
res.end(data);
} catch (err) {
if (isSafeOpenError(err)) {
if (err.code === "outside-workspace") {
res.status(400).send("file is outside workspace root");
sendText(res, 400, "file is outside workspace root");
return;
}
if (err.code === "invalid-path") {
res.status(400).send("invalid path");
sendText(res, 400, "invalid path");
return;
}
if (err.code === "not-found") {
res.status(404).send("not found");
sendText(res, 404, "not found");
return;
}
if (err.code === "too-large") {
res.status(413).send("too large");
sendText(res, 413, "too large");
return;
}
}
res.status(404).send("not found");
sendText(res, 404, "not found");
}
}),
);
})().catch(() => {
if (!res.headersSent) {
sendText(res, 404, "not found");
} else {
res.destroy();
}
});
};
}
function startMediaCleanupInterval(ttlMs: number): void {
// periodic cleanup
setInterval(() => {
void cleanOldMedia(ttlMs, { recursive: false });
@@ -119,10 +211,10 @@ export async function startMediaServer(
ttlMs = DEFAULT_TTL_MS,
runtime: RuntimeEnv = defaultRuntime,
): Promise<Server> {
const app = express();
attachMediaRoutes(app, ttlMs, runtime);
const server = createServer(createMediaRequestHandler(ttlMs));
startMediaCleanupInterval(ttlMs);
return await new Promise((resolve, reject) => {
const server = app.listen(port, "127.0.0.1");
server.listen(port, "127.0.0.1");
server.once("listening", () => resolve(server));
server.once("error", (err) => {
runtime.error(danger(`Media server failed: ${String(err)}`));

View File

@@ -75,10 +75,11 @@ const packageManifestContractTests: PackageManifestContractParams[] = [
"@azure/identity",
"@microsoft/teams.api",
"@microsoft/teams.apps",
"express",
"jsonwebtoken",
"jwks-rsa",
],
mirroredRootRuntimeDeps: ["typebox", "express"],
mirroredRootRuntimeDeps: ["typebox"],
minHostVersionBaseline: "2026.3.22",
},
{ pluginId: "nextcloud-talk", minHostVersionBaseline: "2026.3.22" },