/** * camofox-browser 真机核验(REQ §4.7.4,docs/runbook/browser.md §7)。 * 真实 camofox 容器 + compose 网络里的 mock NexusPHP 站 + 真实迁移后的 PG;服务层(BrowserService、HTTP 路由、 * Agent 能力)在本进程内装配,与生产同一套代码。分阶段运行,阶段之间由 run.sh 重启 / 杀掉 camofox 容器: * setup 建 schema、站点、账号;浏览器服务走环境变量登记(camofox-env) * a 公开静态页导航/截图;mock 站:登录保持(人工接管登录)、截图/快照、列表/详情提取、下载捕获、人与 Agent 同标签页 * b camofox 正常重启(SIGTERM)之后:自动重连、旧引用被拒、登录状态是否恢复 * idle 会话空闲回收(SESSION_TIMEOUT_MS)之后:同上 * c 账号 2 由 Agent 用凭证注入登录(不关会话),随后 run.sh 用 SIGKILL 杀容器 * d SIGKILL 重启之后:账号 2 的登录是否还在 * 输出:每项 PASS/FAIL + 实际观测值(JSON 行),截图落到 OUT_DIR。 */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { CapabilityRegistry, InvokeContext } from "@umt/agent"; import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db"; import { Kysely as K, PostgresDialect } from "kysely"; import pg from "pg"; import { createOutboxPort } from "../../apps/server/src/context.ts"; import { createApp } from "../../apps/server/src/http/app.ts"; import { EventBus } from "../../apps/server/src/http/sse.ts"; import { createAcquisitionOrganizeModule } from "../../apps/server/src/modules/acquisition-organize/index.ts"; import { createCapabilityRegistry } from "../../apps/server/src/modules/agent/capability-registry.ts"; import { createAgentModule } from "../../apps/server/src/modules/agent/index.ts"; import { registerBrowserCapabilities } from "../../apps/server/src/modules/browser/capabilities.ts"; import { createBrowserModule } from "../../apps/server/src/modules/browser/index.ts"; import { createSourcesIdentityModule } from "../../apps/server/src/modules/sources-identity/index.ts"; import type { ServerContext } from "../../apps/server/src/modules/types.ts"; import { DEV_KEY, memoryLogger, memoryStore } from "../../apps/server/test/helpers.ts"; import { fakeEngine, fakeSecrets, plainSettings } from "../../apps/server/test/sources-identity/harness.ts"; import { AUDIENCES_FIXTURE_RELEASES } from "../../packages/testkit/src/sites/nexusphp-fixtures.ts"; // biome-ignore lint/suspicious/noExplicitAny: 核验脚本直接读 JSON type Loose = any; const env = (k: string, d?: string) => { const v = Deno.env.get(k) ?? d; if (v === undefined) throw new Error(`缺少环境变量 ${k}`); return v; }; const PG = env("VERIFY_PG"); const CAMOFOX = env("CAMOFOX_URL", "http://127.0.0.1:49377"); const ACCESS_KEY = env("CAMOFOX_ACCESS_KEY"); const ORIGIN = env("MOCK_ORIGIN", "http://mocknexus:8080"); const DIRECT = env("MOCK_DIRECT", "http://127.0.0.1:48080"); const CTRL = env("MOCK_CTRL", "http://127.0.0.1:48081"); const PUBLIC_URL = env("PUBLIC_URL", "https://example.com/"); const OUT = env("OUT_DIR", "./browser-verify-out"); const EXPECTED_VERSION = env("VERIFY_CAMOFOX_VERSION", "1.18.0"); // SIGKILL 不执行 profile 落盘;新登录应明确识别为需登录,不能把它当作已恢复。 const KILL_LOGIN_REQUIRED = env("VERIFY_KILL_LOGIN_REQUIRED", "true") === "true"; const SCHEMA = "brverify"; const phase = Deno.args[0] ?? "a"; await mkdir(OUT, { recursive: true }); const results: Loose[] = []; const record = (item: string, pass: boolean | null, observed: Loose) => { results.push({ phase, item, pass, observed }); console.log(JSON.stringify({ phase, item, pass, observed })); }; // ---------- 装配(同 modules/browser/__tests__/harness.ts,只是连真实 camofox 与固定 schema) ---------- const admin = new K({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG, max: 1 }) }) }); if (phase === "setup") { await sql.raw(`drop schema if exists ${SCHEMA} cascade`).execute(admin); await sql.raw(`create schema ${SCHEMA}`).execute(admin); } const db = new K({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG, max: 8, options: `-c search_path=${SCHEMA},public` }), }), }) as unknown as Kysely; if (phase === "setup") await migrateToLatest(db, { migrationTableSchema: SCHEMA }); const config = { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY, BROWSER_PROVIDER_URL: CAMOFOX, BROWSER_ACCESS_KEY: ACCESS_KEY, BROWSER_ENABLED: true, } as never; const { logger } = memoryLogger(); const secrets = fakeSecrets(() => db); const settings = plainSettings(() => db); const registry: CapabilityRegistry = createCapabilityRegistry({ logger, db, settings }); const ctx = { role: "all", db, config, 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: join(OUT, "staging") }); registerBrowserCapabilities(registry, () => browser.service(ctx)); const app = createApp({ config, logger, bus: new EventBus(5), role: "all", version: "0.0.0-verify", modules: [sources, createAcquisitionOrganizeModule(), createAgentModule(), browser], ctx, apiKeyStore: memoryStore({}), }); async function req(path: string, body?: unknown, method?: string): Promise<{ status: number; body: Loose }> { const res = await app.request(`/api/v1${path}`, { method: method ?? (body !== undefined ? "POST" : "GET"), headers: { authorization: `Bearer ${DEV_KEY}`, ...(body !== undefined ? { "content-type": "application/json" } : {}), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); return { status: res.status, body: await res.json() }; } const agent: InvokeContext = { actorId: "agent:br1-verify", taskId: crypto.randomUUID(), scopeIds: [], signal: new AbortController().signal, source: "agent", } as InvokeContext; const invoke = (id: string, input: unknown) => registry.invoke(id, input, agent) as Promise; const tryInvoke = async (id: string, input: unknown) => { try { return { ok: true, value: await invoke(id, input) }; } catch (e) { return { ok: false, error: { code: (e as Loose).code, message: (e as Error).message } }; } }; /** 上游直连(只给核验脚本当"人眼":算元素在截图里的坐标;业务代码不这样用) */ async function camofox(method: string, path: string, body?: unknown): Promise { const r = await fetch(`${CAMOFOX}${path}`, { method, headers: { authorization: `Bearer ${ACCESS_KEY}`, ...(body ? { "content-type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }); return { status: r.status, body: await r.json().catch(() => null) }; } async function centerOf(profileKey: string, tabId: string, selector: string): Promise<{ x: number; y: number }> { const r = await camofox("POST", `/tabs/${tabId}/evaluate`, { userId: profileKey, expression: `(() => { const r = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`, }); return r.body.result; } async function sha256(bytes: Uint8Array): Promise { const d = await crypto.subtle.digest("SHA-256", bytes as Uint8Array); return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join(""); } const statePath = join(OUT, "state.json"); const loadState = async (): Promise => JSON.parse(await readFile(statePath, "utf8")); const saveState = (s: Loose) => writeFile(statePath, JSON.stringify(s, null, 2)); async function saveShot(sid: string, name: string): Promise { const r = await req(`/browser/sessions/${sid}/screenshot`); if (r.status !== 200) return { status: r.status, error: r.body.error }; const d = r.body.data; const bytes = Uint8Array.from(atob(d.dataBase64), (c) => c.charCodeAt(0)); await writeFile(join(OUT, `${name}.png`), bytes); return { mimeType: d.mimeType, width: d.width, height: d.height, bytes: bytes.byteLength, modelSafe: d.modelSafe }; } // ---------- 阶段 ---------- async function setup() { const site = await db .insertInto("site") .values({ key: "audiences", name: "观众(mock)", adapter: "nexusphp", base_url: `${ORIGIN}/`, rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }), }) .returning("id") .executeTakeFirstOrThrow(); const pub = await db .insertInto("site") .values({ key: "public", name: "公开静态页", adapter: "nexusphp", base_url: PUBLIC_URL, rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }), }) .returning("id") .executeTakeFirstOrThrow(); const acct = await db .insertInto("site_account") .values({ site_id: site.id, label: "main", auth_kind: "password", login_name: "mockuser" }) .returning("id") .executeTakeFirstOrThrow(); const acct2 = await db .insertInto("site_account") .values({ site_id: site.id, label: "second", auth_kind: "password", login_name: "mockuser" }) .returning("id") .executeTakeFirstOrThrow(); // 本地资源索引:下载捕获后按 site_release_id 匹配 const releaseIds: Record = {}; for (const r of AUDIENCES_FIXTURE_RELEASES.slice(0, 3)) { const release = await db .insertInto("source_release") .values({ site_id: site.id, site_release_id: r.id, title: r.title }) .returning("id") .executeTakeFirstOrThrow(); releaseIds[r.id] = release.id; } for (const a of ["browser.navigate", "browser.interact", "browser.download"]) await db.insertInto("automation_grant").values({ action: a, granted_by: "br1-verify" }).execute(); const probe = await req("/browser/providers?probe=true"); const p = probe.body.data.items[0]; const supported = Object.fromEntries( Object.entries(p?.capabilities ?? {}) .filter(([, v]) => v && typeof v === "object") .map(([k, v]) => [k, (v as Loose).supported]), ); record( "provider:env 登记 + 探测", probe.status === 200 && p?.name === "camofox-env" && p?.health?.status === "ok" && p?.capabilities?.upstreamVersion === EXPECTED_VERSION && Object.keys(supported).length === 10 && Object.values(supported).every((v) => v === true), { name: p?.name, managedBy: p?.managedBy, hasAccessKey: p?.hasAccessKey, health: p?.health, upstreamVersion: p?.capabilities?.upstreamVersion, expectedVersion: EXPECTED_VERSION, supported, }, ); // 没带凭据时应被拒(确认认证头生效) const noAuth = await fetch(`${CAMOFOX}/tabs?userId=x`); record("auth:无 Bearer 被拒", noAuth.status === 401, { status: noAuth.status }); await noAuth.body?.cancel(); await saveState({ siteId: site.id, publicSiteId: pub.id, accountId: acct.id, account2Id: acct2.id, releaseIds }); } async function phaseA() { const st = await loadState(); // 0. 公开静态页:导航 + 快照 + 截图 const t0 = Date.now(); const pubNav = await tryInvoke("browser.navigate", { siteId: st.publicSiteId, url: PUBLIC_URL }); const pubMs = Date.now() - t0; if (pubNav.ok) { const s = await invoke("browser.snapshot", { sessionId: pubNav.value.sessionId }); const shot = await saveShot(pubNav.value.sessionId, "public"); const expectedLoginRequired = /\/login\.php$/.test(new URL(PUBLIC_URL).pathname); record( "public:导航/快照/截图", s.snapshot.text.length > 0 && shot.width > 0 && new URL(s.snapshot.url).origin === new URL(PUBLIC_URL).origin && new URL(s.snapshot.url).pathname === new URL(PUBLIC_URL).pathname && s.loginRequired === expectedLoginRequired, { url: pubNav.value.url, httpStatus: pubNav.value.httpStatus, loginRequired: s.loginRequired, expectedLoginRequired, firstOpenMs: pubMs, snapshotHead: s.snapshot.text.slice(0, 300), refsCount: s.snapshot.refsCount, shot, }, ); const closed = await req(`/browser/sessions/${pubNav.value.sessionId}`, undefined, "DELETE"); const closedView = await req(`/browser/sessions/${pubNav.value.sessionId}`); record("sessions:关闭公开页会话", closed.status === 200 && closedView.body.data?.controller === "closed", { status: closed.status, controller: closedView.body.data?.controller, }); } else record("public:导航/快照/截图", false, pubNav.error); // 1. Agent 打开 mock 站列表:新 profile 未登录 → 落登录页 const nav = await invoke("browser.navigate", { siteId: st.siteId, accountId: st.accountId, url: `${ORIGIN}/torrents.php`, }); const sid = nav.sessionId as string; const loginSnap = await invoke("browser.snapshot", { sessionId: sid }); record("login:未登录识别为登录页", nav.loginRequired === true && loginSnap.loginRequired === true, { url: nav.url, loginRequired: loginSnap.loginRequired, controller: loginSnap.controller, snapshot: loginSnap.snapshot.text.slice(0, 400), }); const view0 = (await req(`/browser/sessions/${sid}`)).body.data; const tabId = view0.currentTabId as string; const profileKey = view0.profileKey as string; const loginShot = await saveShot(sid, "login"); record("screenshot:登录页", loginShot.width > 0 && loginShot.modelSafe === false, loginShot); // 2. 人工接管:原站视图坐标点击 + 焦点输入 + 回车(同一标签页) const acq = await req(`/browser/sessions/${sid}/control/acquire`, { reason: "登录" }); const agentWhileHuman = await tryInvoke("browser.snapshot", { sessionId: sid }); const agentClickWhileHuman = await tryInvoke("browser.interact", { sessionId: sid, action: "press", key: "Enter", }); const u = await centerOf(profileKey, tabId, "input[name=username]"); const pw = await centerOf(profileKey, tabId, "input[name=password]"); const steps: Loose[] = []; const human = async (path: string, body: unknown) => { const r = await req(`/browser/sessions/${sid}/${path}`, body); steps.push({ path, body, status: r.status, error: r.body.error?.message }); return r; }; await human("click", { target: u }); await human("type", { text: "mockuser" }); await human("click", { target: pw }); await human("type", { text: "mock-password" }); const typedSnap = await req(`/browser/sessions/${sid}/snapshot`); const typedShot = await saveShot(sid, "login-typed"); await human("type", { key: "Enter" }); const afterShot = await saveShot(sid, "after-login"); const rel = await req(`/browser/sessions/${sid}/control/release`, { reason: "已登录" }); record("takeover:接管期间自动化被拒", acq.status === 200 && !agentClickWhileHuman.ok, { acquire: acq.status, controller: acq.body.data?.session?.controller, agentSnapshotWhileHuman: agentWhileHuman.ok ? "允许(只读)" : agentWhileHuman.error, agentInputWhileHuman: agentClickWhileHuman.ok ? "允许" : agentClickWhileHuman.error, }); record( "takeover:坐标点击/焦点输入/回车", steps.every((s) => s.status === 200), { coords: { username: u, password: pw }, steps, typedShot, afterShot }, ); record( "redact:密码框值不出现在快照", typedSnap.status === 200 && !JSON.stringify(typedSnap.body).includes("mock-password"), { status: typedSnap.status, snapshot: typedSnap.body.data?.text?.slice(0, 500), redactions: typedSnap.body.data?.redactions, }, ); // 3. 交还后:旧引用被拒;Agent 重新读取同一标签页,看到人工操作后的状态(已登录列表) const oldRef = /\[(?:button|textbox)[^\]]*?(e\d+)/.exec(loginSnap.snapshot.text)?.[1] ?? "e1"; const stale = await tryInvoke("browser.interact", { sessionId: sid, action: "click", target: { ref: oldRef } }); const back = await invoke("browser.snapshot", { sessionId: sid }); const view1 = (await req(`/browser/sessions/${sid}`)).body.data; record( "same-tab:交还后 Agent 读到人工操作结果(同一 tabId)", rel.status === 200 && !stale.ok && back.loginRequired === false && view1.currentTabId === tabId && back.snapshot.text.includes("退出"), { release: rel.body.data?.session?.controller, staleRef: stale.ok ? "未被拒" : stale.error, tabBefore: tabId, tabAfter: view1.currentTabId, url: back.snapshot.url, loginRequired: back.loginRequired, mockLoginAttempts: (await (await fetch(`${CTRL}/__mock/stats`)).json()).loginAttempts, }, ); await invoke("browser.navigate", { sessionId: sid, url: `${ORIGIN}/torrents.php` }); const list = await invoke("browser.snapshot", { sessionId: sid }); const titlesSeen = AUDIENCES_FIXTURE_RELEASES.filter((r) => list.snapshot.text.includes(r.title)).length; record("login:登录后 Cookie 生效(Agent 直接打开受保护列表页)", list.loginRequired === false && titlesSeen > 0, { url: list.snapshot.url, titlesSeen, fixtures: AUDIENCES_FIXTURE_RELEASES.length, }); // 4. 列表快照 + 截图 + 分页 + 提取 const listShot = await saveShot(sid, "list"); record("screenshot:列表页", listShot.width > 0 && listShot.modelSafe === true, listShot); await writeFile(join(OUT, "list-snapshot.txt"), list.snapshot.text); record("snapshot:列表页", list.snapshot.refsCount > 0, { refsCount: list.snapshot.refsCount, bytes: new TextEncoder().encode(list.snapshot.text).byteLength, truncated: list.snapshot.truncated, nextOffset: list.snapshot.nextOffset, head: list.snapshot.text.slice(0, 600), }); const refOf = (text: string, needle: string) => text .split("\n") .find((l) => l.includes(needle) && /\[e\d+\]/.test(l)) ?.match(/\[(e\d+)\]/)?.[1]; const fx = AUDIENCES_FIXTURE_RELEASES[0]!; const titleRef = refOf(list.snapshot.text, fx.title); const schema = { type: "object", properties: { title: { type: "string", "x-ref": titleRef }, heading: { type: "string", "x-ref": "e1" } }, }; const ex = await req(`/browser/sessions/${sid}/extract`, { schema }); const fields = { title: list.snapshot.text.includes(fx.title), subtitle: fx.subtitle ? list.snapshot.text.includes(fx.subtitle.split(" | ")[0]!) : null, seeders: list.snapshot.text.includes(fx.seeders), size: list.snapshot.text.includes(fx.sizeText), }; record("extract:列表", ex.status === 200 && ex.body.data?.data?.title?.includes?.(fx.title.slice(0, 20)), { titleRef, extract: ex.status === 200 ? ex.body.data : ex.body.error, snapshotFields: fields, }); // 5. 详情页(Agent 导航)+ 提取 const det = await invoke("browser.navigate", { sessionId: sid, url: `${ORIGIN}/details.php?id=${fx.id}` }); const detSnap = await invoke("browser.snapshot", { sessionId: sid }); await writeFile(join(OUT, "detail-snapshot.txt"), detSnap.snapshot.text); const detShot = await saveShot(sid, "detail"); const dlRef = refOf(detSnap.snapshot.text, ".torrent"); const detEx = await req(`/browser/sessions/${sid}/extract`, { schema: { type: "object", properties: { download: { type: "string", "x-ref": dlRef } } }, }); record( "extract:详情", detSnap.snapshot.text.includes(fx.title) && detEx.status === 200 && detEx.body.data?.data?.download?.includes?.(`${fx.title}.torrent`), { url: det.url, detShot, hasSubtitle: fx.subtitle ? detSnap.snapshot.text.includes(fx.subtitle.split(" | ")[0]!) : null, downloadRef: dlRef, extract: detEx.status === 200 ? detEx.body.data : detEx.body.error, }, ); // 6. 下载捕获:Agent 点下载链接 → 暂存核验;sha256 与直接 HTTP 下载一致 const t1 = Date.now(); const dl = await tryInvoke("browser.download", { sessionId: sid, target: { ref: dlRef }, timeoutMs: 20_000 }); const dlMs = Date.now() - t1; const direct = new Uint8Array( await ( await fetch(`${DIRECT}/download.php?id=${fx.id}`, { headers: { cookie: "c_secure_uid=10001; c_secure_pass=mock-session-secret" }, }) ).arrayBuffer(), ); const directSha = await sha256(direct); const infohash = (await (await fetch(`${CTRL}/__mock/infohash?id=${fx.id}`)).json()).infohash; record( "download:捕获 + sha256 一致 + 匹配本地资源", dl.ok && dl.value.verification?.valid === true && dl.value.sha256 === directSha && dl.value.verification?.infohashV1 === infohash && dl.value.sourceReleaseId === st.releaseIds[fx.id], dl.ok ? { filename: dl.value.filename, sizeBytes: dl.value.sizeBytes, sha256: dl.value.sha256, directSha, infohashV1: dl.value.verification?.infohashV1, expectInfohash: infohash, sourceReleaseId: dl.value.sourceReleaseId, expectedSourceReleaseId: st.releaseIds[fx.id], duplicate: dl.value.duplicate, ms: dlMs, } : dl.error, ); const dl2 = await tryInvoke("browser.download", { sessionId: sid, timeoutMs: 1_500 }); record("download:已消费的下载不重复返回", !dl2.ok, dl2.ok ? dl2.value : dl2.error); // 7. 原站视图导航按钮与滚动(人工):后退 / 前进 / 刷新 / 滚动 await req(`/browser/sessions/${sid}/control/acquire`, { reason: "导航按钮" }); const hist: Loose = {}; for (const action of ["back", "forward", "reload"] as const) { const r = await req(`/browser/sessions/${sid}/navigate`, { action }); hist[action] = r.status === 200 ? r.body.data.url : r.body.error; } const sc = await req(`/browser/sessions/${sid}/scroll`, { direction: "down", amount: 400 }); hist.scroll = sc.status === 200 ? "ok" : sc.body.error; await req(`/browser/sessions/${sid}/control/release`, { reason: "完成" }); record( "interact:后退/前进/刷新/滚动", String(hist.back).includes("torrents.php") && String(hist.forward).includes("details.php") && String(hist.reload).includes("details.php") && hist.scroll === "ok", hist, ); // 8. 回到列表,留给重启阶段 await invoke("browser.navigate", { sessionId: sid, url: `${ORIGIN}/torrents.php` }); const finalSnap = await invoke("browser.snapshot", { sessionId: sid }); await saveState({ ...st, sid, tabId, profileKey, oldRef: refOf(finalSnap.snapshot.text, fx.title) ?? "e1" }); } /** BR-3:重启 / 回收后自动重连,旧 tab 标 lost,旧引用不能在新页面重放。 */ async function afterRestart(label: string, sidKey = "sid", expectedLoginRequired = false) { const st = await loadState(); const sid = st[sidKey]; const oldTabId = st[sidKey === "sid" ? "tabId" : "tab2Id"]; const oldRef = st[sidKey === "sid" ? "oldRef" : "oldRef2"]; const t0 = Date.now(); const stale = await tryInvoke("browser.interact", { sessionId: sid, action: "click", target: { ref: oldRef } }); const snap = await invoke("browser.snapshot", { sessionId: sid }); const view = (await req(`/browser/sessions/${sid}`)).body.data; record( `${label}:自动重连 + 旧标签页 lost + 旧引用不重放`, !stale.ok && /重新读取|快照|snapshot/i.test(stale.error?.message ?? "") && view?.currentTabId !== oldTabId && view?.tabs?.some((t: Loose) => t.upstreamTabId === oldTabId && t.status === "lost") && view?.reconnect?.status === "ok", { staleRef: stale.ok ? "未被拒" : stale.error, oldTabId, currentTabId: view?.currentTabId, reconnect: view?.reconnect, tabs: view?.tabs?.map((t: Loose) => ({ upstreamTabId: t.upstreamTabId, status: t.status })), needsSnapshot: view?.needsSnapshot, }, ); const reopenMs = Date.now() - t0; record( `${label}:${expectedLoginRequired ? "未落盘登录丢失被明确识别" : "登录状态保持"}`, snap.loginRequired === expectedLoginRequired, { reopenMs, loginRequired: snap.loginRequired, expectedLoginRequired, url: snap.snapshot.url, mockLoginAttempts: (await (await fetch(`${CTRL}/__mock/stats`)).json()).loginAttempts, }, ); st[sidKey === "sid" ? "tabId" : "tab2Id"] = view.currentTabId; st[sidKey === "sid" ? "oldRef" : "oldRef2"] = snap.snapshot.text.match(/\[(e\d+)\]/)?.[1] ?? "e1"; await saveState(st); } /** 账号 2:Agent 用服务端凭证注入登录,登录后不关会话(持久化插件只在会话关闭/服务停止时落盘) */ async function phaseC() { const st = await loadState(); const ref = await secrets.create({ purpose: "site_password", plaintext: "mock-password" }); await db.updateTable("site_account").set({ secret_ref: ref }).where("id", "=", st.account2Id).execute(); const nav = await invoke("browser.navigate", { siteId: st.siteId, accountId: st.account2Id, url: `${ORIGIN}/torrents.php`, }); const sid2 = nav.sessionId as string; const s = await invoke("browser.snapshot", { sessionId: sid2 }); const refOf = (needle: RegExp) => s.snapshot.text .split("\n") .find((l: string) => needle.test(l)) ?.match(/\be\d+\b/)?.[0]; const userRef = refOf(/textbox/); const pwRef = s.snapshot.text .split("\n") .filter((l: string) => /textbox/.test(l))[1] ?.match(/\be\d+\b/)?.[0]; const r1 = await tryInvoke("browser.interact", { sessionId: sid2, action: "type", target: { ref: userRef }, credential: "username", }); const r2 = await tryInvoke("browser.interact", { sessionId: sid2, action: "type", target: { ref: pwRef }, credential: "password", submit: true, }); const after = await invoke("browser.snapshot", { sessionId: sid2 }); record( "agent-login:凭证注入登录(账号 2)", r1.ok && r2.ok && after.loginRequired === false && !JSON.stringify(after).includes("mock-password"), { refs: { userRef, pwRef }, r1: r1.ok || r1.error, r2: r2.ok || r2.error, loginRequired: after.loginRequired, passwordLeak: JSON.stringify(after).includes("mock-password"), }, ); const view = (await req(`/browser/sessions/${sid2}`)).body.data; await saveState({ ...st, sid2, tab2Id: view.currentTabId, oldRef2: after.snapshot.text.match(/\[(e\d+)\]/)?.[1] ?? "e1", }); } try { if (phase === "setup") await setup(); else if (phase === "a") await phaseA(); else if (phase === "b") await afterRestart("restart(SIGTERM)"); else if (phase === "idle") await afterRestart("idle(SESSION_TIMEOUT_MS)"); else if (phase === "c") await phaseC(); else if (phase === "d") await afterRestart("restart(SIGKILL) 账号 2", "sid2", KILL_LOGIN_REQUIRED); else throw new Error(`未知阶段 ${phase}`); } catch (e) { record("error", false, { message: (e as Error).message, stack: (e as Error).stack?.split("\n").slice(0, 6) }); } finally { await writeFile(join(OUT, `results-${phase}.json`), JSON.stringify(results, null, 2)); await db.destroy(); await admin.destroy(); } Deno.exit(results.length > 0 && results.every((r) => r.pass === true) ? 0 : 1);