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:
1 parent
c17d4b90bd
commit
adffe36846
12 files changed
+2133
-5
No files matched your search
@@ -0,0 +1,275 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { MOCK_ROW_H, MOCK_ROW_Y0, MOCK_VIEWPORT } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { registerBrowserCapabilities } from "../capabilities.ts";
|
||||
import { createBrowserModule } from "../index.ts";
|
||||
import {
|
||||
ACCESS_KEY,
|
||||
type BrowserHarness,
|
||||
createBrowserHarness,
|
||||
type Loose,
|
||||
ORIGIN,
|
||||
OTHER_KEY,
|
||||
PASSKEY,
|
||||
PASSWORD,
|
||||
pgAvailable,
|
||||
READ_KEY,
|
||||
} from "./harness.ts";
|
||||
|
||||
// C1-E 浏览器 HTTP 端点(对 testkit 交互式 mock camofox 验证,未连真实实例):
|
||||
// 浏览器服务列表与探测、会话开关(数据库为准)、接管租约、坐标点击与凭证注入、截图、下载暂存核验与提交、异常路径。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const row = (i: number) => ({ x: 200, y: MOCK_ROW_Y0 + MOCK_ROW_H * i + MOCK_ROW_H / 2 });
|
||||
|
||||
describe.skipIf(!up)("浏览器 HTTP 端点", () => {
|
||||
let h: BrowserHarness;
|
||||
let sid: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("api");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("GET /browser/providers:凭证只给引用;probe 写回能力与健康,verified 只来自人工核验配置", async () => {
|
||||
const r = await h.req("/browser/providers");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.items).toHaveLength(1);
|
||||
expect(r.body.data.items[0]).toMatchObject({ kind: "camofox", enabled: true, capabilities: null, health: null });
|
||||
expect(JSON.stringify(r.body)).not.toContain(ACCESS_KEY);
|
||||
const p = await h.req("/browser/providers?probe=true");
|
||||
const item = p.body.data.items[0];
|
||||
expect(item.health.status).toBe("ok");
|
||||
expect(item.healthStatus).toBe("healthy");
|
||||
expect(item.capabilities.upstreamVersion).toBe("1.17.0");
|
||||
expect(item.capabilities.snapshot).toMatchObject({ supported: true, verified: true, source: "probed" });
|
||||
expect(item.capabilities.interact).toMatchObject({ supported: true, verified: false });
|
||||
expect(item.capabilities.takeover).toMatchObject({ supported: true, verified: false });
|
||||
// 探测带 Bearer 访问凭据
|
||||
expect(h.mock.callsTo("/openapi.json")[0]?.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
|
||||
expect((await h.req("/browser/providers", { key: READ_KEY })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("打开会话:profile 作 camofox userId,会话/标签页落库;同站点账号再开返回同一会话", async () => {
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/torrents.php` } });
|
||||
expect(r.status).toBe(200);
|
||||
const s = r.body.data;
|
||||
sid = s.id;
|
||||
expect(s).toMatchObject({ siteId: h.siteId, accountId: h.accountId, controller: "automation", lease: null });
|
||||
expect(s.profileKey).toBe(`umt-site-${h.siteId}-acct-${h.accountId}`);
|
||||
// 登录失效:落到登录页
|
||||
expect(s.currentUrl).toContain("/login.php");
|
||||
expect(s.tabs).toHaveLength(1);
|
||||
expect([...h.mock.tabs.values()][0]?.userId).toBe(s.profileKey);
|
||||
const dbRow = await h.db.selectFrom("browser_session").selectAll().where("id", "=", sid).executeTakeFirstOrThrow();
|
||||
expect(dbRow.controller).toBe("automation");
|
||||
const again = await h.req("/browser/sessions", { body: { siteId: h.siteId } });
|
||||
expect(again.body.data.id).toBe(sid);
|
||||
const list = await h.req(`/browser/sessions?siteId=${h.siteId}`);
|
||||
expect(list.body.data.items.map((x: Loose) => x.id)).toEqual([sid]);
|
||||
});
|
||||
|
||||
it("输入类动作要先接管;他人接管中 409;读(快照/截图)随时可用", async () => {
|
||||
const nav = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: `${ORIGIN}/` } });
|
||||
expect(nav.status).toBe(409);
|
||||
expect(nav.body.error.message).toContain("先接管");
|
||||
const shot = await h.req(`/browser/sessions/${sid}/screenshot`);
|
||||
expect(shot.status).toBe(200);
|
||||
expect(shot.body.data).toMatchObject({
|
||||
mimeType: "image/png",
|
||||
width: MOCK_VIEWPORT.width,
|
||||
height: MOCK_VIEWPORT.height,
|
||||
modelSafe: false,
|
||||
controller: "automation",
|
||||
});
|
||||
const png = Buffer.from(shot.body.data.dataBase64, "base64");
|
||||
expect(png.subarray(1, 4).toString()).toBe("PNG");
|
||||
|
||||
const a = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: { reason: "登录" } });
|
||||
expect(a.status).toBe(200);
|
||||
expect(a.body.data.lease).toMatchObject({ holderKind: "human", status: "active" });
|
||||
expect(a.body.data.session.controller).toBe("human");
|
||||
const other = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {}, key: OTHER_KEY });
|
||||
expect(other.status).toBe(409);
|
||||
const otherClick = await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(1) }, key: OTHER_KEY });
|
||||
expect(otherClick.status).toBe(409);
|
||||
});
|
||||
|
||||
it("人工:导航限站点域名;坐标点击 + 凭证注入登录;快照遮蔽密码与 passkey", async () => {
|
||||
const evil = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: "https://evil.example/" } });
|
||||
expect(evil.status).toBe(400);
|
||||
expect(evil.body.error.message).toContain("白名单");
|
||||
expect((await h.req(`/browser/sessions/${sid}/navigate`, { body: {} })).status).toBe(400);
|
||||
const nav = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: `${ORIGIN}/torrents.php` } });
|
||||
expect(nav.body.data).toMatchObject({ loginRequired: true, ok: true });
|
||||
|
||||
expect((await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(1) } })).status).toBe(200);
|
||||
await h.req(`/browser/sessions/${sid}/type`, { body: { credential: "username" } });
|
||||
await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(2) } });
|
||||
const typed = await h.req(`/browser/sessions/${sid}/type`, { body: { credential: "password" } });
|
||||
expect(typed.status).toBe(200);
|
||||
expect(JSON.stringify(typed.body)).not.toContain(PASSWORD);
|
||||
// 登录前快照:密码框有值,返回前已遮蔽
|
||||
const snap = await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
expect(snap.body.data.text).toContain("登录");
|
||||
expect(snap.body.data.text).not.toContain(PASSWORD);
|
||||
// 凭证确实下发到了浏览器(mock 记录),点击按钮完成登录
|
||||
expect(h.mock.actions.some((x) => x.action === "type" && x.body.text === PASSWORD)).toBe(true);
|
||||
await h.req(`/browser/sessions/${sid}/type`, { body: { key: "Enter" } });
|
||||
const after = await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
expect(after.body.data.text).toContain("种子列表");
|
||||
expect(after.body.data.text).not.toContain(PASSKEY);
|
||||
expect(after.body.data.url).toContain("/torrents.php");
|
||||
// 后退 / 滚动
|
||||
expect((await h.req(`/browser/sessions/${sid}/scroll`, { body: { direction: "down" } })).status).toBe(200);
|
||||
const back = await h.req(`/browser/sessions/${sid}/navigate`, { body: { action: "reload" } });
|
||||
expect(back.status).toBe(200);
|
||||
});
|
||||
|
||||
it("下载:点击捕获 → 暂存 0600 文件、sha256/infohash 核验、按 infohash 匹配资源,经 /acquisition-plans 提交;同一文件不重复提交", async () => {
|
||||
await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
const r = await h.req(`/browser/sessions/${sid}/download`, {
|
||||
body: { target: { ref: "e11" }, submit: { note: "browser" } },
|
||||
});
|
||||
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
||||
const d = r.body.data;
|
||||
expect(d.artifact.sha256).toBe(createHash("sha256").update(h.torrent.bytes).digest("hex"));
|
||||
expect(d.artifact.sourceUrl).not.toContain(PASSKEY);
|
||||
expect(d.verification).toMatchObject({ valid: true, infohashV1: h.torrent.infoHash, private: true });
|
||||
expect(d.sourceReleaseId).toBe(h.releaseId);
|
||||
// 资源未识别:与 POST /acquisition-plans 相同的拒绝,产物照常返回
|
||||
expect(d.submission).toBeNull();
|
||||
expect(d.note).toContain("提交未通过");
|
||||
const files = await readdir(h.stagingDir);
|
||||
expect(files).toHaveLength(1);
|
||||
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
|
||||
|
||||
// 识别到作品后再下载:走同一流程(无版本目标 → no_slot);同一文件第二次捕获标 duplicate、不提交
|
||||
const entity = await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.updateTable("source_release")
|
||||
.set({ media_entity_id: entity.id })
|
||||
.where("id", "=", h.releaseId)
|
||||
.execute();
|
||||
await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
const dup = await h.req(`/browser/sessions/${sid}/download`, { body: { target: { ref: "e11" }, submit: {} } });
|
||||
expect(dup.body.data).toMatchObject({ duplicate: true, submission: null });
|
||||
expect(dup.body.data.note).toContain("未重复提交");
|
||||
// 未接管时不能点击触发下载
|
||||
await h.req(`/browser/sessions/${sid}/control/release`, { body: {} });
|
||||
const denied = await h.req(`/browser/sessions/${sid}/download`, { body: { target: { ref: "e11" } } });
|
||||
expect(denied.status).toBe(409);
|
||||
});
|
||||
|
||||
it("交还后需重新读取页面;租约到期 → 等待处理 + 待处理事项,自动化被拒", async () => {
|
||||
const v = await h.req(`/browser/sessions/${sid}`);
|
||||
expect(v.body.data).toMatchObject({ controller: "automation", needsSnapshot: true, lease: null });
|
||||
await h.grant("browser.interact");
|
||||
await expect(
|
||||
h.registry.invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e11" } }, h.agentCtx()),
|
||||
).rejects.toMatchObject({ code: "conflict" });
|
||||
|
||||
await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {} });
|
||||
await h.db
|
||||
.updateTable("browser_control_lease")
|
||||
.set({ acquired_at: new Date(Date.now() - 120_000), expires_at: new Date(Date.now() - 1_000) })
|
||||
.where("session_id", "=", sid)
|
||||
.where("status", "=", "active")
|
||||
.execute();
|
||||
const w = await h.req(`/browser/sessions/${sid}`);
|
||||
expect(w.body.data).toMatchObject({ controller: "waiting", waitingReason: "lease_expired", lease: null });
|
||||
const dec = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", sid).execute();
|
||||
expect(dec).toHaveLength(1);
|
||||
expect(dec[0]).toMatchObject({ status: "awaiting_user", subject_type: "browser_session" });
|
||||
await expect(
|
||||
h.registry.invoke("browser.interact", { sessionId: sid, action: "scroll" }, h.agentCtx()),
|
||||
).rejects.toMatchObject({ code: "conflict" });
|
||||
// 人工重新接管并交还 → 自动化恢复,待处理事项关闭
|
||||
await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {} });
|
||||
await h.req(`/browser/sessions/${sid}/control/release`, { body: { reason: "处理完" } });
|
||||
const d2 = await h.db
|
||||
.selectFrom("decision")
|
||||
.select("status")
|
||||
.where("subject_id", "=", sid)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d2.status).toBe("resolved");
|
||||
});
|
||||
|
||||
it("服务重启:按数据库恢复会话,旧引用失效;上游回收的标签页标 lost", async () => {
|
||||
// 新模块实例 = 进程重启(适配器内存为空)
|
||||
const fresh = createBrowserModule({ stagingDir: h.stagingDir });
|
||||
const reg = (await import("../../agent/capability-registry.ts")).createCapabilityRegistry({
|
||||
logger: h.ctx.logger,
|
||||
db: h.db,
|
||||
settings: h.ctx.settings,
|
||||
});
|
||||
registerBrowserCapabilities(reg, () => fresh.service(h.ctx));
|
||||
await expect(
|
||||
reg.invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e11" } }, h.agentCtx()),
|
||||
).rejects.toMatchObject({ code: "conflict" });
|
||||
const snap = (await reg.invoke("browser.snapshot", { siteId: h.siteId }, h.agentCtx())) as Loose;
|
||||
expect(snap.snapshot.text).toContain("种子列表");
|
||||
expect(snap).toMatchObject({ controller: "automation", loginRequired: false });
|
||||
expect((await fresh.service(h.ctx).view(sid)).needsSnapshot).toBe(false);
|
||||
|
||||
const tab = (
|
||||
await h.db.selectFrom("browser_tab").selectAll().where("session_id", "=", sid).executeTakeFirstOrThrow()
|
||||
).upstream_tab_id;
|
||||
h.mock.dropTab(tab);
|
||||
await expect(reg.invoke("browser.snapshot", { sessionId: sid }, h.agentCtx())).rejects.toMatchObject({
|
||||
code: "conflict",
|
||||
});
|
||||
const t = await h.db
|
||||
.selectFrom("browser_tab")
|
||||
.select("status")
|
||||
.where("upstream_tab_id", "=", tab)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(t.status).toBe("lost");
|
||||
});
|
||||
|
||||
it("站点停用 → 会话关闭、自动访问停止;关闭会话撤销租约并清上游标签页", async () => {
|
||||
const s2 = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null } });
|
||||
expect(s2.status).toBe(200);
|
||||
const id2 = s2.body.data.id;
|
||||
expect(id2).not.toBe(sid);
|
||||
await h.req(`/browser/sessions/${id2}/control/acquire`, { body: {} });
|
||||
const closed = await h.req(`/browser/sessions/${id2}`, { method: "DELETE" });
|
||||
expect(closed.body.data).toMatchObject({ controller: "closed", closeReason: "closed_by_user", lease: null });
|
||||
const lease = await h.db.selectFrom("browser_control_lease").selectAll().where("session_id", "=", id2).execute();
|
||||
expect(lease.every((l) => l.status === "revoked")).toBe(true);
|
||||
|
||||
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", h.siteId).execute();
|
||||
const r = await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
expect(r.status).toBe(412);
|
||||
const s = await h.db.selectFrom("browser_session").selectAll().where("id", "=", sid).executeTakeFirstOrThrow();
|
||||
expect(s).toMatchObject({ controller: "closed", close_reason: "site_disabled" });
|
||||
expect((await h.req("/browser/sessions", { body: { siteId: h.siteId } })).status).toBe(412);
|
||||
await h.db.updateTable("site").set({ enabled: true }).where("id", "=", h.siteId).execute();
|
||||
});
|
||||
|
||||
it("浏览器服务认证失败(401)与超时:报上游不可用,不无限重试", async () => {
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId } });
|
||||
const id = r.body.data.id;
|
||||
await h.req(`/browser/sessions/${id}/control/acquire`, { body: {} });
|
||||
h.mock.fault(/\/navigate$/, { status: 401, times: 5 });
|
||||
const a = await h.req(`/browser/sessions/${id}/navigate`, { body: { url: `${ORIGIN}/` } });
|
||||
expect(a.status).toBe(412);
|
||||
expect(JSON.stringify(a.body)).not.toContain(ACCESS_KEY);
|
||||
h.mock.clearFaults();
|
||||
h.mock.fault(/\/navigate$/, { delayMs: 2_500, times: 5 });
|
||||
const before = h.mock.callsTo(/\/navigate$/).length;
|
||||
const t = await h.req(`/browser/sessions/${id}/navigate`, { body: { url: `${ORIGIN}/` } });
|
||||
expect(t.status).toBe(502);
|
||||
// maxRetries = 1:共 2 次
|
||||
expect(h.mock.callsTo(/\/navigate$/).length - before).toBe(2);
|
||||
h.mock.clearFaults();
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { MOCK_ROW_H, MOCK_ROW_Y0 } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BrowserHarness,
|
||||
createBrowserHarness,
|
||||
type Loose,
|
||||
ORIGIN,
|
||||
PASSKEY,
|
||||
PASSWORD,
|
||||
pgAvailable,
|
||||
} from "./harness.ts";
|
||||
|
||||
// C1-E e2e(mock 站点,未连真实 camofox):
|
||||
// Agent 打开站点 → 登录失效 + 验证码 → 会话转等待处理(待处理事项)→ 自动化被拒 →
|
||||
// 人在原站视图接管(截图坐标点击 + 输入,密码由服务端注入)完成登录 → 交还 →
|
||||
// Agent 的旧引用被拒,重新读取页面后继续:捕获种子下载、核验并匹配到本地资源。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const at = (i: number) => ({ x: 320, y: MOCK_ROW_Y0 + MOCK_ROW_H * i + 10 });
|
||||
|
||||
describe.skipIf(!up)("e2e:登录失效 → 人工接管 → 交还 → Agent 重新读取并继续", () => {
|
||||
let h: BrowserHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("e2e", { captcha: true });
|
||||
await h.grant("browser.navigate", "browser.interact", "browser.download");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("全流程", async () => {
|
||||
const agent = h.agentCtx();
|
||||
const invoke = (id: string, input: unknown) => h.registry.invoke(id, input, agent) as Promise<Loose>;
|
||||
|
||||
// 1. Agent 打开种子列表:登录已失效,落到登录页
|
||||
const nav = await invoke("browser.navigate", { siteId: h.siteId, url: `${ORIGIN}/torrents.php` });
|
||||
expect(nav).toMatchObject({ loginRequired: true, controller: "automation" });
|
||||
const sid = nav.sessionId as string;
|
||||
|
||||
// 2. Agent 读页面:有验证码 → 会话转等待处理,生成待处理事项
|
||||
const snap = await invoke("browser.snapshot", { sessionId: sid });
|
||||
expect(snap).toMatchObject({ controller: "waiting", waitingReason: "captcha", loginRequired: true });
|
||||
expect(snap.snapshot.text).toContain("验证码");
|
||||
const decision = await h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("subject_id", "=", sid)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(decision).toMatchObject({ status: "awaiting_user", kind: "other" });
|
||||
expect(decision.title).toContain("观众");
|
||||
|
||||
// 3. 等待处理期间 Agent 不能输入(不无限重试)
|
||||
await expect(
|
||||
invoke("browser.interact", { sessionId: sid, action: "type", target: { ref: "e3" }, credential: "password" }),
|
||||
).rejects.toMatchObject({ code: "conflict" });
|
||||
|
||||
// 4. 人在原站视图接管:截图 → 坐标点击 → 输入(密码走凭证注入)→ 验证码 → 登录
|
||||
const view = await h.req(`/browser/sessions/${sid}`);
|
||||
expect(view.body.data).toMatchObject({ controller: "waiting", waitingReason: "captcha" });
|
||||
const acq = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: { reason: "验证码" } });
|
||||
expect(acq.body.data.session.controller).toBe("human");
|
||||
const shot = await h.req(`/browser/sessions/${sid}/screenshot`);
|
||||
expect(shot.body.data).toMatchObject({ controller: "human", modelSafe: false });
|
||||
const human = async (path: string, body: unknown) => {
|
||||
const r = await h.req(`/browser/sessions/${sid}/${path}`, { body });
|
||||
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
||||
};
|
||||
await human("click", { target: at(1) });
|
||||
await human("type", { text: "fixture-user" });
|
||||
await human("click", { target: at(2) });
|
||||
await human("type", { credential: "password" });
|
||||
await human("click", { target: at(4) });
|
||||
await human("type", { text: "4321" });
|
||||
await human("click", { target: at(5) });
|
||||
const loggedIn = await h.req(`/browser/sessions/${sid}/snapshot`);
|
||||
expect(loggedIn.body.data.text).toContain("种子列表");
|
||||
expect(JSON.stringify(loggedIn.body)).not.toContain(PASSWORD);
|
||||
expect(JSON.stringify(loggedIn.body)).not.toContain(PASSKEY);
|
||||
|
||||
// 5. 交还:Agent 恢复前必须重新读页面;待处理事项关闭
|
||||
const rel = await h.req(`/browser/sessions/${sid}/control/release`, { body: { reason: "已登录" } });
|
||||
expect(rel.body.data.session).toMatchObject({ controller: "automation", needsSnapshot: true, waitingReason: null });
|
||||
const d2 = await h.db
|
||||
.selectFrom("decision")
|
||||
.select("status")
|
||||
.where("id", "=", decision.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d2.status).toBe("resolved");
|
||||
|
||||
// 6. 旧引用(交接前拿到的 eN)被拒,不重放
|
||||
await expect(
|
||||
invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e6" } }),
|
||||
).rejects.toMatchObject({ code: "conflict", message: expect.stringContaining("重新读取页面") });
|
||||
|
||||
// 7. 重新读取:人工登录后的状态;继续捕获下载 → 暂存核验 → 匹配本地资源(提交仍走 download.plan)
|
||||
const fresh = await invoke("browser.snapshot", { siteId: h.siteId });
|
||||
expect(fresh).toMatchObject({ controller: "automation", loginRequired: false, waitingReason: null });
|
||||
expect(fresh.snapshot.text).toContain("种子列表");
|
||||
expect((await h.req(`/browser/sessions/${sid}`)).body.data.needsSnapshot).toBe(false);
|
||||
const dl = await invoke("browser.download", { sessionId: sid, target: { ref: "e11" }, timeoutMs: 2_000 });
|
||||
expect(dl).toMatchObject({
|
||||
sourceReleaseId: h.releaseId,
|
||||
duplicate: false,
|
||||
verification: { valid: true, infohashV1: h.torrent.infoHash },
|
||||
});
|
||||
expect(dl.note).toContain("download.plan");
|
||||
expect(JSON.stringify(dl)).not.toContain(PASSKEY);
|
||||
|
||||
// 审计:能力调用留痕,输入里没有凭证
|
||||
const audits = await h.db.selectFrom("audit_event").select(["action", "target_id", "payload"]).execute();
|
||||
expect(audits.some((a) => a.target_id === "browser.download" && a.action === "capability.invoke")).toBe(true);
|
||||
expect(JSON.stringify(audits)).not.toContain(PASSWORD);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CapabilityRegistry, InvokeContext } from "@umt/agent";
|
||||
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
|
||||
import { buildFakeTorrent, createMockCamofox, type FakeTorrent, type MockCamofox } from "@umt/testkit";
|
||||
import { Kysely as K, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { withSchemaLock } from "../../../../test/auth-settings/harness.ts";
|
||||
import { DEV_KEY, memoryLogger, memoryStore } from "../../../../test/helpers.ts";
|
||||
import { fakeEngine, fakeSecrets, PG_URL, plainSettings } from "../../../../test/sources-identity/harness.ts";
|
||||
import { createOutboxPort } from "../../../context.ts";
|
||||
import { createApp } from "../../../http/app.ts";
|
||||
import { EventBus } from "../../../http/sse.ts";
|
||||
import { createAcquisitionOrganizeModule } from "../../acquisition-organize/index.ts";
|
||||
import { createCapabilityRegistry } from "../../agent/capability-registry.ts";
|
||||
import { createAgentModule } from "../../agent/index.ts";
|
||||
import { createSourcesIdentityModule } from "../../sources-identity/index.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { registerBrowserCapabilities } from "../capabilities.ts";
|
||||
import { createBrowserModule } from "../index.ts";
|
||||
|
||||
// C1-E 浏览器模块测试装配:独立 PG schema + 真实迁移 + 交互式 mock camofox(观众风格站点)+ 真实能力注册中心。
|
||||
|
||||
export { pgAvailable } from "../../../../test/sources-identity/harness.ts";
|
||||
|
||||
export const ORIGIN = "https://audiences.example";
|
||||
export const PASSWORD = "brw-s3cret-login-pw";
|
||||
export const PASSKEY = "0f0e0d0c0b0a09080706050403020100";
|
||||
export const ACCESS_KEY = "camofox-access-key-c1e";
|
||||
/** 第二位管理员(接管冲突) */
|
||||
export const OTHER_KEY = "brw-other-admin-key";
|
||||
export const READ_KEY = "brw-read-key";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
|
||||
export type Loose = any;
|
||||
|
||||
export interface BrowserHarness {
|
||||
db: Kysely<DB>;
|
||||
ctx: ServerContext;
|
||||
mock: MockCamofox;
|
||||
torrent: FakeTorrent;
|
||||
registry: CapabilityRegistry;
|
||||
siteId: string;
|
||||
accountId: string;
|
||||
providerId: string;
|
||||
releaseId: string;
|
||||
stagingDir: string;
|
||||
secrets: ReturnType<typeof fakeSecrets>;
|
||||
req(path: string, init?: { method?: string; body?: unknown; key?: string }): Promise<{ status: number; body: Loose }>;
|
||||
agentCtx(): InvokeContext;
|
||||
grant(...actions: string[]): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createBrowserHarness(name: string, opts: { captcha?: boolean } = {}): Promise<BrowserHarness> {
|
||||
const torrent = buildFakeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay.x265-GRP" });
|
||||
const mock = await createMockCamofox({
|
||||
accessKey: ACCESS_KEY,
|
||||
site: {
|
||||
origin: ORIGIN,
|
||||
username: "fixture-user",
|
||||
password: PASSWORD,
|
||||
passkey: PASSKEY,
|
||||
...(opts.captcha ? { captcha: true, captchaAnswer: "4321" } : {}),
|
||||
releases: [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }],
|
||||
torrent: () => torrent.bytes,
|
||||
},
|
||||
});
|
||||
const stagingDir = await mkdtemp(join(tmpdir(), "umt-browser-"));
|
||||
const schema = `c1e_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const admin = new K<unknown>({
|
||||
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }),
|
||||
});
|
||||
await sql.raw(`create schema ${schema}`).execute(admin);
|
||||
await import("@umt/db");
|
||||
const db = new K<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
}) as unknown as Kysely<DB>;
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
|
||||
const { logger } = memoryLogger();
|
||||
const secrets = fakeSecrets(() => db);
|
||||
const settings = plainSettings(() => db);
|
||||
const registry = createCapabilityRegistry({ logger, db, settings });
|
||||
const ctx = {
|
||||
role: "all",
|
||||
db,
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never,
|
||||
logger,
|
||||
events: new EventBus(5),
|
||||
engine: fakeEngine(),
|
||||
outbox: createOutboxPort(),
|
||||
secrets,
|
||||
settings,
|
||||
capabilities: registry,
|
||||
} as ServerContext;
|
||||
const sources = createSourcesIdentityModule({ tmdb: () => null });
|
||||
const browser = createBrowserModule({ runtime: (c) => sources.runtime(c), stagingDir, downloadPollMs: 20 });
|
||||
registerBrowserCapabilities(registry, () => browser.service(ctx));
|
||||
const app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger,
|
||||
bus: new EventBus(5),
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
// POST /acquisition-plans 在 agent 模块
|
||||
modules: [sources, createAcquisitionOrganizeModule(), createAgentModule(), browser],
|
||||
ctx,
|
||||
apiKeyStore: memoryStore({
|
||||
[OTHER_KEY]: { id: "other-admin", scopes: ["admin"] },
|
||||
[READ_KEY]: { id: "reader", scopes: ["read"] },
|
||||
}),
|
||||
});
|
||||
|
||||
const siteId = (
|
||||
await db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: "audiences",
|
||||
name: "观众",
|
||||
adapter: "nexusphp",
|
||||
base_url: `${ORIGIN}/`,
|
||||
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const pwRef = await secrets.create({ purpose: "site_password", plaintext: PASSWORD });
|
||||
const accountId = (
|
||||
await db
|
||||
.insertInto("site_account")
|
||||
.values({ site_id: siteId, label: "main", auth_kind: "password", secret_ref: pwRef, login_name: "fixture-user" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const keyRef = await secrets.create({ purpose: "other", plaintext: ACCESS_KEY });
|
||||
const providerId = (
|
||||
await db
|
||||
.insertInto("browser_provider")
|
||||
.values({
|
||||
name: "camofox",
|
||||
kind: "camofox",
|
||||
base_url: mock.url,
|
||||
secret_ref: keyRef,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ verified: { snapshot: true }, navigationTimeoutMs: 2_000, maxRetries: 1 }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const releaseId = (
|
||||
await db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
site_release_id: "42",
|
||||
title: "Oppenheimer 2023 2160p UHD BluRay",
|
||||
infohash_v1: torrent.infoHash,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
|
||||
return {
|
||||
db,
|
||||
ctx,
|
||||
mock,
|
||||
torrent,
|
||||
registry,
|
||||
siteId,
|
||||
accountId,
|
||||
providerId,
|
||||
releaseId,
|
||||
stagingDir,
|
||||
secrets,
|
||||
async req(path, init = {}) {
|
||||
const res = await app.request(`/api/v1${path}`, {
|
||||
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
|
||||
headers: {
|
||||
authorization: `Bearer ${init.key ?? DEV_KEY}`,
|
||||
...(init.body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as Loose };
|
||||
},
|
||||
agentCtx: () => ({
|
||||
actorId: "agent:c1e",
|
||||
taskId: crypto.randomUUID(),
|
||||
scopeIds: [],
|
||||
signal: new AbortController().signal,
|
||||
source: "agent",
|
||||
}),
|
||||
async grant(...actions) {
|
||||
for (const action of actions)
|
||||
await db.insertInto("automation_grant").values({ action, granted_by: "test" }).execute();
|
||||
},
|
||||
async close() {
|
||||
await db.destroy();
|
||||
await withSchemaLock(() => sql.raw(`drop schema if exists ${schema} cascade`).execute(admin));
|
||||
await admin.destroy();
|
||||
await mock.close();
|
||||
await rm(stagingDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { isLoginUrl } from "@umt/adapters";
|
||||
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
|
||||
import { capabilitySpec } from "@umt/contracts";
|
||||
import type { Actor, BrowserService } from "./service.ts";
|
||||
import { BrowserServiceError } from "./service.ts";
|
||||
|
||||
// C1-E Agent 浏览器能力:browser.snapshot(read)/ browser.navigate / browser.interact / browser.download(execute)。
|
||||
// 一律以自动化身份执行:会话被人工接管或在等待处理时拒绝;元素引用只来自最近一次 snapshot;
|
||||
// 凭证用 credential 字段由服务端注入;下载只暂存核验并匹配本地资源,提交仍走 download.plan / download.submit。
|
||||
|
||||
const CODE = {
|
||||
not_found: "not_found",
|
||||
conflict: "conflict",
|
||||
precondition: "precondition",
|
||||
bad_request: "precondition",
|
||||
upstream: "precondition",
|
||||
rate_limited: "budget",
|
||||
} as const;
|
||||
|
||||
async function guard<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserServiceError) throw new CapabilityError(CODE[e.code], e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const agent = (actorId: string): Actor => ({ kind: "automation", holder: actorId });
|
||||
|
||||
export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: () => BrowserService): void {
|
||||
/** sessionId 优先;否则取站点的未关闭会话 */
|
||||
const resolveSession = async (input: { sessionId?: string | undefined; siteId?: string | undefined }) => {
|
||||
if (input.sessionId) return input.sessionId;
|
||||
const row = input.siteId ? await svc().liveSessionFor(input.siteId) : undefined;
|
||||
if (!row) throw new BrowserServiceError("not_found", "该站点没有浏览器会话,先用 browser.navigate 打开页面");
|
||||
return row.id;
|
||||
};
|
||||
|
||||
registry.register(capabilitySpec("browser.snapshot"), (input, ctx) =>
|
||||
guard(async () => {
|
||||
const id = await resolveSession(input);
|
||||
const snap = await svc().run(
|
||||
id,
|
||||
agent(ctx.actorId),
|
||||
(p, ref) => p.snapshot(ref, input.offset !== undefined ? { offset: input.offset } : {}),
|
||||
{ write: false, patch: () => ({ needsSnapshot: false }), ...(input.tabId ? { tabId: input.tabId } : {}) },
|
||||
);
|
||||
const s = svc();
|
||||
const loginPage = isLoginUrl(snap.url || null);
|
||||
await s.afterAutomationSnapshot(id, snap.text, loginPage);
|
||||
const view = await s.view(id);
|
||||
return {
|
||||
snapshot: snap,
|
||||
controller: view.controller,
|
||||
waitingReason: view.waitingReason,
|
||||
loginRequired: loginPage,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
registry.register(capabilitySpec("browser.navigate"), (input, ctx) =>
|
||||
guard(async () => {
|
||||
let id = input.sessionId;
|
||||
if (!id) {
|
||||
const live = await svc().liveSessionFor(input.siteId!, input.accountId);
|
||||
id =
|
||||
live?.id ??
|
||||
(await svc().open({
|
||||
siteId: input.siteId!,
|
||||
...(input.accountId ? { accountId: input.accountId } : {}),
|
||||
url: input.url,
|
||||
}));
|
||||
if (!live) {
|
||||
const v = await svc().view(id);
|
||||
const info = v.currentUrl ?? "";
|
||||
return {
|
||||
sessionId: id,
|
||||
url: info,
|
||||
httpStatus: null,
|
||||
ok: true,
|
||||
attempts: 1,
|
||||
loginRequired: isLoginUrl(info || null),
|
||||
controller: v.controller,
|
||||
};
|
||||
}
|
||||
}
|
||||
const sid = id;
|
||||
const nav = await svc().run(
|
||||
sid,
|
||||
agent(ctx.actorId),
|
||||
async (p, ref, x) => {
|
||||
await svc().limited(x.site);
|
||||
const r = await p.navigate(ref, input.url, x.budget);
|
||||
return { ...r, loginRequired: p.sessionInfo(sid)?.loginPage ?? false };
|
||||
},
|
||||
input.tabId ? { tabId: input.tabId } : {},
|
||||
);
|
||||
const v = await svc().view(sid);
|
||||
return { sessionId: sid, ...nav, controller: v.controller };
|
||||
}),
|
||||
);
|
||||
|
||||
registry.register(capabilitySpec("browser.interact"), (input, ctx) =>
|
||||
guard(async () => {
|
||||
const s = svc();
|
||||
if (input.action === "request_human") {
|
||||
await s.requestHuman(input.sessionId, input.reason ?? "agent_request");
|
||||
const v = await s.view(input.sessionId);
|
||||
return { ok: true as const, controller: v.controller, needsSnapshot: true };
|
||||
}
|
||||
const needTarget = input.action === "click" || input.action === "select";
|
||||
if (needTarget && !input.target) throw new BrowserServiceError("bad_request", `${input.action} 需要 target`);
|
||||
await s.run(
|
||||
input.sessionId,
|
||||
agent(ctx.actorId),
|
||||
async (p, ref, x) => {
|
||||
switch (input.action) {
|
||||
case "click":
|
||||
return p.click(ref, input.target!);
|
||||
case "select":
|
||||
return p.select(ref, input.target!, input.value ?? "");
|
||||
case "scroll":
|
||||
return p.scroll(ref, { direction: input.direction ?? "down", amount: input.amount ?? 600 });
|
||||
case "press":
|
||||
return p.press(ref, input.key ?? "Enter");
|
||||
case "type": {
|
||||
const extra = input.submit !== undefined ? { submit: input.submit } : {};
|
||||
if (input.credential)
|
||||
return p.type(ref, input.target ?? null, {
|
||||
kind: "secret",
|
||||
siteId: x.row.site_id,
|
||||
accountId: x.row.account_id,
|
||||
field: input.credential,
|
||||
...extra,
|
||||
});
|
||||
if (input.text === undefined)
|
||||
throw new BrowserServiceError("bad_request", "type 需要 text 或 credential");
|
||||
return p.type(ref, input.target ?? null, { kind: "text", text: input.text, ...extra });
|
||||
}
|
||||
}
|
||||
},
|
||||
input.tabId ? { tabId: input.tabId } : {},
|
||||
);
|
||||
const info = (await s.view(input.sessionId)).controller;
|
||||
const changes = input.action === "click" || input.action === "press" || !!input.submit;
|
||||
return { ok: true as const, controller: info, needsSnapshot: changes };
|
||||
}),
|
||||
);
|
||||
|
||||
registry.register(capabilitySpec("browser.download"), (input, ctx) =>
|
||||
guard(async () => {
|
||||
const s = svc();
|
||||
const artifact = await s.run(
|
||||
input.sessionId,
|
||||
agent(ctx.actorId),
|
||||
async (p, ref) => {
|
||||
if (input.target) await p.click(ref, input.target);
|
||||
return p.waitForDownload(ref, { timeoutMs: input.timeoutMs, maxBytes: 10 * 1024 * 1024 });
|
||||
},
|
||||
input.tabId ? { tabId: input.tabId } : {},
|
||||
);
|
||||
const v = await s.verifyDownload(input.sessionId, artifact);
|
||||
await s.recordDownload(input.sessionId, {
|
||||
sha256: artifact.sha256,
|
||||
artifactId: artifact.id,
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
planId: v.duplicate?.planId ?? null,
|
||||
});
|
||||
return {
|
||||
artifactId: artifact.id,
|
||||
filename: artifact.filename,
|
||||
sizeBytes: artifact.sizeBytes,
|
||||
sha256: artifact.sha256,
|
||||
verification: v.verification,
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
duplicate: !!v.duplicate,
|
||||
note: v.duplicate
|
||||
? "同一会话已捕获过这个文件;先查已有下载计划,不要重复提交"
|
||||
: !v.verification.valid
|
||||
? "不是有效的种子文件"
|
||||
: v.sourceReleaseId
|
||||
? "已暂存核验;用 download.plan 提交这个 sourceReleaseId"
|
||||
: "资源不在本地索引;先 release.search 该站点后再提交",
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { registerBrowserCapabilities } from "./capabilities.ts";
|
||||
import { registerBrowserRoutes } from "./routes.ts";
|
||||
import { type BrowserModuleOptions, BrowserService } from "./service.ts";
|
||||
|
||||
/**
|
||||
* C1-E 浏览器:BrowserProvider(camofox)之上的站点浏览器会话、原站视图与人工接管、Agent 浏览器能力。
|
||||
* 会话/标签/租约以数据库(0023 四张表)为准;站点限流与 sources-identity 共享;下载产物只进后端暂存核验,
|
||||
* 提交走 POST /acquisition-plans 同一流程。运维与真实实例核验见 docs/runbook/browser.md。
|
||||
*/
|
||||
export function createBrowserModule(opts: BrowserModuleOptions = {}): ServerModule & {
|
||||
service(ctx: ServerContext): BrowserService;
|
||||
} {
|
||||
const services = new WeakMap<ServerContext, BrowserService>();
|
||||
const options: BrowserModuleOptions = {
|
||||
runtime: (ctx) => sourcesIdentityModule.runtime(ctx),
|
||||
...opts,
|
||||
};
|
||||
const service = (ctx: ServerContext) => {
|
||||
let s = services.get(ctx);
|
||||
if (!s) {
|
||||
s = new BrowserService(ctx, options);
|
||||
services.set(ctx, s);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
return {
|
||||
name: "browser",
|
||||
service,
|
||||
registerRoutes(app, ctx) {
|
||||
registerBrowserRoutes(app, () => service(ctx));
|
||||
},
|
||||
registerTasks() {},
|
||||
registerCapabilities(registry, ctx) {
|
||||
registerBrowserCapabilities(registry, () => service(ctx));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const browserModule = createBrowserModule();
|
||||
@@ -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) };
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
import { acquisitionOrganizeModule } from "./acquisition-organize/index.ts";
|
||||
import { agentModule } from "./agent/index.ts";
|
||||
import { authSettingsModule } from "./auth-settings/index.ts";
|
||||
import { browserModule } from "./browser/index.ts";
|
||||
import { fileManagerModule } from "./file-manager/index.ts";
|
||||
import { piModule } from "./pi/index.ts";
|
||||
import { schedulesModule } from "./schedules/index.ts";
|
||||
@@ -24,5 +25,6 @@ export const SERVER_MODULES: readonly ServerModule[] = [
|
||||
piModule,
|
||||
schedulesModule,
|
||||
searchModule,
|
||||
browserModule,
|
||||
systemModule,
|
||||
];
|
||||
@@ -42,8 +42,8 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权
|
||||
return r.body.data.id as string;
|
||||
};
|
||||
|
||||
it("启动断言:21 个能力全部注册,并在能力目录里可见", async () => {
|
||||
expect(e.booted.ctx.capabilities.list()).toHaveLength(21);
|
||||
it("启动断言:25 个能力全部注册,并在能力目录里可见", async () => {
|
||||
expect(e.booted.ctx.capabilities.list()).toHaveLength(25);
|
||||
const r = await e.api("GET", "/capabilities");
|
||||
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
|
||||
e.booted.ctx.capabilities
|
||||
|
||||
@@ -5,6 +5,7 @@ import { makeTorrent } from "@umt/testkit";
|
||||
import { createAcquisitionOrganizeModule } from "../../../src/modules/acquisition-organize/index.ts";
|
||||
import { createAgentModule } from "../../../src/modules/agent/index.ts";
|
||||
import { authSettingsModule } from "../../../src/modules/auth-settings/index.ts";
|
||||
import { createBrowserModule } from "../../../src/modules/browser/index.ts";
|
||||
import { createSchedulesModule } from "../../../src/modules/schedules/index.ts";
|
||||
import { createSourcesIdentityModule } from "../../../src/modules/sources-identity/index.ts";
|
||||
import type { ServerModule } from "../../../src/modules/types.ts";
|
||||
@@ -65,6 +66,8 @@ export function buildModules(o: E2EModuleOptions): ServerModule[] {
|
||||
...(o.fakeScripts ? { fakeScripts: o.fakeScripts } : {}),
|
||||
goalPollSec: 1,
|
||||
}),
|
||||
// 浏览器能力(browser.*)随首批能力一起注册;e2e 不配置浏览器服务
|
||||
createBrowserModule(),
|
||||
// 定时任务调度器(驱动上面模块登记的周期任务;须在末尾)
|
||||
createSchedulesModule({ builtinChecks: false, scheduler: { tickMs: 100, lockRetryMs: 200 } }),
|
||||
];
|
||||
|
||||
@@ -43,7 +43,7 @@ const pingModule: ServerModule = {
|
||||
};
|
||||
|
||||
describe("ServerModule 骨架", () => {
|
||||
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 进程与健康(末尾),名字唯一", () => {
|
||||
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 浏览器 + 进程与健康(末尾),名字唯一", () => {
|
||||
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
|
||||
"auth-settings",
|
||||
"sources-identity",
|
||||
@@ -53,6 +53,7 @@ describe("ServerModule 骨架", () => {
|
||||
"pi",
|
||||
"schedules",
|
||||
"search",
|
||||
"browser",
|
||||
"system",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ const TiB = 1024 ** 4;
|
||||
const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const;
|
||||
|
||||
describe("站点能力规格", () => {
|
||||
it("进入首批能力目录(17 + 4 = 21);类别 / 风险 / 重放按约定", () => {
|
||||
expect(CAPABILITY_IDS).toHaveLength(21);
|
||||
it("进入首批能力目录(17 + 4 站点 + 4 浏览器 = 25);类别 / 风险 / 重放按约定", () => {
|
||||
expect(CAPABILITY_IDS).toHaveLength(25);
|
||||
for (const id of SITE_IDS) expect(CAPABILITY_IDS).toContain(id);
|
||||
expect(capabilitySpec("site.list")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
||||
expect(capabilitySpec("site.user_data")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
||||
|
||||
Reference in new issue
Block a user