feat(server): proxy browser desktop with lease-based control/view credentials

/api/v1/browser/desktop/{sessionId}/* proxies Selkies HTTP and WebSocket,
console sessions only. The holder of the active human lease gets the control
password, everyone else the view-only one; the WS bridge rechecks every 3s
and drops the connection on role change so the client reconnects downgraded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 02:30:55 +08:00
1 parent 880cec4d23
commit 7e028fa23e
5 files changed
+563 -1

No files matched your search

@@ -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<Principal>; role?: DesktopRole; check?: DesktopDeps["checkSession"] } = {},
) {
const calls: { url: string; headers: Headers; method: string }[] = [];
const app = new OpenAPIHono<AuthEnv>();
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<AuthEnv>();
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);
});
});
+380
View File
@@ -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<DesktopRole, string> {
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 模块写租约时同一格式 `<principal.kind>:<name>` */
checkSession(sessionId: string, holder: string): Promise<SessionCheck>;
/** 控制台会话是否仍有效(WS 长连接期间复查) */
consoleSessionValid(sessionRowId: string): Promise<boolean>;
fetch?: typeof fetch;
/** 角色复查间隔 */
roleCheckMs?: number;
/** 可用性探测缓存 */
probeTtlMs?: number;
log?: (msg: string, extra?: Record<string, unknown>) => void;
}
/** 数据库实现:会话必须属于环境变量登记的浏览器服务(camofox-env,即 compose 自带的桌面容器) */
export function dbSessionChecks(db: Kysely<DB>): Pick<DesktopDeps, "checkSession" | "consoleSessionValid"> {
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<string, unknown>;
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<AuthEnv>): { 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<AuthEnv>): 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<DesktopRole | null>;
roleCheckMs: number;
live: Set<() => void>;
log(msg: string, extra?: Record<string, unknown>): 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<typeof setInterval> | 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;
}
+32
View File
@@ -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();
+2
View File
@@ -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,
+2 -1
View File
@@ -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",