feat(browser): ship camofox v1.17.0 in compose, verified on a real instance
- compose: camofox service on by default (internal only, volume umt-camofox, healthcheck, CAMOFOX_REPLICAS=0 to skip); app depends on it with service_started/required=false and auto-registers camofox-env from BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED - docker/camofox/Dockerfile: builds upstream tag v1.17.0 (389c996); fixes the upstream Dockerfile (missing postinstall.js before npm ci, sh -c not forwarding SIGTERM so logins were never persisted on restart) - adapter fixes from the real container: coordinate click via evaluate + selector, focus typing with mode=keyboard, password values redacted from ARIA snapshots/extract, downloads field names, 410 after browser restart treated as lost tab; navigate reopens a tab in the same session - verified-capability table keyed by upstream version (1.17.0 all ten); other versions stay unverified; probe reports auth failures - settings: browser providers block (create/edit/delete/probe, capability table); API POST/PATCH/DELETE /browser/providers, POST .../probe - scripts/browser-verify: umtbr stack + mock NexusPHP run of REQ 4.7.4 items - docs: runbook browser §1/§7 (real-instance record), deploy §2.1 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b4c9b1ead2
commit
40dd0e69e4
29 files changed
+1661
-73
No files matched your search
@@ -0,0 +1,20 @@
|
||||
# 真机核验用的 compose 覆盖(只起 postgres + camofox + mocknexus,不起 app;项目名 umtbr,与正式 umt 栈隔离)。
|
||||
# 用法见 run.sh。
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "127.0.0.1:55447:5432"
|
||||
camofox:
|
||||
# 核验脚本在宿主上跑,需要直连;正式部署不映射端口
|
||||
ports:
|
||||
- "127.0.0.1:49377:9377"
|
||||
mocknexus:
|
||||
image: denoland/deno:2.9.7
|
||||
working_dir: /repo
|
||||
command: ["run", "--no-config", "--no-lock", "-A", "scripts/browser-verify/mock-site.ts"]
|
||||
volumes:
|
||||
# 相对路径按项目目录(第一个 -f 文件所在的仓库根)解析
|
||||
- ./:/repo:ro
|
||||
ports:
|
||||
- "127.0.0.1:48080:8080"
|
||||
- "127.0.0.1:48081:8081"
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 真机核验用的 mock NexusPHP 站(testkit mock-nexusphp,观众风格),在 compose 网络里以 mocknexus:8080 提供给 camofox。
|
||||
* testkit 的 mock 只监听 127.0.0.1 随机端口,这里在 0.0.0.0:8080 做一层 TCP 转发。
|
||||
* 控制端点(只供核验脚本):POST /__mock/login?v=0|1 切换登录态;GET /__mock/infohash?id= 取种子 infohash;
|
||||
* GET /__mock/stats 取 takelogin 次数。
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { connect, createServer as createTcp } from "node:net";
|
||||
import { createMockNexusPhpSite } from "../../packages/testkit/src/sites/mock-nexusphp.ts";
|
||||
|
||||
const site = await createMockNexusPhpSite({ siteName: "观众(mock)", loginMode: "redirect" });
|
||||
const upstream = new URL(site.baseUrl);
|
||||
|
||||
const control = createServer((req, res) => {
|
||||
const u = new URL(req.url ?? "/", "http://x");
|
||||
res.setHeader("content-type", "application/json");
|
||||
if (u.pathname === "/__mock/login") {
|
||||
site.setLoggedIn(u.searchParams.get("v") !== "0");
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
if (u.pathname === "/__mock/infohash")
|
||||
return res.end(JSON.stringify({ infohash: site.infoHashOf(u.searchParams.get("id") ?? "") }));
|
||||
if (u.pathname === "/__mock/stats")
|
||||
return res.end(JSON.stringify({ loginAttempts: site.loginAttempts, releases: site.releases.length }));
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
});
|
||||
control.listen(8081, "0.0.0.0");
|
||||
|
||||
createTcp((client) => {
|
||||
const up = connect(Number(upstream.port), upstream.hostname);
|
||||
client.pipe(up).pipe(client);
|
||||
client.on("error", () => up.destroy());
|
||||
up.on("error", () => client.destroy());
|
||||
}).listen(8080, "0.0.0.0", () => console.log(`mock nexus ${site.baseUrl} → 0.0.0.0:8080(控制 8081)`));
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# camofox 真机核验:起临时栈 umtbr → 分阶段跑 verify.ts(阶段间重启 / 杀 camofox)→ 结果写 $OUT_DIR。
|
||||
# 结束后 `docker compose -p umtbr down -v`(镜像保留)。需要本机已有 camofox-browser:1.17.0 镜像(见 docs/runbook/browser.md §1)。
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
export POSTGRES_PASSWORD=umtbr-verify SECRET_MASTER_KEY=umtbr-verify-master-key-0123456789
|
||||
export CAMOFOX_ACCESS_KEY=${CAMOFOX_ACCESS_KEY:-umtbr-camofox-access-key}
|
||||
# 空闲回收阶段用短超时
|
||||
export CAMOFOX_SESSION_TIMEOUT_MS=${CAMOFOX_SESSION_TIMEOUT_MS:-60000}
|
||||
export CAMOFOX_BROWSER_IDLE_TIMEOUT_MS=${CAMOFOX_BROWSER_IDLE_TIMEOUT_MS:-30000}
|
||||
export VERIFY_PG=postgres://umt:[email protected]:55447/umt
|
||||
export OUT_DIR=${OUT_DIR:-$(pwd)/.browser-verify-out}
|
||||
C="docker compose -p umtbr -f docker-compose.yml -f scripts/browser-verify/compose.yml"
|
||||
V="deno run -A scripts/browser-verify/verify.ts"
|
||||
wait_healthy() {
|
||||
for _ in $(seq 1 60); do
|
||||
[ "$(docker inspect -f '{{.State.Health.Status}}' umtbr-camofox-1 2>/dev/null)" = healthy ] && return 0
|
||||
sleep 2
|
||||
done
|
||||
echo "camofox 未就绪" >&2; return 1
|
||||
}
|
||||
$C up -d --no-build postgres camofox mocknexus
|
||||
wait_healthy
|
||||
until docker exec umtbr-postgres-1 pg_isready -U umt -d umt >/dev/null 2>&1; do sleep 1; done
|
||||
$V setup
|
||||
$V a
|
||||
$C restart camofox && wait_healthy
|
||||
$V b
|
||||
# 上游每 60 秒扫一次过期会话:超时 + 一个扫描周期 + 余量
|
||||
echo "等待会话空闲回收(${CAMOFOX_SESSION_TIMEOUT_MS}ms + 60s 扫描周期)…"
|
||||
sleep $(( CAMOFOX_SESSION_TIMEOUT_MS / 1000 + 75 ))
|
||||
$V idle
|
||||
$V c
|
||||
docker kill -s KILL umtbr-camofox-1 && $C up -d --no-build camofox && wait_healthy
|
||||
$V d
|
||||
echo "结果:$OUT_DIR/results-*.json"
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* 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)之后:旧标签页识别为 lost、登录状态是否恢复
|
||||
* 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 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<unknown>({ 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<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG, max: 8, options: `-c search_path=${SCHEMA},public` }),
|
||||
}),
|
||||
}) as unknown as Kysely<DB>;
|
||||
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<Loose>;
|
||||
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<Loose> {
|
||||
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<string> {
|
||||
const d = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
|
||||
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const statePath = join(OUT, "state.json");
|
||||
const loadState = async (): Promise<Loose> => 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<Loose> {
|
||||
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 匹配
|
||||
for (const r of AUDIENCES_FIXTURE_RELEASES.slice(0, 3))
|
||||
await db.insertInto("source_release").values({ site_id: site.id, site_release_id: r.id, title: r.title }).execute();
|
||||
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];
|
||||
record("provider:env 登记 + 探测", p?.name === "camofox-env" && p?.health?.status === "ok", {
|
||||
name: p?.name,
|
||||
managedBy: p?.managedBy,
|
||||
hasAccessKey: p?.hasAccessKey,
|
||||
health: p?.health,
|
||||
upstreamVersion: p?.capabilities?.upstreamVersion,
|
||||
supported: Object.fromEntries(
|
||||
Object.entries(p?.capabilities ?? {})
|
||||
.filter(([, v]) => v && typeof v === "object")
|
||||
.map(([k, v]) => [k, (v as Loose).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 });
|
||||
}
|
||||
|
||||
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");
|
||||
record("public:导航/快照/截图", s.snapshot.text.length > 0 && shot.width > 0, {
|
||||
url: pubNav.value.url,
|
||||
httpStatus: pubNav.value.httpStatus,
|
||||
firstOpenMs: pubMs,
|
||||
snapshotHead: s.snapshot.text.slice(0, 300),
|
||||
refsCount: s.snapshot.refsCount,
|
||||
shot,
|
||||
});
|
||||
await req(`/browser/sessions/${pubNav.value.sessionId}`, undefined, "DELETE");
|
||||
} 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:接管期间自动化被拒", !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:密码框值不出现在快照", !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, {
|
||||
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.sha256 === directSha && dl.value.verification?.infohashV1 === infohash,
|
||||
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,
|
||||
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` });
|
||||
await saveState({ ...st, sid, tabId, profileKey });
|
||||
}
|
||||
|
||||
/** 重启 / 空闲回收之后:旧标签页 → lost(409,不重放);重新打开后登录状态 */
|
||||
async function afterRestart(label: string, sidKey = "sid") {
|
||||
const st = await loadState();
|
||||
const sid = st[sidKey];
|
||||
const old = await tryInvoke("browser.snapshot", { sessionId: sid });
|
||||
const view = (await req(`/browser/sessions/${sid}`)).body.data;
|
||||
record(`${label}:旧标签页识别为 lost`, !old.ok && view?.tabs?.some((t: Loose) => t.status === "lost"), {
|
||||
snapshot: old.ok ? "仍可读" : old.error,
|
||||
tabs: view?.tabs?.map((t: Loose) => ({ upstreamTabId: t.upstreamTabId, status: t.status })),
|
||||
needsSnapshot: view?.needsSnapshot,
|
||||
});
|
||||
// 会话本身仍在(数据库为准):在同一会话里开新标签页读列表
|
||||
const t0 = Date.now();
|
||||
const nav = await tryInvoke("browser.navigate", { sessionId: sid, url: `${ORIGIN}/torrents.php` });
|
||||
const reopenMs = Date.now() - t0;
|
||||
let snap: Loose = null;
|
||||
if (nav.ok) snap = await invoke("browser.snapshot", { sessionId: sid });
|
||||
else {
|
||||
// 会话无可用标签页:关闭后按站点/账号重开(同一 profileKey = 同一 camofox userId)
|
||||
await req(`/browser/sessions/${sid}`, undefined, "DELETE");
|
||||
const re = await invoke("browser.navigate", {
|
||||
siteId: st.siteId,
|
||||
accountId: sidKey === "sid" ? st.accountId : st.account2Id,
|
||||
url: `${ORIGIN}/torrents.php`,
|
||||
});
|
||||
st[sidKey] = re.sessionId;
|
||||
await saveState(st);
|
||||
snap = await invoke("browser.snapshot", { sessionId: re.sessionId });
|
||||
}
|
||||
record(`${label}:登录状态保持`, snap.loginRequired === false, {
|
||||
navigate: nav.ok ? { url: nav.value.url, loginRequired: nav.value.loginRequired } : nav.error,
|
||||
reopenMs,
|
||||
loginRequired: snap.loginRequired,
|
||||
url: snap.snapshot.url,
|
||||
mockLoginAttempts: (await (await fetch(`${CTRL}/__mock/stats`)).json()).loginAttempts,
|
||||
});
|
||||
}
|
||||
|
||||
/** 账号 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, {
|
||||
refs: { userRef, pwRef },
|
||||
r1: r1.ok || r1.error,
|
||||
r2: r2.ok || r2.error,
|
||||
loginRequired: after.loginRequired,
|
||||
passwordLeak: JSON.stringify(after).includes("mock-password"),
|
||||
});
|
||||
await saveState({ ...st, sid2 });
|
||||
}
|
||||
|
||||
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");
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user