diff --git a/apps/server/src/modules/desktop/__tests__/desktop.test.ts b/apps/server/src/modules/desktop/__tests__/desktop.test.ts new file mode 100644 index 00000000..19e3e83e --- /dev/null +++ b/apps/server/src/modules/desktop/__tests__/desktop.test.ts @@ -0,0 +1,147 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; +import type { AuthEnv, Principal, SessionVar } from "../../../http/auth.ts"; +import { ApiException, errorBody } from "../../../http/errors.ts"; +import { + basicAuth, + type DesktopDeps, + type DesktopRole, + deriveDesktopPasswords, + desktopUpstream, + registerDesktopRoutes, + sameOrigin, +} from "../desktop.ts"; + +// 桌面模式反代:口令派生与容器 init 脚本一致、角色 → 口令注入、只接受控制台会话、响应头与路径约束。 + +const SID = "7d3c1a52-9b0e-4b7a-8a51-0c2f7f7f0a11"; +const KEY = "p0key"; + +function harness( + opts: { principal?: Partial; role?: DesktopRole; check?: DesktopDeps["checkSession"] } = {}, +) { + const calls: { url: string; headers: Headers; method: string }[] = []; + const app = new OpenAPIHono(); + app.onError((err, c) => { + if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400); + throw err; + }); + app.use("*", async (c, next) => { + const p: Principal = { kind: "admin_session", id: "u1", name: "admin", scopes: ["admin"], ...opts.principal }; + c.set("principal", p); + c.set("session", p.kind === "admin_session" ? ({ id: "s1" } as SessionVar) : undefined); + await next(); + }); + registerDesktopRoutes(app, { + upstream: "http://camofox:3000", + accessKey: KEY, + checkSession: + opts.check ?? + (async (_id, holder) => { + expect(holder).toBe("admin_session:admin"); + return { ok: true, role: opts.role ?? "viewer", leaseExpiresAt: null }; + }), + consoleSessionValid: async () => true, + probeTtlMs: 0, + fetch: (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + calls.push({ url, headers: new Headers(init?.headers), method: init?.method ?? "GET" }); + if (url.endsWith("/api/secret-401")) + return new Response("no", { status: 401, headers: { "www-authenticate": "Basic" } }); + return new Response("ok", { + status: 200, + headers: { "content-type": "text/html", "www-authenticate": "Basic", "x-frame-options": "DENY" }, + }); + }) as typeof fetch, + }); + const root = new OpenAPIHono(); + root.onError((err, c) => { + if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400); + throw err; + }); + root.route("/api/v1", app); + return { app: root, calls }; +} + +describe("桌面口令与上游地址", () => { + it("口令与容器 init-umt-desktop 的 openssl HMAC 派生一致", () => { + // printf '%s' umt-desktop/control | openssl dgst -sha256 -hmac p0key -r | cut -c1-48 + const p = deriveDesktopPasswords(KEY); + expect(p.controller).toBe("0b29251f95456b1e4612e3f4c45c2e353fd24e5ecbdf4959"); + expect(p.viewer).toHaveLength(48); + expect(p.viewer).not.toBe(p.controller); + }); + + it("缺省由浏览器服务地址推出(端口换 3000),可显式覆盖", () => { + expect(desktopUpstream({ providerUrl: "http://camofox:9377" })).toBe("http://camofox:3000"); + expect(desktopUpstream({ providerUrl: "http://camofox:9377", desktopUrl: "http://desk:3000/" })).toBe( + "http://desk:3000", + ); + expect(desktopUpstream({})).toBeNull(); + }); + + it("WebSocket 只接受同源页面", () => { + const req = (origin: string | null, host = "media.example.com") => + new Request("https://media.example.com/x", { headers: { host, ...(origin ? { origin } : {}) } }); + expect(sameOrigin(req("https://media.example.com"))).toBe(true); + expect(sameOrigin(req("https://evil.example"))).toBe(false); + expect(sameOrigin(req(null))).toBe(false); + }); +}); + +describe("桌面反代", () => { + it("外部 API Key 不能用桌面模式", async () => { + const { app } = harness({ principal: { kind: "api_key" } }); + const r = await app.request(`/api/v1/browser/desktop/${SID}/`); + expect(r.status).toBe(403); + }); + + it("状态:按租约给角色,并返回 iframe 地址", async () => { + const { app } = harness({ role: "controller" }); + const r = await app.request(`/api/v1/browser/desktop/${SID}/_status`); + const body = (await r.json()) as { data: { available: boolean; role: string; path: string } }; + expect(body.data).toMatchObject({ available: true, role: "controller", path: `/api/v1/browser/desktop/${SID}/` }); + }); + + it("静态资源:不带口令、不转发 cookie;响应只允许本站嵌入,不外露认证质询", async () => { + const { app, calls } = harness(); + const r = await app.request(`/api/v1/browser/desktop/${SID}/assets/index.js?v=1`, { + headers: { cookie: "umt_session=secret" }, + }); + expect(r.status).toBe(200); + expect(calls.at(-1)?.url).toBe("http://camofox:3000/assets/index.js?v=1"); + expect(calls.at(-1)?.headers.get("authorization")).toBeNull(); + expect(calls.at(-1)?.headers.get("cookie")).toBeNull(); + expect(r.headers.get("content-security-policy")).toBe("frame-ancestors 'self'"); + expect(r.headers.get("x-frame-options")).toBe("SAMEORIGIN"); + expect(r.headers.get("www-authenticate")).toBeNull(); + }); + + it("Selkies 接口:只读 / 控制口令按角色注入", async () => { + const pw = deriveDesktopPasswords(KEY); + for (const role of ["viewer", "controller"] as const) { + const { app, calls } = harness({ role }); + await app.request(`/api/v1/browser/desktop/${SID}/api/websockets`); + expect(calls.at(-1)?.headers.get("authorization")).toBe(basicAuth(pw[role])); + } + }); + + it("上游 401 不透传质询,报口令不匹配", async () => { + const { app } = harness(); + const r = await app.request(`/api/v1/browser/desktop/${SID}/api/secret-401`); + expect(r.status).toBe(502); + expect(r.headers.get("www-authenticate")).toBeNull(); + }); + + it("会话不属于自带浏览器服务 → 409;非法会话 id → 404;WebSocket 跨源 → 403", async () => { + const { app } = harness({ + check: async () => ({ ok: false, code: "unsupported", message: "只有部署自带的浏览器服务支持桌面模式" }), + }); + expect((await app.request(`/api/v1/browser/desktop/${SID}/api/websockets`)).status).toBe(409); + expect((await app.request("/api/v1/browser/desktop/not-a-uuid/")).status).toBe(404); + const ws = await harness().app.request(`/api/v1/browser/desktop/${SID}/api/websockets`, { + headers: { upgrade: "websocket", origin: "https://evil.example", host: "media.example.com" }, + }); + expect(ws.status).toBe(403); + }); +}); diff --git a/apps/server/src/modules/desktop/desktop.ts b/apps/server/src/modules/desktop/desktop.ts new file mode 100644 index 00000000..621f2b47 --- /dev/null +++ b/apps/server/src/modules/desktop/desktop.ts @@ -0,0 +1,380 @@ +import { createHmac } from "node:crypto"; +import type { DB, Kysely } from "@umt/db"; +import type { Context } from "hono"; +import type { AuthEnv } from "../../http/auth.ts"; +import { ApiException } from "../../http/errors.ts"; +import type { ModuleApp } from "../types.ts"; + +// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.6、docs/runbook/browser.md §8): +// /api/v1/browser/desktop/{sessionId}/* 反代浏览器桌面容器里的 Selkies(nginx :3000,静态客户端 + /api/websockets)。 +// - 只接受控制台 cookie 会话(外部 API Key、DEV key 一律拒绝);Selkies 端口不映射到宿主,唯一入口是这里。 +// - Selkies 口令由服务端注入(Authorization: Basic),浏览器永远拿不到:持有该会话 active 人工租约的本人 → 控制口令, +// 其他人 → 只读口令(Selkies 服务端把只读口令的连接限为 viewer:键鼠/剪贴板输入一律丢弃)。 +// - 口令 = HMAC-SHA256(CAMOFOX_ACCESS_KEY, "umt-desktop/control" | "umt-desktop/view") 的前 48 位 hex, +// 容器 init-umt-desktop 用 openssl 算同一个值,不另设密钥。 +// - WebSocket 连接期间每隔 roleCheckMs 重新判定角色;租约交还/过期/被夺、控制台会话失效时主动断开, +// Selkies 客户端自动重连并按新角色认证(降级为只读)。 + +export const DESKTOP_USER = "umt"; +const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** 客户端能拿到的路径前缀里 _status 保留给本模块;其余原样转给 Selkies */ +const STATUS_SEGMENT = "_status"; + +export type DesktopRole = "controller" | "viewer"; + +export function deriveDesktopPasswords(accessKey: string): Record { + const d = (label: string) => createHmac("sha256", accessKey).update(label).digest("hex").slice(0, 48); + return { controller: d("umt-desktop/control"), viewer: d("umt-desktop/view") }; +} + +export function basicAuth(password: string): string { + return `Basic ${btoa(`${DESKTOP_USER}:${password}`)}`; +} + +/** + * 桌面上游地址:BROWSER_DESKTOP_URL 显式指定;否则取 BROWSER_PROVIDER_URL 的主机、端口换成 3000 + * (compose 缺省 http://camofox:9377 → http://camofox:3000)。 + */ +export function desktopUpstream(env: { providerUrl?: string; desktopUrl?: string }): string | null { + if (env.desktopUrl) return env.desktopUrl.replace(/\/+$/, ""); + if (!env.providerUrl) return null; + try { + const u = new URL(env.providerUrl); + u.port = "3000"; + u.pathname = ""; + return u.origin; + } catch { + return null; + } +} + +export type SessionCheck = + | { ok: true; role: DesktopRole; leaseExpiresAt: string | null } + | { ok: false; code: "not_found" | "closed" | "unsupported"; message: string }; + +export interface DesktopDeps { + /** 上游 Selkies(nginx)根地址;null = 未配置 */ + upstream: string | null; + /** 访问凭据(派生桌面口令);null = 未配置 */ + accessKey: string | null; + /** 会话/租约判定:holder 与 browser 模块写租约时同一格式 `:` */ + checkSession(sessionId: string, holder: string): Promise; + /** 控制台会话是否仍有效(WS 长连接期间复查) */ + consoleSessionValid(sessionRowId: string): Promise; + fetch?: typeof fetch; + /** 角色复查间隔 */ + roleCheckMs?: number; + /** 可用性探测缓存 */ + probeTtlMs?: number; + log?: (msg: string, extra?: Record) => void; +} + +/** 数据库实现:会话必须属于环境变量登记的浏览器服务(camofox-env,即 compose 自带的桌面容器) */ +export function dbSessionChecks(db: Kysely): Pick { + return { + async checkSession(sessionId, holder) { + const row = await db + .selectFrom("browser_session as s") + .innerJoin("browser_provider as p", "p.id", "s.provider_id") + .select(["s.id", "s.closed_at", "p.config", "p.kind"]) + .where("s.id", "=", sessionId) + .executeTakeFirst(); + if (!row) return { ok: false, code: "not_found", message: "浏览器会话不存在" }; + if (row.closed_at) return { ok: false, code: "closed", message: "浏览器会话已关闭" }; + const cfg = (row.config ?? {}) as Record; + if (row.kind !== "camofox" || cfg.managedBy !== "env") + return { ok: false, code: "unsupported", message: "只有部署自带的浏览器服务(camofox-env)支持桌面模式" }; + const lease = await db + .selectFrom("browser_control_lease") + .select(["holder_kind", "holder", "expires_at"]) + .where("session_id", "=", sessionId) + .where("status", "=", "active") + .executeTakeFirst(); + const mine = + lease?.holder_kind === "human" && lease.holder === holder && new Date(lease.expires_at).getTime() > Date.now(); + return { + ok: true, + role: mine ? "controller" : "viewer", + leaseExpiresAt: mine ? new Date(lease.expires_at).toISOString() : null, + }; + }, + async consoleSessionValid(id) { + const s = await db + .selectFrom("admin_session") + .select(["expires_at", "revoked_at"]) + .where("id", "=", id) + .executeTakeFirst(); + return !!s && !s.revoked_at && new Date(s.expires_at).getTime() > Date.now(); + }, + }; +} + +/** 只允许控制台会话;返回租约 holder 与会话行 id */ +function consoleActor(c: Context): { holder: string; sessionRowId: string } { + const p = c.get("principal"); + const s = c.get("session"); + if (!p) throw new ApiException("unauthorized", "未登录"); + if (p.kind !== "admin_session" || !s) throw new ApiException("forbidden", "桌面模式只能在控制台登录后使用"); + return { holder: `${p.kind}:${p.name ?? p.id}`, sessionRowId: s.id }; +} + +function sessionIdOf(c: Context): string { + const id = c.req.param("sessionId") ?? ""; + if (!SESSION_ID_RE.test(id)) throw new ApiException("not_found", "浏览器会话不存在"); + return id; +} + +async function requireSession(deps: DesktopDeps, sessionId: string, holder: string) { + const r = await deps.checkSession(sessionId, holder); + if (!r.ok) throw new ApiException(r.code === "not_found" ? "not_found" : "conflict", r.message); + return r; +} + +/** 浏览器发来的 WebSocket 必须同源(cookie 会话 + WS 无 CORS,防跨站劫持) */ +export function sameOrigin(req: Request): boolean { + const origin = req.headers.get("origin"); + if (!origin) return false; + const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host"); + try { + return !!host && new URL(origin).host === host.split(",")[0]?.trim(); + } catch { + return false; + } +} + +const HOP_BY_HOP = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "content-length", + "content-encoding", +]; + +/** 转给上游的请求头:去掉 cookie / 本站鉴权 / 逐跳头,Authorization 由服务端决定 */ +function upstreamHeaders(req: Request, auth: string | null): Headers { + const h = new Headers(); + for (const k of ["accept", "accept-language", "content-type", "range", "if-none-match", "if-modified-since"]) { + const v = req.headers.get(k); + if (v) h.set(k, v); + } + h.set("accept-encoding", "identity"); + if (auth) h.set("authorization", auth); + return h; +} + +/** 回给浏览器的响应头:只允许本站 iframe 嵌入;上游的认证质询不外露(否则浏览器弹 Basic 登录框) */ +function downstreamHeaders(res: Response): Headers { + const h = new Headers(res.headers); + for (const k of HOP_BY_HOP) h.delete(k); + h.delete("www-authenticate"); + h.delete("set-cookie"); + h.set("content-security-policy", "frame-ancestors 'self'"); + h.set("x-frame-options", "SAMEORIGIN"); + h.set("referrer-policy", "same-origin"); + return h; +} + +export interface DesktopStatus { + available: boolean; + /** 不可用原因(给界面说明,回退截图模式) */ + reason: string | null; + role: DesktopRole; + leaseExpiresAt: string | null; + /** 桌面客户端相对地址(iframe src) */ + path: string; + /** WebCodecs / 剪贴板只在安全上下文可用:非 HTTPS(且非 localhost)时前端自动回退截图模式 */ + requiresSecureContext: true; +} + +export function registerDesktopRoutes(app: ModuleApp, deps: DesktopDeps): { closeAll(): void } { + const doFetch = deps.fetch ?? fetch; + const roleCheckMs = deps.roleCheckMs ?? 3000; + const probeTtl = deps.probeTtlMs ?? 15_000; + const passwords = deps.accessKey ? deriveDesktopPasswords(deps.accessKey) : null; + const log = deps.log ?? (() => {}); + const live = new Set<() => void>(); + let probe: { at: number; ok: boolean; reason: string | null } | null = null; + + async function probeUpstream(): Promise<{ ok: boolean; reason: string | null }> { + if (!deps.upstream) + return { ok: false, reason: "未配置浏览器桌面地址(BROWSER_PROVIDER_URL / BROWSER_DESKTOP_URL)" }; + if (!passwords) return { ok: false, reason: "浏览器服务未设置访问凭据 CAMOFOX_ACCESS_KEY,桌面模式不可用" }; + if (probe && Date.now() - probe.at < probeTtl) return probe; + let ok = false; + let reason: string | null = null; + try { + const r = await doFetch(`${deps.upstream}/`, { signal: AbortSignal.timeout(2500) }); + await r.body?.cancel(); + ok = r.ok; + if (!ok) reason = `浏览器桌面返回 ${r.status}`; + } catch { + reason = "浏览器服务没有桌面(旧版 camofox 镜像或桌面未启动),使用截图模式"; + } + probe = { at: Date.now(), ok, reason }; + return probe; + } + + app.get(`/browser/desktop/:sessionId/${STATUS_SEGMENT}`, async (c) => { + const { holder } = consoleActor(c); + const sessionId = sessionIdOf(c); + const s = await requireSession(deps, sessionId, holder); + const p = await probeUpstream(); + const data: DesktopStatus = { + available: p.ok, + reason: p.reason, + role: s.role, + leaseExpiresAt: s.leaseExpiresAt, + path: `/api/v1/browser/desktop/${sessionId}/`, + requiresSecureContext: true, + }; + return c.json({ ok: true, data }); + }); + + // Selkies 客户端用相对路径(静态资源、api/websockets),目录地址必须以 / 结尾 + app.get("/browser/desktop/:sessionId", (c) => { + consoleActor(c); + return c.redirect(`/api/v1/browser/desktop/${sessionIdOf(c)}/`, 302); + }); + + app.all("/browser/desktop/:sessionId/*", async (c) => { + const { holder, sessionRowId } = consoleActor(c); + const sessionId = sessionIdOf(c); + if (!deps.upstream || !passwords) throw new ApiException("upstream_unavailable", "桌面模式未启用"); + const prefix = `/api/v1/browser/desktop/${sessionId}/`; + const url = new URL(c.req.url); + const rest = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : ""; + if (rest.split("/").some((seg) => seg === ".." || seg === ".")) throw new ApiException("bad_request", "路径不合法"); + const target = `${deps.upstream}/${rest}${url.search}`; + const isApi = rest === "api" || rest.startsWith("api/"); + + if (c.req.header("upgrade")?.toLowerCase() === "websocket") { + if (!sameOrigin(c.req.raw)) throw new ApiException("forbidden", "桌面连接必须来自本站页面"); + const s = await requireSession(deps, sessionId, holder); + return bridgeWebSocket(c.req.raw, target.replace(/^http/, "ws"), s.role, { + auth: (role) => basicAuth(passwords[role]), + recheck: async () => { + if (!(await deps.consoleSessionValid(sessionRowId))) return null; + const r = await deps.checkSession(sessionId, holder); + return r.ok ? r.role : null; + }, + roleCheckMs, + live, + log, + }); + } + + // HTTP:静态客户端(nginx,无需口令)与 Selkies HTTP 接口(api/*,要口令) + let auth: string | null = null; + if (isApi) { + const s = await requireSession(deps, sessionId, holder); + auth = basicAuth(passwords[s.role]); + } + const method = c.req.method; + if (!["GET", "HEAD"].includes(method) && !isApi) throw new ApiException("bad_request", "不支持的请求"); + let res: Response; + try { + res = await doFetch(target, { + method, + headers: upstreamHeaders(c.req.raw, auth), + body: ["GET", "HEAD"].includes(method) ? undefined : c.req.raw.body, + redirect: "manual", + }); + } catch { + throw new ApiException("upstream_unavailable", "浏览器桌面不可达"); + } + if (res.status === 401) { + await res.body?.cancel(); + log("selkies rejected desktop credentials", { status: 401 }); + throw new ApiException( + "upstream_unavailable", + "浏览器桌面口令不匹配。检查 app 与浏览器容器的 CAMOFOX_ACCESS_KEY 是否一致", + ); + } + return new Response(res.body, { status: res.status, headers: downstreamHeaders(res) }); + }); + + return { + closeAll() { + for (const close of [...live]) close(); + }, + }; +} + +interface BridgeOpts { + auth(role: DesktopRole): string; + /** 返回当前应有的角色;null = 不再允许(会话/控制台会话失效) */ + recheck(): Promise; + roleCheckMs: number; + live: Set<() => void>; + log(msg: string, extra?: Record): void; +} + +/** 浏览器 ⇄ app ⇄ Selkies 的 WebSocket 双向转发;角色变化时断开(客户端重连后按新角色认证) */ +function bridgeWebSocket(req: Request, target: string, role: DesktopRole, o: BridgeOpts): Response { + const protocols = (req.headers.get("sec-websocket-protocol") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const { socket: client, response } = Deno.upgradeWebSocket(req, protocols[0] ? { protocol: protocols[0] } : {}); + client.binaryType = "arraybuffer"; + // Deno 的客户端 WebSocket 支持自定义请求头;不带 Origin(Selkies 对无 Origin 的非浏览器客户端放行) + // (tsc 按 DOM 类型只认 protocols 参数;Deno 运行时接受 { headers, protocols }) + const wsOptions = { headers: { authorization: o.auth(role) }, ...(protocols.length ? { protocols } : {}) }; + const upstream = new WebSocket(target, wsOptions as unknown as string[]); + upstream.binaryType = "arraybuffer"; + const pending: (string | ArrayBuffer)[] = []; + let closed = false; + let timer: ReturnType | undefined; + + const close = (code = 1000, reason = "") => { + if (closed) return; + closed = true; + if (timer !== undefined) clearInterval(timer); + o.live.delete(close); + try { + if (client.readyState <= WebSocket.OPEN) client.close(code, reason); + } catch { + /* 已关闭 */ + } + try { + if (upstream.readyState <= WebSocket.OPEN) upstream.close(1000); + } catch { + /* 已关闭 */ + } + }; + o.live.add(close); + + client.onmessage = (e) => { + if (upstream.readyState === WebSocket.OPEN) upstream.send(e.data); + else if (upstream.readyState === WebSocket.CONNECTING) pending.push(e.data); + }; + upstream.onopen = () => { + for (const m of pending.splice(0)) upstream.send(m); + }; + upstream.onmessage = (e) => { + if (client.readyState === WebSocket.OPEN) client.send(e.data); + }; + client.onclose = () => close(); + upstream.onclose = (e) => close(e.code === 1000 || e.code === 1001 ? 1000 : 1011, "upstream closed"); + client.onerror = () => close(1011); + upstream.onerror = () => { + o.log("desktop upstream websocket error"); + close(1011, "upstream error"); + }; + + timer = setInterval(async () => { + try { + const next = await o.recheck(); + if (next === null) close(4401, "session ended"); + else if (next !== role) close(4000, `role ${next}`); + } catch (err) { + o.log("desktop role recheck failed", { err: String(err) }); + } + }, o.roleCheckMs); + return response; +} diff --git a/apps/server/src/modules/desktop/index.ts b/apps/server/src/modules/desktop/index.ts new file mode 100644 index 00000000..a05deae4 --- /dev/null +++ b/apps/server/src/modules/desktop/index.ts @@ -0,0 +1,32 @@ +import type { ServerContext, ServerModule } from "../types.ts"; +import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts"; + +/** + * WT-1 原站视图桌面模式:/api/v1/browser/desktop/{sessionId}/* 反代浏览器桌面容器(Selkies)的 HTTP 与 WebSocket, + * 按人工租约注入控制 / 只读口令。只用会话 / 租约表(只读),不改浏览器模块。 + * 桌面地址:BROWSER_DESKTOP_URL,缺省由 BROWSER_PROVIDER_URL 推出(端口换 3000)。 + */ +export function createDesktopModule(): ServerModule { + let closeAll: (() => void) | null = null; + return { + name: "desktop", + registerRoutes(app, ctx: ServerContext) { + const r = registerDesktopRoutes(app, { + upstream: desktopUpstream({ + providerUrl: ctx.config?.BROWSER_PROVIDER_URL, + desktopUrl: Deno.env.get("BROWSER_DESKTOP_URL") || undefined, + }), + accessKey: ctx.config?.BROWSER_ACCESS_KEY ?? null, + ...dbSessionChecks(ctx.db), + log: (msg, extra) => ctx.logger.warn({ component: "desktop", ...extra }, msg), + }); + closeAll = r.closeAll; + }, + registerTasks() {}, + stop() { + closeAll?.(); + }, + }; +} + +export const desktopModule = createDesktopModule(); diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts index 4e6a4a55..514e3025 100644 --- a/apps/server/src/modules/index.ts +++ b/apps/server/src/modules/index.ts @@ -3,6 +3,7 @@ import { agentModule } from "./agent/index.ts"; import { authSettingsModule } from "./auth-settings/index.ts"; import { browserModule } from "./browser/index.ts"; import { cacheModule } from "./cache/index.ts"; +import { desktopModule } from "./desktop/index.ts"; import { fileManagerModule } from "./file-manager/index.ts"; import { mcpModule } from "./mcp/index.ts"; import { piModule } from "./pi/index.ts"; @@ -29,6 +30,7 @@ export const SERVER_MODULES: readonly ServerModule[] = [ schedulesModule, searchModule, browserModule, + desktopModule, mcpModule, webSearchModule, cacheModule, diff --git a/apps/server/test/modules.test.ts b/apps/server/test/modules.test.ts index 744887d2..a2394ffc 100644 --- a/apps/server/test/modules.test.ts +++ b/apps/server/test/modules.test.ts @@ -43,7 +43,7 @@ const pingModule: ServerModule = { }; describe("ServerModule 骨架", () => { - it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 浏览器 + MCP + 网页搜索 + 缓存 + 进程与健康(末尾),名字唯一", () => { + it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 浏览器 + 桌面反代 + MCP + 网页搜索 + 缓存 + 进程与健康(末尾),名字唯一", () => { expect(SERVER_MODULES.map((m) => m.name)).toEqual([ "auth-settings", "sources-identity", @@ -54,6 +54,7 @@ describe("ServerModule 骨架", () => { "schedules", "search", "browser", + "desktop", "mcp", "websearch", "cache",