feat(server): browser module with DB-backed sessions, takeover lease and browser.* capabilities

/browser/providers, /browser/sessions (open/close/list), navigate/snapshot/screenshot/
click/type/scroll/select/extract/download and control acquire/release. Human input
needs an active lease; expiry or captcha moves the session to waiting with a decision;
release forces a fresh snapshot. Downloads are staged under DATA_DIR/tmp, verified by
infohash and submitted through POST /acquisition-plans. Capability count 21 -> 25.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:17:46 +08:00
1 parent c17d4b90bd
commit adffe36846
12 files changed
+2133 -5

No files matched your search

+292
View File
@@ -0,0 +1,292 @@
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import {
Api,
type ApiScope,
apiResponse,
type BrowserDownloadResult,
type BrowserNavigation,
type BrowserScreenshotView,
type EndpointDef,
type ManualAcquisitionResult,
} from "@umt/contracts";
import type { Context } from "hono";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import { InternalDispatcher } from "../search/dispatch.ts";
import type { ModuleApp } from "../types.ts";
import { type Actor, type BrowserService, BrowserServiceError, imageSize } from "./service.ts";
// C1-E 浏览器 HTTP 端点:原站视图与外部程序共用。读(会话/快照/截图)任何时候可用;
// 输入类动作(导航/点击/输入/滚动/选择/标签页/触发下载)只接受持有人工租约的调用者(先接管)。
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
interface Input<D extends EndpointDef> {
params: Infer<D["params"]>;
query: Infer<D["query"]>;
body: Infer<D["body"]>;
c: Context<AuthEnv>;
}
const CODE = {
not_found: "not_found",
conflict: "conflict",
precondition: "precondition_failed",
bad_request: "bad_request",
upstream: "upstream_unavailable",
rate_limited: "rate_limited",
} as const;
function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (input: Input<D>) => Promise<unknown>) {
const request: RouteConfig["request"] = {};
if (def.params) request.params = def.params as never;
if (def.query) request.query = def.query as never;
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
const r = createRoute({
method: def.method,
path: def.path,
summary: def.summary,
tags: ["browser"],
middleware: [requireScope(def.scope as ApiScope)] as never,
request,
responses: {
200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
app.openapi(r, (async (c: any) => {
const input = {
params: def.params ? c.req.valid("param") : {},
query: def.query ? c.req.valid("query") : {},
body: def.body ? c.req.valid("json") : {},
c,
} as Input<D>;
try {
const data = def.response.parse(await handler(input));
return c.json({ ok: true, data }, 200);
} catch (e) {
if (e instanceof BrowserServiceError) throw new ApiException(CODE[e.code], e.message);
throw e;
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
}) as any);
}
/** 人工操作者:控制台会话 / API Key 的身份(租约 holder) */
function human(c: Context<AuthEnv>): Actor {
const p = c.get("principal");
return { kind: "human", holder: `${p?.kind ?? "unknown"}:${p?.name ?? p?.id ?? "?"}` };
}
const b64 = (data: Uint8Array) => Buffer.from(data).toString("base64");
export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService) {
const dispatch = new InternalDispatcher(app);
const B = Api.browser;
route(app, B.providers, async ({ query }) => ({ items: await svc().listProviders(query.probe === "true") }));
route(app, B.sessions, async ({ query }) => ({
items: await svc().list({
...(query.siteId ? { siteId: query.siteId } : {}),
includeClosed: query.includeClosed === "true",
}),
}));
route(app, B.open, async ({ body }) => {
const id = await svc().open({
siteId: body.siteId,
...(body.accountId !== undefined ? { accountId: body.accountId } : {}),
...(body.providerId ? { providerId: body.providerId } : {}),
...(body.url ? { url: body.url } : {}),
});
return svc().view(id);
});
route(app, B.get, async ({ params }) => svc().view(params.id));
route(app, B.close, async ({ params }) => {
await svc().close(params.id, "closed_by_user");
return svc().view(params.id);
});
route(app, B.openTab, async ({ params, body, c }) => {
await svc().run(params.id, human(c), async (p, ref, x) => {
if (body.url) await svc().limited(x.site);
return p.openTab(ref.sessionId, { ...(body.url ? { url: body.url } : {}), budget: x.budget, as: "human" });
});
return svc().view(params.id);
});
route(app, B.closeTab, async ({ params, c }) => {
await svc().run(params.id, human(c), (p, ref) => p.closeTab(ref), { tabId: params.tabId });
return svc().view(params.id);
});
route(app, B.navigate, async ({ params, body, c }): Promise<BrowserNavigation> => {
if (!body.url && !body.action) throw new ApiException("bad_request", "url 与 action 二选一");
const out = await svc().run(
params.id,
human(c),
async (p, ref, x) => {
await svc().limited(x.site);
const nav = body.url ? await p.navigate(ref, body.url, x.budget) : await p.history(ref, body.action!, x.budget);
return { ...nav, loginRequired: p.sessionInfo(ref.sessionId)?.loginPage ?? false };
},
body.tabId ? { tabId: body.tabId } : {},
);
return { ...out, session: await svc().view(params.id) };
});
route(app, B.snapshot, async ({ params, query, c }) =>
svc().run(
params.id,
human(c),
(p, ref) => p.snapshot(ref, query.offset !== undefined ? { offset: query.offset } : {}),
{ write: false, ...(query.tabId ? { tabId: query.tabId } : {}) },
),
);
route(app, B.screenshot, async ({ params, query, c }): Promise<BrowserScreenshotView> => {
const shot = await svc().run(params.id, human(c), (p, ref) => p.screenshot(ref, { maxBytes: 8 * 1024 * 1024 }), {
write: false,
persist: false,
...(query.tabId ? { tabId: query.tabId } : {}),
});
const view = await svc().view(params.id);
return {
mimeType: shot.mimeType,
dataBase64: b64(shot.data),
...imageSize(shot.data),
url: shot.url,
modelSafe: shot.modelSafe,
capturedAt: shot.capturedAt,
controller: view.controller,
};
});
const ok = async (id: string) => ({ ok: true as const, session: await svc().view(id) });
route(app, B.click, async ({ params, body, c }) => {
await svc().run(
params.id,
human(c),
(p, ref) => p.click(ref, body.target),
body.tabId ? { tabId: body.tabId } : {},
);
return ok(params.id);
});
route(app, B.type, async ({ params, body, c }) => {
await svc().run(
params.id,
human(c),
async (p, ref, x) => {
if (body.key) return p.press(ref, body.key);
if (!body.credential && body.text === undefined)
throw new BrowserServiceError("bad_request", "text / credential / key 至少给一个");
const extra = {
...(body.submit !== undefined ? { submit: body.submit } : {}),
...(body.clear !== undefined ? { clear: body.clear } : {}),
};
return p.type(
ref,
body.target ?? null,
body.credential
? { kind: "secret", siteId: x.row.site_id, accountId: x.row.account_id, field: body.credential, ...extra }
: { kind: "text", text: body.text ?? "", ...extra },
);
},
body.tabId ? { tabId: body.tabId } : {},
);
return ok(params.id);
});
route(app, B.scroll, async ({ params, body, c }) => {
await svc().run(
params.id,
human(c),
(p, ref) => p.scroll(ref, { direction: body.direction, amount: body.amount }),
body.tabId ? { tabId: body.tabId } : {},
);
return ok(params.id);
});
route(app, B.select, async ({ params, body, c }) => {
await svc().run(
params.id,
human(c),
(p, ref) => p.select(ref, body.target, body.value),
body.tabId ? { tabId: body.tabId } : {},
);
return ok(params.id);
});
route(app, B.extract, async ({ params, body, c }) => ({
data: await svc().run(params.id, human(c), (p, ref) => p.extract(ref, body.schema), {
write: false,
...(body.tabId ? { tabId: body.tabId } : {}),
}),
}));
// 下载:给 target 视为人工输入(需接管);不给 target 只等待已触发的下载。
// 产物暂存核验后,给 submit 时转调 POST /acquisition-plans(同一鉴权、同一计划/去重/阈值/授权检查)。
route(app, B.download, async ({ params, body, c }): Promise<BrowserDownloadResult> => {
const artifact = await svc().run(
params.id,
human(c),
async (p, ref) => {
if (body.target) await p.click(ref, body.target);
return p.waitForDownload(ref, { timeoutMs: body.timeoutMs, maxBytes: 10 * 1024 * 1024 });
},
{ write: !!body.target, ...(body.tabId ? { tabId: body.tabId } : {}) },
);
const v = await svc().verifyDownload(params.id, artifact);
let submission: ManualAcquisitionResult | null = null;
let note: string | null = null;
if (v.duplicate) note = "同一会话已捕获过这个文件,未重复提交";
else if (!v.verification.valid) note = "不是有效的种子文件,未提交";
else if (!v.sourceReleaseId) note = "资源不在本地索引(先快照或搜索该站点),未提交";
else if (body.submit) {
try {
submission = await dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
...body.submit,
sourceReleaseId: v.sourceReleaseId,
});
if (!submission.submitted) note = "提交未生成下载计划,见 rejected";
} catch (e) {
// 与 POST /acquisition-plans 相同的拒绝(未识别、无授权等):产物已暂存,结果照常返回
if (!(e instanceof ApiException) || e.code === "internal") throw e;
note = `提交未通过:${e.message}`;
}
}
await svc().recordDownload(params.id, {
sha256: artifact.sha256,
artifactId: artifact.id,
sourceReleaseId: v.sourceReleaseId,
planId: submission?.plan?.id ?? v.duplicate?.planId ?? null,
});
return {
artifact,
verification: v.verification,
sourceReleaseId: v.sourceReleaseId,
duplicate: !!v.duplicate,
submission,
note,
};
});
route(app, B.acquire, async ({ params, body, c }) => {
const lease = await svc().acquire(params.id, human(c).holder, body.reason, body.ttlMs);
return { lease, session: await svc().view(params.id) };
});
route(app, B.release, async ({ params, body }) => {
const lease = await svc().release(params.id, {
...(body.leaseId ? { leaseId: body.leaseId } : {}),
reason: body.reason,
to: body.to,
});
return { lease, session: await svc().view(params.id) };
});
}