diff --git a/CHANGELOG.md b/CHANGELOG.md index fa5d093b2b0..b5f55cef65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Browser/config: support per-profile `browser.profiles..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. diff --git a/extensions/browser/package.json b/extensions/browser/package.json index 7a6040ff51a..e9ccfbf1c02 100644 --- a/extensions/browser/package.json +++ b/extensions/browser/package.json @@ -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", diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index f24b2d5d332..12b31b11357 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -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 diff --git a/extensions/msteams/setup-entry.ts b/extensions/msteams/setup-entry.ts index 0d3a069e969..e120f638427 100644 --- a/extensions/msteams/setup-entry.ts +++ b/extensions/msteams/setup-entry.ts @@ -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", diff --git a/extensions/msteams/setup-plugin-api.ts b/extensions/msteams/setup-plugin-api.ts new file mode 100644 index 00000000000..7784e9f8184 --- /dev/null +++ b/extensions/msteams/setup-plugin-api.ts @@ -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"; diff --git a/extensions/msteams/src/channel.setup.ts b/extensions/msteams/src/channel.setup.ts new file mode 100644 index 00000000000..8cbc537bbc8 --- /dev/null +++ b/extensions/msteams/src/channel.setup.ts @@ -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; + 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 = { + 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, +}; diff --git a/extensions/qa-lab/web/src/assets.d.ts b/extensions/qa-lab/web/src/assets.d.ts new file mode 100644 index 00000000000..cbe652dbe00 --- /dev/null +++ b/extensions/qa-lab/web/src/assets.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/package.json b/package.json index fec9820121f..80d15a3e6fc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 470f34fb625..d53df1cfc38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index c955400bbf8..3154c399b50 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -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", () => { diff --git a/src/media/server.test.ts b/src/media/server.test.ts index 6da12cc038c..f817e0c5fd4 100644 --- a/src/media/server.test.ts +++ b/src/media/server.test.ts @@ -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>, expected: { status: number; noSniff?: boolean }, @@ -107,6 +112,23 @@ describe("media server", () => { } } + async function requestAndAbort(url: string) { + await new Promise((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", ""); + + 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(""); + }); + + 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", diff --git a/src/media/server.ts b/src/media/server.ts index d4126624a2a..5d0b5a0ad2d 100644 --- a/src/media/server.ts +++ b/src/media/server.ts @@ -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 { - 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)}`)); diff --git a/src/plugins/contracts/package-manifest.contract.test.ts b/src/plugins/contracts/package-manifest.contract.test.ts index 6597163dfbc..bca25a083b3 100644 --- a/src/plugins/contracts/package-manifest.contract.test.ts +++ b/src/plugins/contracts/package-manifest.contract.test.ts @@ -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" },