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
+1659
-71
No files matched your search
@@ -16,3 +16,4 @@ data/
|
||||
docker-compose.override.yml
|
||||
.env.deploy
|
||||
.local/
|
||||
.browser-verify-out/
|
||||
@@ -45,7 +45,8 @@ describe.skipIf(!up)("浏览器 HTTP 端点", () => {
|
||||
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");
|
||||
// mock 的版本号不在真机核验表里:verified 只来自配置
|
||||
expect(item.capabilities.upstreamVersion).toBe("1.17.0-mock");
|
||||
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 });
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { BrowserService, BrowserServiceError, ENV_PROVIDER_NAME } from "../service.ts";
|
||||
import { ACCESS_KEY, type BrowserHarness, createBrowserHarness, type Loose, pgAvailable } from "./harness.ts";
|
||||
|
||||
// BR-1:浏览器服务登记 —— 环境变量(BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED → camofox-env)
|
||||
// 与设置页端点(POST / PATCH /browser/providers、POST /browser/providers/{id}/probe)。
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe.skipIf(!up)("浏览器服务登记", () => {
|
||||
let h: BrowserHarness;
|
||||
const withEnv = (env: Record<string, unknown>) =>
|
||||
new BrowserService({ ...h.ctx, config: { ...(h.ctx.config as object), ...env } } as ServerContext, {});
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("provcfg");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("BROWSER_PROVIDER_URL:登记 camofox-env,凭据只在进程内;探测带 Bearer;地址/凭据不能在设置页改", async () => {
|
||||
const svc = withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ACCESS_KEY: ACCESS_KEY, BROWSER_ENABLED: true });
|
||||
const list = await svc.listProviders(true);
|
||||
const env = list.find((p) => p.name === ENV_PROVIDER_NAME)!;
|
||||
expect(env).toMatchObject({ managedBy: "env", hasAccessKey: true, secretRef: null, enabled: true });
|
||||
expect(env.health?.status).toBe("ok");
|
||||
const row = await h.db
|
||||
.selectFrom("browser_provider")
|
||||
.selectAll()
|
||||
.where("name", "=", ENV_PROVIDER_NAME)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(JSON.stringify(row)).not.toContain(ACCESS_KEY);
|
||||
expect(h.mock.calls.filter((c) => c.path === "/openapi.json").at(-1)?.headers.authorization).toBe(
|
||||
`Bearer ${ACCESS_KEY}`,
|
||||
);
|
||||
await expect(svc.updateProvider(env.id, { baseUrl: "http://x:1" })).rejects.toBeInstanceOf(BrowserServiceError);
|
||||
// 启用可以改
|
||||
expect((await svc.updateProvider(env.id, { enabled: false })).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("BROWSER_ENABLED=false / 去掉地址:camofox-env 停用,其它服务不受影响", async () => {
|
||||
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: false }).syncEnvProvider();
|
||||
const off = await h.db
|
||||
.selectFrom("browser_provider")
|
||||
.selectAll()
|
||||
.where("name", "=", ENV_PROVIDER_NAME)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(off.enabled).toBe(false);
|
||||
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: true }).syncEnvProvider();
|
||||
await withEnv({}).syncEnvProvider();
|
||||
const rows = await h.db.selectFrom("browser_provider").select(["name", "enabled"]).orderBy("name").execute();
|
||||
expect(rows).toEqual([
|
||||
{ name: "camofox", enabled: true },
|
||||
{ name: ENV_PROVIDER_NAME, enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("设置页:登记 / 改凭据 / 测试连通;凭据明文不出 API,名称 camofox-env 保留", async () => {
|
||||
const bad = await h.req("/browser/providers", { body: { name: ENV_PROVIDER_NAME, baseUrl: h.mock.url } });
|
||||
expect(bad.status).toBe(400);
|
||||
const c = await h.req("/browser/providers", {
|
||||
body: { name: "my-camofox", baseUrl: h.mock.url, accessKey: "wrong-key", enabled: true },
|
||||
});
|
||||
expect(c.status).toBe(200);
|
||||
const id = c.body.data.id as string;
|
||||
expect(c.body.data).toMatchObject({ managedBy: null, hasAccessKey: true, enabled: true });
|
||||
expect(JSON.stringify(c.body)).not.toContain("wrong-key");
|
||||
const dup = await h.req("/browser/providers", { body: { name: "my-camofox", baseUrl: h.mock.url } });
|
||||
expect(dup.status).toBe(409);
|
||||
// 错误凭据:/health 免认证是好的,但 /openapi.json 401 → 标离线并注明 auth
|
||||
const p1 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
|
||||
expect(p1.body.data).toMatchObject({
|
||||
healthStatus: "offline",
|
||||
health: { status: "down", detail: { error: "auth" } },
|
||||
});
|
||||
const u = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: ACCESS_KEY } });
|
||||
expect(u.status).toBe(200);
|
||||
const p2 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
|
||||
expect(p2.status).toBe(200);
|
||||
expect((p2.body.data as Loose).capabilities.upstreamVersion).toBe("1.17.0-mock");
|
||||
const cleared = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: null } });
|
||||
expect(cleared.body.data).toMatchObject({ hasAccessKey: false, secretRef: null });
|
||||
expect((await h.req(`/browser/providers/${id}`, { method: "DELETE" })).status).toBe(200);
|
||||
const env = (await h.req("/browser/providers")).body.data.items.find((p: Loose) => p.name === ENV_PROVIDER_NAME);
|
||||
expect((await h.req(`/browser/providers/${env.id}`, { method: "DELETE" })).status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,17 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
agent(ctx.actorId),
|
||||
async (p, ref, x) => {
|
||||
await svc().limited(x.site);
|
||||
// 标签页全被上游回收(重启 / 空闲超时,已对账为 lost):在同一会话(同一 profile)里开新标签页,不重放旧引用
|
||||
if (!input.tabId && !p.sessionInfo(sid)?.tabs.some((t) => t.status === "open")) {
|
||||
const tab = await p.openTab(sid, { url: input.url, budget: x.budget, as: "automation" });
|
||||
return {
|
||||
url: tab.url ?? "",
|
||||
httpStatus: null,
|
||||
ok: true,
|
||||
attempts: 1,
|
||||
loginRequired: p.sessionInfo(sid)?.loginPage ?? false,
|
||||
};
|
||||
}
|
||||
const r = await p.navigate(ref, input.url, x.budget);
|
||||
return { ...r, loginRequired: p.sessionInfo(sid)?.loginPage ?? false };
|
||||
},
|
||||
|
||||
@@ -87,6 +87,7 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
|
||||
route(app, B.providers, async ({ query }) => ({ items: await svc().listProviders(query.probe === "true") }));
|
||||
route(app, B.createProvider, async ({ body }) => svc().createProvider(body));
|
||||
route(app, B.updateProvider, async ({ params, body }) => svc().updateProvider(params.id, body));
|
||||
route(app, B.deleteProvider, async ({ params }) => svc().deleteProvider(params.id));
|
||||
route(app, B.probeProvider, async ({ params }) => {
|
||||
const [v] = await svc().listProviders(true, params.id);
|
||||
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BrowserError,
|
||||
type BrowserProvider,
|
||||
type BrowserSecretResolver,
|
||||
CAMOFOX_VERIFIED_RELEASES,
|
||||
createCamofoxBrowserProvider,
|
||||
type DownloadSink,
|
||||
type ElementTarget,
|
||||
@@ -224,14 +225,21 @@ export class BrowserService {
|
||||
* 地址与启用由环境变量决定;config 里的其它键(verified、超时等)保留。没设地址时已有的 camofox-env 置为停用。
|
||||
*/
|
||||
syncEnvProvider(): Promise<void> {
|
||||
this.envSync ??= this.doSyncEnvProvider().catch((e) => {
|
||||
this.envSync ??= this.doSyncEnvProvider().then(
|
||||
(probe) => {
|
||||
// 首次登记后在后台探测一次,写回能力与上游版本(不阻塞当前请求;camofox 未就绪时下次"测试连通"再写)
|
||||
if (probe) void this.listProviders(true).catch(() => {});
|
||||
},
|
||||
(e) => {
|
||||
this.envSync = undefined;
|
||||
this.ctx.logger.warn({ component: "browser", err: String(e) }, "登记环境变量浏览器服务失败");
|
||||
});
|
||||
},
|
||||
);
|
||||
return this.envSync;
|
||||
}
|
||||
|
||||
private async doSyncEnvProvider(): Promise<void> {
|
||||
/** 返回是否需要后台探测(已启用且还没有探测过能力) */
|
||||
private async doSyncEnvProvider(): Promise<boolean> {
|
||||
const c = this.ctx.config;
|
||||
const url = c?.BROWSER_PROVIDER_URL;
|
||||
const enabled = !!url && c.BROWSER_ENABLED !== false;
|
||||
@@ -244,7 +252,7 @@ export class BrowserService {
|
||||
if (!url) {
|
||||
if (row?.enabled)
|
||||
await db.updateTable("browser_provider").set({ enabled: false }).where("id", "=", row.id).execute();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const config = JSON.stringify({ ...obj(row?.config), managedBy: ENV_MANAGED });
|
||||
if (row)
|
||||
@@ -259,6 +267,7 @@ export class BrowserService {
|
||||
.values({ name: ENV_PROVIDER_NAME, kind: "camofox", base_url: url, enabled, config })
|
||||
.onConflict((oc) => oc.column("name").doNothing())
|
||||
.execute();
|
||||
return enabled && !Object.keys(obj(row?.capabilities)).length;
|
||||
}
|
||||
|
||||
/** 设置页登记浏览器服务:访问凭据加密存 secret;名称 camofox-env 保留给环境变量 */
|
||||
@@ -300,7 +309,8 @@ export class BrowserService {
|
||||
): Promise<BrowserProviderView> {
|
||||
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
if (obj(row.config).managedBy === ENV_MANAGED && (body.baseUrl !== undefined || body.accessKey !== undefined))
|
||||
const changesAddr = body.baseUrl !== undefined && body.baseUrl !== row.base_url;
|
||||
if (obj(row.config).managedBy === ENV_MANAGED && (changesAddr || body.accessKey !== undefined))
|
||||
throw new BrowserServiceError(
|
||||
"conflict",
|
||||
"该浏览器服务由环境变量 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 管理,改部署配置后重启",
|
||||
@@ -331,6 +341,25 @@ export class BrowserService {
|
||||
return this.providerView(id);
|
||||
}
|
||||
|
||||
/** 删除设置页登记的服务:有未关闭会话时拒绝(先关会话);环境变量登记的只能停用 */
|
||||
async deleteProvider(id: string): Promise<{ id: string }> {
|
||||
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
if (obj(row.config).managedBy === ENV_MANAGED)
|
||||
throw new BrowserServiceError("conflict", "该浏览器服务由环境变量登记,只能停用(BROWSER_ENABLED=false)");
|
||||
const open = await this.ctx.db
|
||||
.selectFrom("browser_session")
|
||||
.select("id")
|
||||
.where("provider_id", "=", id)
|
||||
.where("controller", "<>", "closed")
|
||||
.executeTakeFirst();
|
||||
if (open) throw new BrowserServiceError("conflict", "该浏览器服务还有未关闭的会话,先关闭会话");
|
||||
await this.ctx.db.deleteFrom("browser_provider").where("id", "=", id).execute();
|
||||
if (row.secret_ref) await this.ctx.secrets.remove(row.secret_ref).catch(() => {});
|
||||
this.providers.delete(id);
|
||||
return { id };
|
||||
}
|
||||
|
||||
private async providerView(id: string): Promise<BrowserProviderView> {
|
||||
const v = (await this.listProviders(false, id))[0];
|
||||
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
@@ -364,13 +393,20 @@ export class BrowserService {
|
||||
let caps: BrowserCapabilities | null = null;
|
||||
try {
|
||||
caps = await p.capabilities({ probe: health.status !== "down" });
|
||||
} catch {
|
||||
} catch (e) {
|
||||
caps = await p.capabilities();
|
||||
// /health 免认证,只看它会把"凭据错了"显示成健康:探测被 401/403 拒绝时标为离线并注明
|
||||
if (e instanceof BrowserError && e.kind === "auth")
|
||||
health = { ...health, status: "down", detail: { ...health.detail, error: "auth" } };
|
||||
}
|
||||
row = await this.ctx.db
|
||||
.updateTable("browser_provider")
|
||||
.set({
|
||||
capabilities: JSON.stringify(caps),
|
||||
// 上游版本有真机核验记录时,verified_at 取核验日期(不是探测时间)
|
||||
...(caps.upstreamVersion && CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]
|
||||
? { verified_at: new Date(`${CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]!.verifiedAt}T00:00:00Z`) }
|
||||
: {}),
|
||||
health_status: HEALTH[health.status],
|
||||
last_health_at: new Date(health.checkedAt),
|
||||
})
|
||||
@@ -934,7 +970,7 @@ export class BrowserService {
|
||||
try {
|
||||
out = await fn(loaded.provider, ref, { row, site, budget });
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "not_found" && e.httpStatus === 404) {
|
||||
if (e instanceof BrowserError && e.kind === "not_found" && (e.httpStatus === 404 || e.httpStatus === 410)) {
|
||||
const r = await loaded.provider.reconcileTabs(id).catch(() => null);
|
||||
await this.persist(id, loaded.provider, { needsSnapshot: true });
|
||||
if (r?.lost.length) throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面");
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
BrowserActionResult,
|
||||
BrowserControlResult,
|
||||
BrowserNavigation,
|
||||
BrowserProviderCreate,
|
||||
BrowserProviderUpdate,
|
||||
BrowserProviderView,
|
||||
BrowserScreenshotView,
|
||||
BrowserSessionView,
|
||||
@@ -23,6 +25,50 @@ export function useBrowserProviders() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 设置页:浏览器服务登记 / 修改 / 删除 / 测试连通 ──
|
||||
|
||||
function useInvalidateProviders() {
|
||||
const qc = useQueryClient();
|
||||
return () => qc.invalidateQueries({ queryKey: qk.list(AREA, { kind: "providers" }) });
|
||||
}
|
||||
|
||||
export function useCreateBrowserProvider() {
|
||||
const invalidate = useInvalidateProviders();
|
||||
return useMutation({
|
||||
mutationFn: (body: BrowserProviderCreate) =>
|
||||
apiData<BrowserProviderView>("/browser/providers", { method: "POST", body }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateBrowserProvider() {
|
||||
const invalidate = useInvalidateProviders();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: BrowserProviderUpdate }) =>
|
||||
apiData<BrowserProviderView>("/browser/providers/{id}", { method: "PATCH", pathParams: { id }, body }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBrowserProvider() {
|
||||
const invalidate = useInvalidateProviders();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiData<{ id: string }>("/browser/providers/{id}", { method: "DELETE", pathParams: { id } }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** 测试连通:探测 /health 与 /openapi.json 并写回能力 */
|
||||
export function useProbeBrowserProvider() {
|
||||
const invalidate = useInvalidateProviders();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiData<BrowserProviderView>("/browser/providers/{id}/probe", { method: "POST", pathParams: { id }, body: {} }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** 站点的未关闭会话(最新的一个) */
|
||||
export function useSiteBrowserSession(siteId: string, enabled = true) {
|
||||
return useQuery({
|
||||
|
||||
@@ -103,7 +103,9 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
);
|
||||
|
||||
const caps = provider.capabilities;
|
||||
const unverified = !caps?.takeover.verified || !caps?.screenshot.verified;
|
||||
// 上游缺端点 → 不可用;有端点但没有真机核验记录 → 未核验(REQ §4.7.4)
|
||||
const unsupported = !!caps && (!caps.takeover.supported || !caps.screenshot.supported);
|
||||
const unverified = !unsupported && (!caps?.takeover.verified || !caps?.screenshot.verified);
|
||||
const conn: { tone: StatusTone; text: string } = paused
|
||||
? { tone: "neutral", text: "已暂停刷新" }
|
||||
: shot.isError
|
||||
@@ -147,6 +149,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
{human && leaseHolder ? `(${leaseHolder})` : ""}
|
||||
</StatusBadge>
|
||||
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
|
||||
{unsupported ? <StatusBadge tone="destructive">该浏览器服务不支持画面与输入</StatusBadge> : null}
|
||||
{unverified ? <StatusBadge tone="warning">画面与输入未对真实实例核验</StatusBadge> : null}
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
{human ? (
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
BROWSER_CAPABILITY_KEYS,
|
||||
type BrowserCapabilityKey,
|
||||
BrowserProviderCreate,
|
||||
BrowserProviderUpdate,
|
||||
type BrowserProviderView,
|
||||
} from "@umt/contracts";
|
||||
import {
|
||||
useBrowserProviders,
|
||||
useCreateBrowserProvider,
|
||||
useDeleteBrowserProvider,
|
||||
useProbeBrowserProvider,
|
||||
useUpdateBrowserProvider,
|
||||
} from "~/api/browser.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Health, TestButton } from "./instances.tsx";
|
||||
import { type FormValues, optStr, ResourceSection, str } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// 浏览器服务(camofox-browser):地址、访问凭据(只写,存凭证库)、启用、测试连通、能力核验状态。
|
||||
// 部署自带的 camofox 由环境变量登记为 camofox-env,地址与凭据只能改部署配置。
|
||||
|
||||
const CAP_LABEL: Record<BrowserCapabilityKey, string> = {
|
||||
sessions: "会话",
|
||||
tabs: "标签页",
|
||||
navigate: "导航",
|
||||
snapshot: "页面快照",
|
||||
screenshot: "截图",
|
||||
interact: "点击与输入",
|
||||
extract: "字段提取",
|
||||
downloadCapture: "下载捕获",
|
||||
takeover: "人工接管",
|
||||
persistentProfile: "登录保持",
|
||||
};
|
||||
|
||||
function Verified({ p }: { p: BrowserProviderView }) {
|
||||
const caps = p.capabilities;
|
||||
if (!caps) return <StatusBadge>未探测</StatusBadge>;
|
||||
const keys = BROWSER_CAPABILITY_KEYS.filter((k) => caps[k].supported);
|
||||
const ok = keys.filter((k) => caps[k].verified).length;
|
||||
if (ok === 0) return <StatusBadge tone="warning">未核验</StatusBadge>;
|
||||
return (
|
||||
<StatusBadge tone={ok === keys.length ? "success" : "warning"}>
|
||||
{ok === keys.length ? "已核验" : `已核验 ${ok}/${keys.length}`}
|
||||
{caps.upstreamVersion ? ` · ${caps.upstreamVersion}` : ""}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityTable({ p }: { p: BrowserProviderView }) {
|
||||
const caps = p.capabilities;
|
||||
if (!caps) return <p className="mt-4 text-caption text-muted-foreground">还没有探测过。点"测试连通"读取能力。</p>;
|
||||
return (
|
||||
<div className="mt-5 space-y-2">
|
||||
<h3 className="text-caption font-semibold">
|
||||
能力
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
上游版本 {caps.upstreamVersion ?? "未知"}
|
||||
{p.verifiedAt ? ` · 真机核验 ${p.verifiedAt.slice(0, 10)}` : ""}
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{BROWSER_CAPABILITY_KEYS.map((k) => {
|
||||
const f = caps[k];
|
||||
return (
|
||||
<li key={k} className="flex items-start justify-between gap-3 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium">{CAP_LABEL[k]}</p>
|
||||
{f.note ? <p className="text-meta text-muted-foreground">{f.note}</p> : null}
|
||||
</div>
|
||||
{!f.supported ? (
|
||||
<StatusBadge tone="destructive">不可用</StatusBadge>
|
||||
) : f.verified ? (
|
||||
<StatusBadge tone="success">已核验</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="warning">未核验</StatusBadge>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
"已核验"只来自对真实实例的逐项核验记录(见运行手册),探测只能判断接口在不在。未核验的能力在原站视图里标出。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowserProvidersBlock() {
|
||||
const list = useBrowserProviders();
|
||||
const create = useCreateBrowserProvider();
|
||||
const update = useUpdateBrowserProvider();
|
||||
const remove = useDeleteBrowserProvider();
|
||||
const probe = useProbeBrowserProvider();
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="sites"
|
||||
anchor="browser"
|
||||
title="浏览器服务"
|
||||
description="原站视图和 Agent 打开站点网页用的 camofox-browser。部署自带的一台由环境变量登记(camofox-env)。"
|
||||
plain
|
||||
>
|
||||
<ResourceSection<BrowserProviderView>
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "浏览器服务",
|
||||
emptyHint: "没有浏览器服务时,原站视图不可用。",
|
||||
deleteHint: "有未关闭会话时不能删除。环境变量登记的只能停用。",
|
||||
drawerExtra: (r) => <CapabilityTable p={r} />,
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{r.name}
|
||||
{r.managedBy === "env" ? <StatusBadge>环境变量</StatusBadge> : null}
|
||||
</p>
|
||||
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "key",
|
||||
header: "访问凭据",
|
||||
hideBelow: "md",
|
||||
cell: (r) => (r.hasAccessKey ? "已设置" : "未设置"),
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
header: "状态",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<StatusBadge tone={r.enabled ? "success" : "neutral"}>{r.enabled ? "启用" : "停用"}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
|
||||
{ key: "verified", header: "核验", hideBelow: "lg", cell: (r) => <Verified p={r} /> },
|
||||
],
|
||||
rowActions: (r) => (
|
||||
<TestButton
|
||||
label="测试连通"
|
||||
pending={probe.isPending}
|
||||
run={async () => {
|
||||
const v = await probe.mutateAsync(r.id);
|
||||
const auth = (v.health?.detail as { error?: string } | undefined)?.error === "auth";
|
||||
return {
|
||||
ok: v.health?.status === "ok",
|
||||
message: auth ? "访问凭据不对" : v.health?.status === "degraded" ? "浏览器服务正在恢复" : "连不上",
|
||||
version: v.capabilities?.upstreamVersion ?? null,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
),
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
label: "名称",
|
||||
kind: "text",
|
||||
required: true,
|
||||
createOnly: true,
|
||||
placeholder: "例如:家里的 camofox",
|
||||
},
|
||||
{
|
||||
name: "baseUrl",
|
||||
label: "服务地址",
|
||||
kind: "url",
|
||||
required: true,
|
||||
placeholder: "http://camofox:9377",
|
||||
hint: "环境变量登记的服务改部署配置里的 BROWSER_PROVIDER_URL",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "accessKey",
|
||||
label: "访问凭据",
|
||||
kind: "password",
|
||||
hint: "camofox 的 CAMOFOX_ACCESS_KEY;没设就留空",
|
||||
editHint: "留空不修改",
|
||||
},
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
],
|
||||
initial: { name: "", baseUrl: "", accessKey: "", enabled: true },
|
||||
fromRow: (r) => ({ name: r.name, baseUrl: r.baseUrl ?? "", accessKey: "", enabled: r.enabled }),
|
||||
toBody: (v: FormValues, mode) =>
|
||||
mode === "create"
|
||||
? {
|
||||
name: str(v.name),
|
||||
baseUrl: str(v.baseUrl),
|
||||
accessKey: optStr(v.accessKey),
|
||||
enabled: v.enabled === true,
|
||||
}
|
||||
: { baseUrl: str(v.baseUrl), accessKey: optStr(v.accessKey), enabled: v.enabled === true },
|
||||
createSchema: BrowserProviderCreate,
|
||||
updateSchema: BrowserProviderUpdate,
|
||||
}}
|
||||
/>
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { ArrowRight } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { BrowserProvidersBlock } from "./browser-providers.tsx";
|
||||
import { num, str } from "./resource-section.tsx";
|
||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理。
|
||||
// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理;浏览器服务(BR-1)。
|
||||
// 站点、凭证、账号在「站点」页管理;站点级覆盖在站点的"编辑"里。
|
||||
|
||||
const DEFAULTS_CARD: CardSpec = {
|
||||
@@ -91,6 +92,7 @@ export function SitesSection() {
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsGate>{(s) => <SettingsCard section="sites" spec={DEFAULTS_CARD} settings={s} />}</SettingsGate>
|
||||
<BrowserProvidersBlock />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+57
-1
@@ -1,9 +1,12 @@
|
||||
# 生产/试运行部署:只有两个服务 —— app(单容器:API + worker(io 组)+ worker:agent + web 静态,单端口 4380)+ postgres。
|
||||
# 生产/试运行部署:app(单容器:API + worker(io 组)+ worker:agent + web 静态,单端口 4380)+ postgres
|
||||
# + camofox(浏览器服务,原站视图 / Agent 浏览器能力用;只在内网,不映射端口;挂了不影响 app)。
|
||||
# 用法(详见 docs/runbook/deploy.md):
|
||||
# cp docker/compose.env.example .env # 按注释生成 SECRET_MASTER_KEY / POSTGRES_PASSWORD
|
||||
# docker compose --env-file .env.deploy up -d --build
|
||||
# docker compose logs app | grep -i setup # 首次启动打印一次性 setup token
|
||||
# 开发只起数据库用 docker-compose.dev.yml。
|
||||
# 不要自带浏览器:.env 里 CAMOFOX_REPLICAS=0 + BROWSER_ENABLED=false;接自有 camofox:改 BROWSER_PROVIDER_URL
|
||||
# (见 docs/runbook/browser.md §1)。
|
||||
name: umt
|
||||
|
||||
x-logging: &logging
|
||||
@@ -30,6 +33,50 @@ services:
|
||||
stop_grace_period: 30s
|
||||
logging: *logging
|
||||
|
||||
# camofox-browser(jo-inc/camofox-browser v1.17.0,commit 389c996):上游不发布镜像,按锁定的 tag 从源码构建
|
||||
# (docker/camofox/Dockerfile,修了上游 v1.17.0 Dockerfile 的构建错误)。本机已有同名镜像时 `up` 不会重新构建。
|
||||
camofox:
|
||||
build:
|
||||
context: docker/camofox
|
||||
additional_contexts:
|
||||
upstream: https://github.com/jo-inc/camofox-browser.git#v1.17.0
|
||||
args:
|
||||
APT_MIRROR: ${APT_MIRROR:-}
|
||||
image: ${CAMOFOX_IMAGE:-camofox-browser:1.17.0}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
# 0 = 不起浏览器服务(同时设 BROWSER_ENABLED=false)
|
||||
replicas: ${CAMOFOX_REPLICAS:-1}
|
||||
environment:
|
||||
CAMOFOX_PORT: "9377"
|
||||
# 设置后除 /health 外全部接口要求 Authorization: Bearer <key>;app 用同一个值
|
||||
CAMOFOX_ACCESS_KEY: ${CAMOFOX_ACCESS_KEY:-}
|
||||
# 登录状态(cookies + localStorage)按 userId 存 /root/.camofox/profiles,随卷持久化
|
||||
CAMOFOX_PROFILE_DIR: /root/.camofox/profiles
|
||||
# 上游缺省把崩溃报告发到它的中继服务;这里关掉
|
||||
CAMOFOX_CRASH_REPORT_ENABLED: "false"
|
||||
# 不装缺省扩展(uBlock Origin):每次启动浏览器都要从 addons.mozilla.org 下载,内网 / 国内网络下失败重试拖慢启动
|
||||
CAMOFOX_DISABLE_DEFAULT_ADDONS: "true"
|
||||
# 无请求多久回收会话(回收时落盘登录状态,下次打开恢复)/ 浏览器空闲多久退出(省内存,下次请求再启动)
|
||||
SESSION_TIMEOUT_MS: ${CAMOFOX_SESSION_TIMEOUT_MS:-1800000}
|
||||
BROWSER_IDLE_TIMEOUT_MS: ${CAMOFOX_BROWSER_IDLE_TIMEOUT_MS:-300000}
|
||||
MAX_SESSIONS: ${CAMOFOX_MAX_SESSIONS:-8}
|
||||
CAMOFOX_LOCALE: ${CAMOFOX_LOCALE:-}
|
||||
CAMOFOX_TIMEZONE: ${CAMOFOX_TIMEZONE:-}
|
||||
volumes:
|
||||
- umt-camofox:/root/.camofox
|
||||
# Firefox 多进程需要较大的 /dev/shm(上游 Makefile 用 2g)
|
||||
shm_size: 2gb
|
||||
mem_limit: ${CAMOFOX_MEM_LIMIT:-2g}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:9377/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
stop_grace_period: 20s
|
||||
logging: *logging
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
@@ -41,6 +88,10 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# 只要求已启动,不等健康:浏览器服务挂了 app 照常运行,界面标"浏览器不可用"
|
||||
camofox:
|
||||
condition: service_started
|
||||
required: false
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgres://umt:${POSTGRES_PASSWORD}@postgres:5432/umt
|
||||
@@ -55,6 +106,10 @@ services:
|
||||
WORKER_AGENT_CONCURRENCY: ${WORKER_AGENT_CONCURRENCY:-2}
|
||||
# 手动按路径整理时递归遍历的文件数上限
|
||||
ORGANIZE_WALK_MAX_FILES: ${ORGANIZE_WALK_MAX_FILES:-5000}
|
||||
# 浏览器服务:缺省指向上面的 camofox;接自有实例改成它的地址与访问凭据;false = 停用
|
||||
BROWSER_PROVIDER_URL: ${BROWSER_PROVIDER_URL:-http://camofox:9377}
|
||||
BROWSER_ACCESS_KEY: ${BROWSER_ACCESS_KEY:-${CAMOFOX_ACCESS_KEY:-}}
|
||||
BROWSER_ENABLED: ${BROWSER_ENABLED:-true}
|
||||
ports:
|
||||
- "${UMT_BIND:-0.0.0.0}:${UMT_PORT:-4380}:4380"
|
||||
volumes:
|
||||
@@ -69,3 +124,4 @@ services:
|
||||
volumes:
|
||||
umt-pg-data:
|
||||
umt-data:
|
||||
umt-camofox:
|
||||
@@ -0,0 +1,60 @@
|
||||
# camofox-browser 镜像(docker-compose.yml 的 camofox 服务)。
|
||||
# 上游 jo-inc/camofox-browser 不发布镜像;源码由命名上下文 upstream 提供(compose 里锁定 git tag v1.17.0,
|
||||
# commit 389c996ae3c7d42e539295a336ee6f975847f066)。本文件照抄上游 Dockerfile 的 camofox-browser 阶段,只改三处:
|
||||
# 1. 上游 v1.17.0 的 Dockerfile 在 `npm ci` 之前没有复制 postinstall.js,构建必然失败(MODULE_NOT_FOUND);
|
||||
# 这里先复制 postinstall.js 与 lib/(Camoufox 已预置,postinstall 见到 version.json 直接跳过下载);
|
||||
# 2. 可选 APT_MIRROR(国内构建换源,如 mirrors.ustc.edu.cn);
|
||||
# 3. CMD 用 exec 启动 node(见文件末尾)。
|
||||
# 手工构建:docker build -t camofox-browser:1.17.0 \
|
||||
# --build-context upstream=https://github.com/jo-inc/camofox-browser.git#v1.17.0 docker/camofox
|
||||
FROM node:22-trixie-slim AS camofox-browser
|
||||
|
||||
ARG CAMOUFOX_VERSION=152.0.4
|
||||
ARG CAMOUFOX_RELEASE=beta.28
|
||||
ARG ARCH=x86_64
|
||||
ARG APT_MIRROR=
|
||||
|
||||
RUN if [ -n "$APT_MIRROR" ]; then sed -i "[email protected]@${APT_MIRROR}@g" /etc/apt/sources.list.d/debian.sources; fi \
|
||||
&& apt-get update && apt-get install -y \
|
||||
libgtk-3-0 libdbus-glib-1-2 libxt6 libasound2 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxfixes3 \
|
||||
libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \
|
||||
libegl1 libgl1-mesa-dri libgbm1 \
|
||||
xvfb fonts-liberation fonts-noto-color-emoji fontconfig \
|
||||
ca-certificates curl unzip python3-minimal \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /root/.cache/camoufox \
|
||||
&& curl -fL -o /tmp/camoufox.zip "https://github.com/daijro/camoufox/releases/download/v${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}/camoufox-${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}-lin.${ARCH}.zip" \
|
||||
&& (unzip -q /tmp/camoufox.zip -d /root/.cache/camoufox || true) \
|
||||
&& rm /tmp/camoufox.zip \
|
||||
&& chmod -R 755 /root/.cache/camoufox \
|
||||
&& echo "{\"version\":\"${CAMOUFOX_VERSION}\",\"release\":\"${CAMOUFOX_RELEASE}\"}" > /root/.cache/camoufox/version.json \
|
||||
&& test -f /root/.cache/camoufox/camoufox-bin
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=upstream package.json package-lock.json postinstall.js ./
|
||||
COPY --from=upstream scripts/ ./scripts/
|
||||
COPY --from=upstream lib/ ./lib/
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential python3 \
|
||||
&& npm ci --omit=dev \
|
||||
&& apt-get purge -y --auto-remove build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=upstream server.js camofox.config.json ./
|
||||
COPY --from=upstream mcp/ ./mcp/
|
||||
COPY --from=upstream plugins/ ./plugins/
|
||||
|
||||
RUN sh scripts/install-plugin-deps.sh
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/jo-inc/camofox-browser" \
|
||||
org.opencontainers.image.version="v1.17.0" \
|
||||
org.opencontainers.image.revision="389c996ae3c7d42e539295a336ee6f975847f066"
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV CAMOFOX_PORT=9377
|
||||
EXPOSE 9377
|
||||
# exec:让 node 成为 PID 1 收到 SIGTERM。上游用 `sh -c "node …"`,sh 不转发信号,docker stop/restart 等满宽限期后
|
||||
# SIGKILL,持久化插件的关机落盘(server:shutdown)不会执行,上次落盘之后的登录状态全部丢失(2026-09-28 实测)
|
||||
CMD ["sh", "-c", "exec node --max-old-space-size=${MAX_OLD_SPACE_SIZE:-128} server.js"]
|
||||
@@ -26,3 +26,21 @@ LOG_LEVEL=info
|
||||
# WORKER_AGENT_CONCURRENCY=2
|
||||
# 手动按路径整理时递归遍历的文件数上限(dryRun 超出返回 truncated,正式执行拒绝)
|
||||
# ORGANIZE_WALK_MAX_FILES=5000
|
||||
|
||||
# ── 浏览器服务(camofox-browser,原站视图 / Agent 浏览器能力;docs/runbook/browser.md)──
|
||||
# 缺省随 compose 起一个 camofox 容器(只在内网,不映射端口),app 自动登记为 camofox-env。
|
||||
# 访问凭据:camofox 与 app 共用;建议设置(openssl rand -hex 24)
|
||||
CAMOFOX_ACCESS_KEY=
|
||||
# 接自有 camofox 实例:改成它的地址与凭据,并把 CAMOFOX_REPLICAS 设 0
|
||||
# BROWSER_PROVIDER_URL=http://camofox:9377
|
||||
# BROWSER_ACCESS_KEY=
|
||||
# 完全不用浏览器:CAMOFOX_REPLICAS=0 且 BROWSER_ENABLED=false(界面标"浏览器不可用")
|
||||
# CAMOFOX_REPLICAS=1
|
||||
# BROWSER_ENABLED=true
|
||||
# camofox 调优(可选):会话空闲回收(回收时落盘登录状态)、浏览器空闲退出、最多会话数、内存上限
|
||||
# CAMOFOX_SESSION_TIMEOUT_MS=1800000
|
||||
# CAMOFOX_BROWSER_IDLE_TIMEOUT_MS=300000
|
||||
# CAMOFOX_MAX_SESSIONS=8
|
||||
# CAMOFOX_MEM_LIMIT=2g
|
||||
# CAMOFOX_LOCALE=zh-CN
|
||||
# CAMOFOX_TIMEZONE=Asia/Shanghai
|
||||
@@ -20,7 +20,8 @@
|
||||
- **BrowserProviderKind**:`camofox | none`。`none` 为默认,能力全关,动作报 `unavailable`,界面据此显示"浏览器不可用"。
|
||||
- **BrowserCapabilities**:`sessions / tabs / navigate / snapshot / screenshot / interact / extract / downloadCapture / takeover / persistentProfile`,每项是 `{ supported, verified, source, note? }`:
|
||||
- `supported`:本适配器当前对该上游能提供(上游有端点但适配器尚未实现的也记 false,并注明 M2);
|
||||
- `verified`:仅在真实实例原型验证通过后由配置注入为 true,探测永远不会把它设为 true;
|
||||
- `verified`:只来自真机核验 —— 适配器内置的按版本核验表 `CAMOFOX_VERIFIED_RELEASES`(探测到完全相同的上游版本才生效)
|
||||
与 `config.verified` 逐项覆盖;探测本身永远不会把它设为 true;
|
||||
- `source`:`static`(按文档声明)或 `probed`(从上游 `/openapi.json` 推导);
|
||||
- 另有 `upstreamVersion`、`probedAt`。界面只把 `supported && verified` 视为可用(REQ §4.7.4)。
|
||||
- **BrowserSession**:`providerId, siteId, accountId, profileKey, controller, currentUrl, lastActivityAt, upstreamRef, recoveryState, closedAt, closeReason`。`controller ∈ automation | human | waiting | closed`;非 `automation` 时适配器拒绝自动化动作(`control_conflict`)。
|
||||
@@ -42,7 +43,11 @@
|
||||
|
||||
## 3. camofox-browser OpenAPI 对照
|
||||
|
||||
来源:`https://raw.githubusercontent.com/jo-inc/camofox-browser/master/openapi.json`(`info.version` 1.17.0)与同仓 README,2026-09-27 取得,**仅读文档,未连接真实实例**;用户实际部署的仓库/fork/版本尚未确认。
|
||||
来源:`https://raw.githubusercontent.com/jo-inc/camofox-browser/master/openapi.json`(`info.version` 1.17.0)与同仓 README,2026-09-27 取得。
|
||||
**2026-09-28 已对 v1.17.0(commit `389c996`)真实容器逐项核验**,部署随 compose 自带该版本(`docker/camofox/Dockerfile`);
|
||||
实测与下表文档描述的差异(截图是 `image/png` 二进制、click 不接受坐标、焦点输入要 `mode=keyboard`、ARIA 快照里密码框带明文值、
|
||||
downloads 字段名、重启后旧标签页 410)已在适配器里修正,见 `docs/runbook/browser.md` §7。
|
||||
下表"状态"列是 C1-E 当时的记录;现在全部方法已实现并在真机上通过。
|
||||
|
||||
上游模型:一个 Camoufox 实例 → `userId` 对应一个隔离 BrowserContext(cookies/storage)→ `sessionKey` 为标签组 → 标签页。本项目以 `profileKey`(站点/账号)作 `userId`,以 `browser_session.id` 作 `sessionKey`。认证:设置 `CAMOFOX_ACCESS_KEY` 后,除 `/health`、cookie 导入、`/stop` 外全部路由要求 `Authorization: Bearer <key>`;`CAMOFOX_API_KEY` 另管 cookie 导入与 traces。
|
||||
|
||||
|
||||
+100
-29
@@ -4,33 +4,64 @@
|
||||
代码:`packages/adapters/src/browser/**`(适配器)、`apps/server/src/modules/browser/**`(会话/接管/能力)、
|
||||
`apps/web/app/components/browser/**`(原站视图)。
|
||||
|
||||
> 当前状态:全部能力只对 testkit 的 mock camofox 验证过,**没有连接过真实实例**。界面上"画面与输入未对真实实例核验"
|
||||
> 这个标记会一直显示,直到按第 6 节完成核验并写入 `config.verified`。
|
||||
> 当前状态:已对真实 camofox-browser **v1.17.0**(jo-inc,commit `389c996`)容器逐项核验(§7),十项能力全部通过;
|
||||
> 探测到其它版本 / fork 时一律显示"未核验",需按 §6 重新核验。
|
||||
|
||||
## 1. 登记浏览器服务
|
||||
## 1. 浏览器服务从哪来
|
||||
|
||||
设置页还没有浏览器服务的表单(留给 C1 之后)。当前用 API + SQL 登记:
|
||||
### 1.1 部署自带(缺省)
|
||||
|
||||
`docker compose up -d` 同时起 `camofox` 服务(`docker-compose.yml`,只在内网、不映射端口,卷 `umt-camofox`)。
|
||||
app 的 `BROWSER_PROVIDER_URL` 缺省 `http://camofox:9377`,启动后第一次用到浏览器时登记为 **`camofox-env`**
|
||||
(`browser_provider.config.managedBy = "env"`,访问凭据取 `BROWSER_ACCESS_KEY` / `CAMOFOX_ACCESS_KEY`,只在进程内,不入库),
|
||||
并在后台探测一次能力。地址与凭据只能改部署配置;设置页只能改它的启用。
|
||||
|
||||
```sh
|
||||
KEY="Authorization: Bearer <admin API Key 或 DEV_ADMIN_KEY>"
|
||||
# 1) 访问凭据(camofox 的 CAMOFOX_ACCESS_KEY)存进凭证库,拿到 secret id
|
||||
curl -s -XPOST localhost:4380/api/v1/secrets -H "$KEY" -H 'content-type: application/json' \
|
||||
-d '{"purpose":"other","name":"camofox-access-key","plaintext":"<CAMOFOX_ACCESS_KEY>"}'
|
||||
# 2) 登记服务(secret_ref 用上一步的 id;没设访问凭据就留 null)
|
||||
psql "$DATABASE_URL" -c "insert into browser_provider (name, kind, base_url, secret_ref, enabled)
|
||||
values ('camofox', 'camofox', 'http://camofox:9377', '<secret id>', true)"
|
||||
# 3) 探测:读 /health 与 /openapi.json,写回 capabilities / health_status
|
||||
curl -s "localhost:4380/api/v1/browser/providers?probe=true" -H "$KEY"
|
||||
# .env(部署目录)
|
||||
CAMOFOX_ACCESS_KEY=$(openssl rand -hex 24) # camofox 与 app 共用
|
||||
docker compose --env-file .env.deploy up -d --build
|
||||
docker compose ps camofox # healthy 即可
|
||||
```
|
||||
|
||||
设置页将来要做的字段:`base_url`、访问凭据(写 `secret_ref`)、`enabled`、只读的能力表(supported / verified / 上游版本)、
|
||||
下面的 `config` 可调项。
|
||||
上游不发布镜像,compose 用 `docker/camofox/Dockerfile` 从 git tag `v1.17.0` 构建(修了上游 Dockerfile 的两个问题,见文件头)。
|
||||
首次构建要从 GitHub 拉源码与约 630 MB 的 Camoufox 浏览器包;网络不好时先在本机构建同名镜像,compose 会直接用:
|
||||
|
||||
```sh
|
||||
git clone --depth 1 --branch v1.17.0 https://github.com/jo-inc/camofox-browser.git /tmp/camofox-src
|
||||
docker build -t camofox-browser:1.17.0 --build-arg APT_MIRROR=mirrors.ustc.edu.cn \
|
||||
--build-context upstream=/tmp/camofox-src docker/camofox
|
||||
# 走宿主代理时加 --network host --build-arg https_proxy=http://127.0.0.1:7890 --build-arg http_proxy=...
|
||||
```
|
||||
|
||||
### 1.2 接自有实例
|
||||
|
||||
`.env` 里 `BROWSER_PROVIDER_URL=http://<host>:9377`、`BROWSER_ACCESS_KEY=<它的 CAMOFOX_ACCESS_KEY>`、`CAMOFOX_REPLICAS=0`
|
||||
(不再起自带容器)。或者不设环境变量,在 **设置 → 站点默认 → 浏览器服务** 里"添加浏览器服务":名称、服务地址、访问凭据
|
||||
(加密存凭证库,只写不回显)、启用。自有实例的版本不是 1.17.0 时能力一律显示"未核验"。
|
||||
|
||||
### 1.3 不用浏览器
|
||||
|
||||
`CAMOFOX_REPLICAS=0` + `BROWSER_ENABLED=false`:`camofox-env` 停用,原站视图显示"没有启用的浏览器服务"。
|
||||
|
||||
### 1.4 设置页与 API
|
||||
|
||||
设置 → 站点默认 → 浏览器服务:列表(地址、来源、凭据是否设置、启用、健康、核验)、测试连通(探测 `/health` 与
|
||||
`/openapi.json`,凭据错误显示"访问凭据不对")、编辑、删除(有未关闭会话时拒绝;`camofox-env` 只能停用);
|
||||
点行打开的抽屉里是逐项能力表(不可用 / 未核验 / 已核验 + 说明)。
|
||||
|
||||
| 端点 | 说明 |
|
||||
| --- | --- |
|
||||
| `GET /browser/providers[?probe=true]` | 列表;`probe=true` 探测全部启用的服务并写回 |
|
||||
| `POST /browser/providers` | 登记 `{name, baseUrl, accessKey?, enabled?}`;`camofox-env` 名称保留 |
|
||||
| `PATCH /browser/providers/{id}` | `{baseUrl?, accessKey?(null 清除), enabled?}` |
|
||||
| `POST /browser/providers/{id}/probe` | 测试连通(停用的也可以测) |
|
||||
| `DELETE /browser/providers/{id}` | 删除 |
|
||||
|
||||
`browser_provider.config` 可调项(都可省略):
|
||||
|
||||
| 键 | 缺省 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `verified` | `{}` | 人工核验通过的能力,如 `{"snapshot": true, "screenshot": true}`;探测永远不会把它置 true |
|
||||
| `verified` | `{}` | 逐项覆盖核验结果,如 `{"takeover": false}`;缺省取适配器内置的真机核验表(§7,按上游版本);探测永远不会把它置 true |
|
||||
| `navigationTimeoutMs` | 30000 | 单次导航超时 |
|
||||
| `maxRetries` | 2 | 导航只重试超时 / 网络 / 5xx / 503;401、白名单、控制权冲突不重试 |
|
||||
| `snapshotMaxBytes` | 524288 | 快照文本上限(超出截断并标 truncated) |
|
||||
@@ -90,13 +121,23 @@ execute 类需要持续授权(`automation_grant`)或审批。元素引用只来
|
||||
| 502 浏览器服务不可用 | camofox `/health`(503 = 恢复中);容器日志 |
|
||||
| 409 先接管会话再操作 | 界面点"接管";或租约已被他人持有 / 已过期 |
|
||||
| "连接中断" | 截图请求失败;看 API 日志 `component: browser` 的方法 / 路径 / 状态(日志不含正文与凭据) |
|
||||
| 测试连通提示"访问凭据不对" | `/health` 免认证所以能连上,但 `/openapi.json` 401:`BROWSER_ACCESS_KEY` 与 camofox 的 `CAMOFOX_ACCESS_KEY` 不一致 |
|
||||
| 409"标签页已被浏览器服务回收" | camofox 重启(旧标签页返回 410)或会话空闲回收(404);Agent 再次 `browser.navigate` 会在同一会话开新标签页 |
|
||||
| 重启 camofox 后要重新登录 | 容器是被 SIGKILL / OOM 杀掉的(`docker inspect` 看 `OOMKilled`、退出码 137):只有 SIGTERM 正常停止、会话空闲回收、关闭会话时才落盘登录状态;加大 `CAMOFOX_MEM_LIMIT` |
|
||||
| 设置页显示"未核验" | 探测到的上游版本不在核验表里(不是 1.17.0,或 fork);按 §6 核验后在适配器 `CAMOFOX_VERIFIED_RELEASES` 加一行,或临时写 `config.verified` |
|
||||
|
||||
## 6. 真实实例核验清单(REQ §4.7.4)
|
||||
|
||||
先固定用户现有 camofox-browser 的**仓库、版本(`/openapi.json` 的 `info.version`)、启动方式与认证配置**,记在本节末尾。
|
||||
每项通过后把对应能力写进 `browser_provider.config.verified` 并更新 `verified_at`;不通过的保持 false,界面继续标"未核验"。
|
||||
先固定 camofox-browser 的**仓库、版本(`/openapi.json` 的 `info.version`)、启动方式与认证配置**,记在本节末尾。
|
||||
通过的能力登记进适配器的真机核验表 `CAMOFOX_VERIFIED_RELEASES`(`packages/adapters/src/browser/camofox/capabilities.ts`,
|
||||
按上游版本,只在探测到完全相同的版本时生效,并把 `verified_at` 写成核验日期);个别部署要撤销某项写
|
||||
`browser_provider.config.verified = {"<键>": false}`。不通过的保持 false,界面继续标"未核验"。
|
||||
不得用另一个浏览器窗口代替后宣称通过。
|
||||
|
||||
自动化核验脚本:`scripts/browser-verify/run.sh`(临时栈 `umtbr`:postgres 127.0.0.1:55447、camofox 127.0.0.1:49377、
|
||||
compose 网络里的 mock NexusPHP 站 `mocknexus:8080`;服务层在宿主进程里装配,与生产同一套代码;
|
||||
阶段之间重启 / SIGKILL camofox;结果写 `.browser-verify-out/results-*.json` 与截图)。跑完 `docker compose -p umtbr down -v`。
|
||||
|
||||
| # | 项目 | 做法 | 通过标准 | 对应 verified 键 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 登录保持 | 观众 / 馒头 / HHClub / CHD 各一账号:原站视图接管登录;闲置超过 `SESSION_TIMEOUT_MS` 后再 `browser.snapshot` | 不需重新登录;过期时落到登录页、`loginRequired = true`,而不是空列表 | `sessions`、`persistentProfile` |
|
||||
@@ -106,17 +147,47 @@ execute 类需要持续授权(`automation_grant`)或审批。元素引用只来
|
||||
| 5 | 重启后会话 | 分别触发 `BROWSER_IDLE_TIMEOUT_MS` 回收与强制重启 camofox 容器 | 持久化 profile 恢复登录;旧 tabId 识别为 `lost`,不重放旧引用 | `persistentProfile`、`tabs` |
|
||||
| 6 | 人与 Agent 同标签页连续操作 | Agent 打开详情页 → 原站视图接管,在**同一标签页**点击、输入 → 交还 → Agent 重新 `snapshot` | 人看到的是同一会话同一页面;交还后 Agent 读到人工操作后的状态;下载不重复提交 | `takeover`、`interact` |
|
||||
|
||||
本适配器依赖、但 1.17.0 OpenAPI 未写明的行为,核验时一并确认(不符就改适配器,不改合同):
|
||||
|
||||
- `POST /tabs/{id}/click` 接受 `{x, y}` 视口坐标(原站视图点击);不支持时需要改走 VNC 插件或上游新增端点。
|
||||
- `POST /tabs/{id}/type` 不带 `ref`/`selector` 时输入到当前焦点;`submit` 与 README 的 `pressEnter` 哪个生效。
|
||||
- `POST /tabs/{id}/press`、`/scroll`、`/back`、`/forward`、`/refresh` 是否存在及请求体字段。
|
||||
- `DELETE /tabs/{id}` 的 `userId` 放 query 还是 body;`DELETE /sessions/{userId}` 是否关闭该 profile 全部标签。
|
||||
- `userId` 允许的字符与长度(`umt-site-<uuid>-acct-<uuid>`,约 90 字符)。
|
||||
- 性能:四站并发 1 / 2 / 4 会话的启动延迟、截图往返(原站视图 1.5 秒刷新是否跟得上)、内存与失败恢复时间。
|
||||
|
||||
核验记录(填写):
|
||||
核验记录:
|
||||
|
||||
| 日期 | 仓库 / fork | 版本 | 启动方式 / 认证 | 通过项 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| | | | | | |
|
||||
| 2026-09-28 | jo-inc/camofox-browser | v1.17.0(`389c996`),Camoufox 152.0.4-beta.28 | `docker/camofox/Dockerfile` + compose;`CAMOFOX_ACCESS_KEY` Bearer | 十项全部 | mock NexusPHP 站 + example.com;四个真实站点未测,见 §7 |
|
||||
|
||||
## 7. 真机核验(版本 1.17.0)
|
||||
|
||||
环境:`camofox-browser:1.17.0` 容器(本机构建,镜像 3.6 GB,空闲内存约 200 MB,打开 2 个会话后约 390 MB),
|
||||
`CAMOFOX_ACCESS_KEY` 已设置,`SESSION_TIMEOUT_MS=60000`、`BROWSER_IDLE_TIMEOUT_MS=30000`(空闲回收阶段用短超时);
|
||||
目标页:testkit mock NexusPHP(观众风格,`mocknexus:8080`,与 camofox 同一 compose 网络)与公开静态页 `https://example.com/`。
|
||||
浏览器服务经环境变量登记为 `camofox-env`。每项的原始输出见 `scripts/browser-verify/run.sh` 的结果文件。
|
||||
|
||||
| # | 项目 | 结果 | 实际观测 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | 认证 / 探测 | 通过 | 无 Bearer 请求 401;`/openapi.json` `info.version = 1.17.0`,十项能力的端点都在 |
|
||||
| 1 | 登录保持 | 通过 | 新 profile 打开 `torrents.php` → 302 到 `login.php?returnto=…`,`loginRequired = true`;人工接管登录后 Agent 直接打开受保护列表页,不再登录(站点 takelogin 只收到 1 次);Agent 用服务端凭证注入登录第二个账号也通过,快照与返回值里没有密码 |
|
||||
| 2 | 截图 / 结构化快照 | 通过(修了遮蔽) | 截图直接返回 `image/png` 二进制(不是 OpenAPI 写的 JSON base64),视口随指纹变化(实测 1433×1145、1691×867),截图像素 = 点击坐标;登录页 `modelSafe = false`。快照是 Playwright ARIA 格式(`- textbox [e2]: 值`,引用写作 `[eN]`),**密码框与普通文本框同形且带明文值**,原有按标签名的遮蔽规则抓不到 → 已修(见下);列表页 45 个引用、8.6 KB,未触发分页 |
|
||||
| 3 | 列表 / 详情提取 | 通过 | 列表页快照含 fixture 的标题、副标题、大小、做种数(5 条中 4 条标题完整出现,1 条是站点截断显示);`POST /extract` 按 `x-ref` 取回标题;详情页取回下载链接文字。与 HTTP 适配器同源 fixture 对照,未逐字段比对促销标记 |
|
||||
| 4 | 下载捕获 | 通过(修了字段名) | Agent 点详情页 `.torrent` 链接 → `downloads` 返回 `{id, url, suggestedFilename, mimeType, bytes, createdAt, failure, dataBase64}`;sha256 与直接 HTTP 下载一致(`9fcf61f7…`),infohash 与站点一致并匹配到本地资源,约 2 秒;`consume=true` 后不再返回同一文件 |
|
||||
| 5 | 重启后会话 | 通过(修了 3 处),有已知限制 | SIGTERM 重启:旧标签页返回 **410** `browser_restarted`(不是 404)→ 已按 lost 处理;登录状态恢复,Agent 再次导航在同一会话开新标签页(约 1.3 秒,含浏览器冷启动)。会话空闲回收(60 秒 + 上游 60 秒扫描周期):旧标签页 404 → lost,重新打开仍登录(约 0.4 秒)。**SIGKILL**:上次落盘之后的新登录丢失(需重新登录),这是上游落盘时机决定的(只在会话空闲回收、关闭、SIGTERM 停止时写),界面照常识别为登录页 |
|
||||
| 6 | 人与 Agent 同标签页 | 通过(修了坐标点击与焦点输入) | Agent 打开登录页(上游 tabId T)→ 接管:自动化输入被拒(409)、只读快照允许 → 截图坐标点击用户名框 → 焦点输入 → 点密码框 → 输入 → 回车 → 交还;交还前的旧引用 `e1` 被拒("先重新读取页面");Agent 重新读取,**仍是 tabId T**,看到人工登录后的页面。后退 / 前进 / 刷新 / 滚动也在同一标签页通过。核验走原站视图调用的同一组 HTTP 端点,没有在浏览器里点原站视图界面 |
|
||||
|
||||
对接中发现并已修的差异:
|
||||
|
||||
1. **截图格式**:1.17.0 直接返回 `image/png`;适配器原本两种都兼容,无需改。
|
||||
2. **坐标点击**:`POST /click` 只认 `ref` / `selector`,不接受 `{x, y}` → 适配器先 `POST /evaluate` 用 `elementFromPoint` 给该点元素打
|
||||
`data-umt-pt` 标记,再按属性选择器点击(仍走上游的完整鼠标事件序列与下载守卫)。坐标处没有元素报 400。
|
||||
3. **焦点输入**:`POST /type` 缺省 `mode=fill` 必须带 `ref`/`selector` → 无目标时发 `mode=keyboard`;回车用 `/press`。
|
||||
4. **密码框遮蔽**:快照里出现带值的输入框时,经 `evaluate` 取页面上 `input[type=password]` 的当前值按字面量遮蔽;
|
||||
`evaluate` 失败时遮蔽全部输入框的值。`extract` 结果同样处理。
|
||||
5. **下载字段**:`suggestedFilename`、`bytes`(大小)、`dataBase64`、`dataSkipped`(超 `maxBytes`)、`failure`。
|
||||
6. **410**:浏览器重启后 5 分钟内旧标签页返回 410,与 404 同样按"标签页已不存在"对账为 lost;Agent 下一次导航在同一会话开新标签页。
|
||||
7. **上游镜像**:① v1.17.0 的 Dockerfile 在 `npm ci` 前没复制 `postinstall.js`,构建必然失败;② `CMD sh -c "node …"`
|
||||
不转发 SIGTERM,`docker stop/restart` 等满宽限期后 SIGKILL,**关机落盘不执行,重启后登录全丢**(修前实测)。
|
||||
`docker/camofox/Dockerfile` 两处都改了(`exec node`)。
|
||||
8. 其它:关掉上游缺省开启的崩溃报告外发(`CAMOFOX_CRASH_REPORT_ENABLED=false`)与每次启动从 addons.mozilla.org 下载
|
||||
uBlock(`CAMOFOX_DISABLE_DEFAULT_ADDONS=true`,内网下载失败会拖慢启动)。
|
||||
|
||||
`userId`(`umt-site-<uuid>-acct-<uuid>`,约 90 字符)上游原样使用(持久化目录按 SHA-256 取名),无字符限制问题;
|
||||
`DELETE /tabs/{id}` 的 `userId` query / body 都认;`DELETE /sessions/{userId}` 关闭该 profile 全部标签页并落盘。
|
||||
|
||||
没有核验的:四个真实站点(观众 / 馒头 / HHClub / CHD)的登录保持、Cloudflare / 验证码挑战、真实站点页面上的提取对比;
|
||||
并发 1 / 2 / 4 会话的性能与资源;原站视图界面本身在浏览器里的点击体验(1.5 秒截图刷新);VNC 插件(镜像已装,未启用、未接入)。
|
||||
+27
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
形态:**除 PostgreSQL 外全部在一个容器里**。镜像 `uhd-media-tool` 内一个 `deno compile` 出的二进制 `umt-server`,
|
||||
容器里同时跑 api、worker(io 组)、worker:agent(Agent 会话)三个进程,api 同端口托管 web 静态产物(`WEB_ROOT=/app/public`),对外只有 **4380** 一个端口。
|
||||
compose 只有 `app` + `postgres` 两个服务。
|
||||
compose 有三个服务:`app` + `postgres` + `camofox`(浏览器服务,只在内网,不映射端口;可关掉或换成自有实例,见 §2.1)。
|
||||
|
||||
## 1. 构建镜像
|
||||
|
||||
@@ -37,6 +37,24 @@ docker compose ps # app 变成 hea
|
||||
curl -s localhost:4380/api/v1/health
|
||||
```
|
||||
|
||||
### 2.1 浏览器服务(camofox)
|
||||
|
||||
缺省随栈起一个 camofox-browser 容器(jo-inc/camofox-browser **v1.17.0**,commit `389c996`),给原站视图与 Agent 浏览器能力用。
|
||||
app 启动时按 `BROWSER_PROVIDER_URL`(缺省 `http://camofox:9377`)自动登记名为 `camofox-env` 的浏览器服务,
|
||||
`depends_on` 只要求已启动、不等健康:camofox 挂了 app 照常运行,界面标"浏览器不可用"。
|
||||
|
||||
- **镜像**:上游不发布镜像,compose 用 `docker/camofox/Dockerfile` 从锁定的 git tag 构建(修了上游 v1.17.0 Dockerfile 的
|
||||
构建错误与信号转发,见文件头注释)。首次 `up --build` 要拉 GitHub 源码与约 630 MB 的 Camoufox 浏览器包,
|
||||
镜像约 3.6 GB;国内网络按 `docs/runbook/browser.md` §1 先在本机构建同名镜像 `camofox-browser:1.17.0`,之后 compose 直接复用。
|
||||
- **访问凭据**:`.env` 里设 `CAMOFOX_ACCESS_KEY`(`openssl rand -hex 24`),camofox 与 app 共用;不设则内网免认证。
|
||||
- **资源**:空闲约 200 MB(浏览器空闲 5 分钟自动退出后更低);每个站点会话一个 BrowserContext,
|
||||
`mem_limit` 缺省 2g、`shm_size` 2gb(Firefox 多进程需要)。
|
||||
- **登录状态**:按站点/账号存在卷 `umt-camofox`(`/root/.camofox/profiles`,cookies + localStorage),
|
||||
在会话空闲回收、关闭会话与 `docker compose stop/restart`(SIGTERM)时落盘;**容器被 SIGKILL / OOM 杀掉时,
|
||||
上次落盘之后的新登录会丢**(需重新登录)。
|
||||
- **接自有实例**:`.env` 里 `BROWSER_PROVIDER_URL=<地址>`、`BROWSER_ACCESS_KEY=<它的 CAMOFOX_ACCESS_KEY>`、`CAMOFOX_REPLICAS=0`。
|
||||
- **不要浏览器**:`CAMOFOX_REPLICAS=0` + `BROWSER_ENABLED=false`。
|
||||
|
||||
**SECRET_MASTER_KEY** 生成一次后不再更换:站点 Cookie、下载器密码、模型 API Key 都用它加密存在 `secret` 表。
|
||||
把它存进密码管理器或离线介质,**和数据库备份分开保管**;备份里不含它,丢了它备份中的凭证无法解密。
|
||||
|
||||
@@ -73,6 +91,7 @@ docker compose logs app | grep 'setup token'
|
||||
| `/media/...` | 用户 bind mount | 媒体库根:控制台 设置 → 媒体库根 登记的 `localPath` | 需对 `PUID:PGID` 可读写;**不会被自动 chown** |
|
||||
| `/downloads/...` | 用户 bind mount | 下载缓存:路径映射的 local 前缀(下载器报告的 remote 路径 → 容器内路径) | 同上 |
|
||||
| `/var/lib/postgresql/data`(postgres) | 命名卷 `umt-pg-data` | 数据库 | — |
|
||||
| `/root/.camofox`(camofox) | 命名卷 `umt-camofox` | 浏览器登录状态(`profiles/`,按站点/账号)、上游 cookies/uploads/traces 目录 | — |
|
||||
|
||||
- 媒体目录在 `docker-compose.yml` 的 `app.volumes` 里取消注释或另写 `docker-compose.override.yml`:
|
||||
|
||||
@@ -110,6 +129,13 @@ docker compose logs app | grep 'setup token'
|
||||
| `ORGANIZE_WALK_MAX_FILES` | `5000` | 手动按路径整理时递归遍历的文件数上限;dryRun 超出返回 `truncated: true`,正式执行拒绝 |
|
||||
| `BACKUP_KEEP` | `14` | 备份保留份数,0 不轮转 |
|
||||
| `LOG_LEVEL` | `info` | pino 级别 |
|
||||
| `CAMOFOX_ACCESS_KEY` | 空 | camofox 访问凭据(camofox 与 app 共用);空 = 内网免认证 |
|
||||
| `BROWSER_PROVIDER_URL` | `http://camofox:9377` | app 登记的浏览器服务地址(`camofox-env`);接自有实例时改 |
|
||||
| `BROWSER_ACCESS_KEY` | `CAMOFOX_ACCESS_KEY` | 该服务的访问凭据,只在进程内使用,不入库 |
|
||||
| `BROWSER_ENABLED` | `true` | `false` = 停用 `camofox-env`,界面标"浏览器不可用" |
|
||||
| `CAMOFOX_REPLICAS` | `1` | `0` = 不起 camofox 容器 |
|
||||
| `CAMOFOX_SESSION_TIMEOUT_MS` / `CAMOFOX_BROWSER_IDLE_TIMEOUT_MS` | `1800000` / `300000` | 会话空闲回收(落盘登录状态)/ 浏览器空闲退出 |
|
||||
| `CAMOFOX_MAX_SESSIONS` / `CAMOFOX_MEM_LIMIT` | `8` / `2g` | 最多并发会话 / 容器内存上限 |
|
||||
|
||||
## 6. 升级
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
`BrowserProvider` 合同(`provider.ts`)及其实现:业务侧访问浏览器服务只走这套接口。产品浏览器能力对接用户已有的 camofox-browser;**Playwright 只用于前端截图测试,不是产品浏览器能力**。设计说明见 `docs/design/browser-provider.md`。
|
||||
|
||||
- `camofox/`:camofox-browser 适配器。按 jo-inc/camofox-browser master `openapi.json`(1.17.0)与 README 实现,`Authorization: Bearer <CAMOFOX_ACCESS_KEY>`,凭据每次请求经 `BrowserSecretResolver` 解出,不缓存、不入日志。camofox `userId` = 本项目 `profileKey`(站点/账号隔离),`sessionKey` = `browser_session.id`。
|
||||
- `camofox/`:camofox-browser 适配器。按 jo-inc/camofox-browser v1.17.0 的 `openapi.json`、README 与 server.js 实现,2026-09-28 对真实容器逐项核验(`docs/runbook/browser.md` §7;核验表 `camofox/capabilities.ts` 的 `CAMOFOX_VERIFIED_RELEASES`),`Authorization: Bearer <CAMOFOX_ACCESS_KEY>`,凭据每次请求经 `BrowserSecretResolver` 解出,不缓存、不入日志。camofox `userId` = 本项目 `profileKey`(站点/账号隔离),`sessionKey` = `browser_session.id`。
|
||||
- `none.ts`:默认实现(未配置浏览器),能力全关,动作报 `unavailable`。
|
||||
- `redact.ts`:`redactSnapshot` / `redactBrowserUrl`,快照与地址进入模型前遮蔽密码框值、Cookie 值、含 passkey/token 的 URL。
|
||||
- `profile-lock.ts`:同一 profile 串行锁(进程内内存实现;多进程部署换持久化实现,接口不变)。
|
||||
- `budget.ts`:导航域名白名单(含子域),非 http(s) 拒绝。
|
||||
|
||||
## 已实现(C1-E,仅对 mock 与上游文档验证)
|
||||
## 已实现(C1-E;BR-1 对 v1.17.0 真机核验并修正实测差异)
|
||||
|
||||
- 会话:`openSession`(`POST /tabs`)、`closeSession`(`DELETE /sessions/{userId}`)、`listSessions`(内存镜像;服务层以数据库为准)、`restoreSession`(重启后按 DB 恢复,旧引用失效)。
|
||||
- 标签页:`openTab`、`closeTab`(`DELETE /tabs/{id}`)、`reconcileTabs`(`GET /tabs?userId`,上游已回收的标 `lost`)。
|
||||
|
||||
@@ -10,7 +10,10 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
BrowserError,
|
||||
type BrowserLogger,
|
||||
CAMOFOX_ENDPOINTS,
|
||||
CAMOFOX_VERIFIED_RELEASES,
|
||||
type CamofoxConfig,
|
||||
camofoxCapabilitiesFromOpenApi,
|
||||
camofoxStaticCapabilities,
|
||||
classifyHttpStatus,
|
||||
createCamofoxBrowserProvider,
|
||||
@@ -89,7 +92,7 @@ describe("能力声明", () => {
|
||||
expect(c.navigate).toMatchObject({ supported: true, verified: false, source: "static" });
|
||||
expect(c.snapshot).toMatchObject({ supported: true, verified: true });
|
||||
expect(c.interact).toMatchObject({ supported: true, verified: true });
|
||||
expect(c.interact.note).toContain("待核验");
|
||||
expect(c.interact.note).toContain("evaluate");
|
||||
expect(c.takeover).toMatchObject({ supported: true, verified: false });
|
||||
expect(c.upstreamVersion).toBeNull();
|
||||
});
|
||||
@@ -97,13 +100,31 @@ describe("能力声明", () => {
|
||||
it("探测 /openapi.json:读取上游版本,缺端点的能力降级", async () => {
|
||||
const { provider } = await setup({ omitPaths: ["get /tabs/{tabId}/snapshot"] });
|
||||
const c = await provider.capabilities({ probe: true });
|
||||
expect(c.upstreamVersion).toBe("1.17.0");
|
||||
expect(c.upstreamVersion).toBe("1.17.0-mock");
|
||||
expect(c.probedAt).not.toBeNull();
|
||||
expect(c.navigate).toMatchObject({ supported: true, source: "probed", verified: false });
|
||||
expect(c.snapshot).toMatchObject({ supported: false, source: "probed" });
|
||||
expect(c.snapshot.note).toContain("/tabs/{tabId}/snapshot");
|
||||
});
|
||||
|
||||
it("真机核验表:探测到已核验的上游版本才置 verified;配置可逐项撤销;其它版本不继承", () => {
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
for (const e of Object.values(CAMOFOX_ENDPOINTS).flat()) {
|
||||
const [m, p] = e.split(" ");
|
||||
paths[p!] = { ...paths[p!], [m!]: {} };
|
||||
}
|
||||
const doc = (version: string) => ({ info: { version }, paths });
|
||||
const ok = camofoxCapabilitiesFromOpenApi(doc("1.17.0"));
|
||||
expect(Object.keys(CAMOFOX_VERIFIED_RELEASES)).toContain("1.17.0");
|
||||
for (const k of CAMOFOX_VERIFIED_RELEASES["1.17.0"]!.keys)
|
||||
expect(ok[k]).toMatchObject({ supported: true, verified: true });
|
||||
expect(camofoxCapabilitiesFromOpenApi(doc("1.17.0"), { takeover: false }).takeover.verified).toBe(false);
|
||||
const other = camofoxCapabilitiesFromOpenApi(doc("1.18.0"));
|
||||
expect(other.snapshot.verified).toBe(false);
|
||||
// 静态声明不知道上游版本:一律未核验
|
||||
expect(camofoxStaticCapabilities().snapshot.verified).toBe(false);
|
||||
});
|
||||
|
||||
it("none 实现:能力全关,动作报 unavailable", async () => {
|
||||
const none = createNoneBrowserProvider();
|
||||
const c = await none.capabilities();
|
||||
@@ -300,6 +321,7 @@ describe("错误分类", () => {
|
||||
expect(classifyHttpStatus(401)).toBe("auth");
|
||||
expect(classifyHttpStatus(403)).toBe("auth");
|
||||
expect(classifyHttpStatus(404)).toBe("not_found");
|
||||
expect(classifyHttpStatus(410)).toBe("not_found");
|
||||
expect(classifyHttpStatus(409)).toBe("bad_request");
|
||||
expect(classifyHttpStatus(429)).toBe("rate_limited");
|
||||
expect(classifyHttpStatus(500)).toBe("upstream");
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { NavigationBudget } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BrowserError, createCamofoxBrowserProvider, memoryDownloadSink, staticBrowserSecrets } from "./index.ts";
|
||||
|
||||
// BR-1:对照 camofox-browser v1.17.0 真实容器(2026-09-28)记下的请求/响应形状,锁住适配器的实测差异修正。
|
||||
// 响应体取自真机核验输出(docs/runbook/browser.md §7),只把值换成测试常量。
|
||||
|
||||
const PROVIDER = "11111111-1111-4111-8111-111111111111";
|
||||
const SITE = "22222222-2222-4222-8222-222222222222";
|
||||
const ORIGIN = "http://mocknexus:8080";
|
||||
const PASSWORD = "mock-password";
|
||||
const budget: NavigationBudget = { domainAllowlist: ["mocknexus"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 };
|
||||
|
||||
type Call = { method: string; path: string; query: Record<string, string>; body: Record<string, unknown> | null };
|
||||
type Reply = { status?: number; json?: unknown; bytes?: Uint8Array; type?: string };
|
||||
|
||||
function realUpstream(handler: (c: Call) => Reply | undefined) {
|
||||
const calls: Call[] = [];
|
||||
const fetchImpl = (async (input: URL | string, init?: RequestInit) => {
|
||||
const u = new URL(String(input));
|
||||
const call: Call = {
|
||||
method: init?.method ?? "GET",
|
||||
path: u.pathname,
|
||||
query: Object.fromEntries(u.searchParams),
|
||||
body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : null,
|
||||
};
|
||||
calls.push(call);
|
||||
const r =
|
||||
handler(call) ??
|
||||
(call.path === "/tabs" && call.method === "POST"
|
||||
? { json: { tabId: "t-1", url: `${ORIGIN}/login.php`, httpStatus: 200, navigationOk: true } }
|
||||
: { json: { ok: true } });
|
||||
if (r.bytes)
|
||||
return new Response(r.bytes as Uint8Array<ArrayBuffer>, {
|
||||
status: r.status ?? 200,
|
||||
headers: { "content-type": r.type ?? "image/png" },
|
||||
});
|
||||
return new Response(JSON.stringify(r.json ?? {}), {
|
||||
status: r.status ?? 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
async function open(handler: (c: Call) => Reply | undefined) {
|
||||
const up = realUpstream(handler);
|
||||
const sink = memoryDownloadSink();
|
||||
const provider = createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: "http://camofox:9377",
|
||||
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: "k" }),
|
||||
fetch: up.fetchImpl,
|
||||
downloadSink: sink,
|
||||
downloadPollMs: 5,
|
||||
});
|
||||
const { session } = await provider.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: null,
|
||||
budget,
|
||||
initialUrl: `${ORIGIN}/login.php`,
|
||||
});
|
||||
return { ...up, provider, sink, ref: { sessionId: session.id } };
|
||||
}
|
||||
|
||||
const ARIA_LOGIN = `- paragraph: 你需要启用cookies才能登录\n- textbox [e1]: mockuser\n- textbox [e2]: ${PASSWORD}\n- button "登录" [e3]`;
|
||||
|
||||
describe("camofox 1.17.0 实测形状", () => {
|
||||
it("ARIA 快照里密码框与普通文本框同形:经 evaluate 取密码框当前值后按字面量遮蔽", async () => {
|
||||
const x = await open((c) => {
|
||||
if (c.path.endsWith("/snapshot"))
|
||||
return { json: { url: `${ORIGIN}/login.php`, snapshot: ARIA_LOGIN, refsCount: 3 } };
|
||||
if (c.path.endsWith("/evaluate")) return { json: { ok: true, result: [PASSWORD] } };
|
||||
});
|
||||
const s = await x.provider.snapshot(x.ref);
|
||||
expect(s.text).not.toContain(PASSWORD);
|
||||
expect(s.text).toContain("- textbox [e1]: mockuser");
|
||||
expect(s.redactions).toEqual([{ kind: "secret_value", count: 1 }]);
|
||||
});
|
||||
|
||||
it("evaluate 不可用时遮蔽全部输入框的值(宁可多遮)", async () => {
|
||||
const x = await open((c) => {
|
||||
if (c.path.endsWith("/snapshot")) return { json: { url: `${ORIGIN}/login.php`, snapshot: ARIA_LOGIN } };
|
||||
if (c.path.endsWith("/evaluate")) return { status: 500, json: { error: "x" } };
|
||||
});
|
||||
const s = await x.provider.snapshot(x.ref);
|
||||
expect(s.text).not.toContain(PASSWORD);
|
||||
expect(s.text).not.toContain("mockuser");
|
||||
expect(s.text).toContain('- button "登录" [e3]');
|
||||
});
|
||||
|
||||
it("截图直接返回 image/png 二进制", async () => {
|
||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
|
||||
const x = await open((c) => (c.path.endsWith("/screenshot") ? { bytes: png } : undefined));
|
||||
const shot = await x.provider.screenshot(x.ref);
|
||||
expect(shot.mimeType).toBe("image/png");
|
||||
expect([...shot.data]).toEqual([...png]);
|
||||
});
|
||||
|
||||
it("坐标点击:click 不接受 {x, y},先 evaluate 标记该点元素,再按选择器点击", async () => {
|
||||
const x = await open((c) => (c.path.endsWith("/evaluate") ? { json: { ok: true, result: true } } : undefined));
|
||||
await x.provider.acquireControl({
|
||||
sessionId: x.ref.sessionId,
|
||||
holderKind: "human",
|
||||
holder: "u",
|
||||
reason: "t",
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
await x.provider.click({ ...x.ref, as: "human" }, { x: 98.4, y: 71 });
|
||||
const ev = x.calls.find((c) => c.path.endsWith("/evaluate"))!;
|
||||
expect(String(ev.body?.expression)).toContain("elementFromPoint(98, 71)");
|
||||
const click = x.calls.find((c) => c.path.endsWith("/click"))!;
|
||||
expect(click.body).toMatchObject({
|
||||
userId: expect.any(String),
|
||||
selector: expect.stringMatching(/^\[data-umt-pt="/),
|
||||
});
|
||||
expect(click.body).not.toHaveProperty("x");
|
||||
});
|
||||
|
||||
it("坐标处没有元素 → bad_request,不发 click", async () => {
|
||||
const x = await open((c) => (c.path.endsWith("/evaluate") ? { json: { ok: true, result: false } } : undefined));
|
||||
await x.provider.acquireControl({
|
||||
sessionId: x.ref.sessionId,
|
||||
holderKind: "human",
|
||||
holder: "u",
|
||||
reason: "t",
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
await expect(x.provider.click({ ...x.ref, as: "human" }, { x: 5000, y: 5000 })).rejects.toMatchObject({
|
||||
kind: "bad_request",
|
||||
});
|
||||
expect(x.calls.some((c) => c.path.endsWith("/click"))).toBe(false);
|
||||
});
|
||||
|
||||
it("无目标输入 = 当前焦点:mode=keyboard(fill 模式必须带 ref/selector)", async () => {
|
||||
const x = await open(() => undefined);
|
||||
await x.provider.type(x.ref, null, { kind: "text", text: "abc" });
|
||||
await x.provider.type(x.ref, { selector: "#u" }, { kind: "text", text: "abc" });
|
||||
const types = x.calls.filter((c) => c.path.endsWith("/type"));
|
||||
expect(types[0]!.body).toMatchObject({ mode: "keyboard", text: "abc" });
|
||||
expect(types[1]!.body).not.toHaveProperty("mode");
|
||||
});
|
||||
|
||||
it("downloads 字段:suggestedFilename / bytes / dataBase64;failure 与 dataSkipped 分别报错", async () => {
|
||||
const bytes = new TextEncoder().encode("d8:announce0:e");
|
||||
const b64 = btoa(String.fromCharCode(...bytes));
|
||||
const list = [
|
||||
{
|
||||
id: "d1",
|
||||
url: `${ORIGIN}/download.php?id=1&passkey=abcdef0123456789abcdef0123456789`,
|
||||
suggestedFilename: "[Mock].x.torrent",
|
||||
mimeType: "application/x-bittorrent",
|
||||
bytes: bytes.byteLength,
|
||||
createdAt: 1,
|
||||
failure: null,
|
||||
dataBase64: b64,
|
||||
},
|
||||
];
|
||||
const x = await open((c) =>
|
||||
c.path.endsWith("/downloads") ? { json: { tabId: "t-1", downloads: list } } : undefined,
|
||||
);
|
||||
const a = await x.provider.waitForDownload(x.ref, { timeoutMs: 200, maxBytes: 1024 });
|
||||
expect(a).toMatchObject({ filename: "[Mock].x.torrent", sizeBytes: bytes.byteLength });
|
||||
expect(a.sourceUrl).not.toContain("abcdef0123456789");
|
||||
|
||||
const y = await open((c) =>
|
||||
c.path.endsWith("/downloads")
|
||||
? { json: { downloads: [{ ...list[0], id: "d2", dataBase64: undefined, dataSkipped: "max_bytes_exceeded" }] } }
|
||||
: undefined,
|
||||
);
|
||||
await expect(y.provider.waitForDownload(y.ref, { timeoutMs: 200, maxBytes: 4 })).rejects.toMatchObject({
|
||||
kind: "budget_exceeded",
|
||||
});
|
||||
const z = await open((c) =>
|
||||
c.path.endsWith("/downloads")
|
||||
? { json: { downloads: [{ ...list[0], id: "d3", dataBase64: undefined, failure: "canceled" }] } }
|
||||
: undefined,
|
||||
);
|
||||
await expect(z.provider.waitForDownload(z.ref, { timeoutMs: 200, maxBytes: 1024 })).rejects.toMatchObject({
|
||||
kind: "upstream",
|
||||
});
|
||||
});
|
||||
|
||||
it("浏览器重启后旧标签页返回 410 browser_restarted:按 not_found 处理", async () => {
|
||||
const x = await open((c) =>
|
||||
c.path.endsWith("/snapshot")
|
||||
? { status: 410, json: { error: "Tab no longer exists (browser was restarted).", code: "browser_restarted" } }
|
||||
: undefined,
|
||||
);
|
||||
const err = await x.provider.snapshot(x.ref).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BrowserError);
|
||||
expect(err).toMatchObject({ kind: "not_found", httpStatus: 410 });
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { BrowserCapabilities, BrowserCapabilityFlag, BrowserCapabilityKey } from "@umt/contracts";
|
||||
|
||||
/**
|
||||
* camofox-browser 能力声明。依据:jo-inc/camofox-browser master 的 openapi.json(info.version 1.17.0)与 README,
|
||||
* 2026-09-27 取得;用户实际部署可能是其他 fork/版本,运行时以 /openapi.json 探测结果为准(REQ §4.7.4)。
|
||||
* camofox-browser 能力声明。依据:jo-inc/camofox-browser v1.17.0(commit 389c996)的 openapi.json、README 与 server.js;
|
||||
* 用户实际部署可能是其他 fork/版本,运行时以 /openapi.json 探测结果为准(REQ §4.7.4)。
|
||||
*
|
||||
* supported = 本适配器当前能对该上游提供的能力(上游有端点但适配器尚未实现的记 false 并注明 M2);
|
||||
* verified 只能由真实实例原型验证结果置 true(配置注入),探测永远不会把它设为 true。
|
||||
* supported = 本适配器当前能对该上游提供的能力(上游缺端点的记 false 并注明);
|
||||
* verified 只来自人工真机核验:CAMOFOX_VERIFIED_RELEASES(按上游版本登记的核验记录,只在探测到的 info.version
|
||||
* 完全一致时生效)与 browser_provider.config.verified(逐项覆盖,可写 false 撤销)。探测本身永远不会把它设为 true。
|
||||
*/
|
||||
|
||||
/** 每项能力依赖的上游端点(OpenAPI paths 键,小写方法) */
|
||||
@@ -15,11 +16,22 @@ export const CAMOFOX_ENDPOINTS: Record<BrowserCapabilityKey, readonly string[]>
|
||||
navigate: ["post /tabs/{tabId}/navigate"],
|
||||
snapshot: ["get /tabs/{tabId}/snapshot"],
|
||||
screenshot: ["get /tabs/{tabId}/screenshot"],
|
||||
interact: ["post /tabs/{tabId}/click", "post /tabs/{tabId}/type", "post /tabs/{tabId}/select"],
|
||||
// 坐标点击经 evaluate(elementFromPoint)转成选择器:1.17.0 的 click 不接受 {x, y}
|
||||
interact: [
|
||||
"post /tabs/{tabId}/click",
|
||||
"post /tabs/{tabId}/type",
|
||||
"post /tabs/{tabId}/select",
|
||||
"post /tabs/{tabId}/evaluate",
|
||||
],
|
||||
extract: ["post /tabs/{tabId}/extract"],
|
||||
downloadCapture: ["get /tabs/{tabId}/downloads"],
|
||||
// 原站视图接管 = 同一会话的定时截图 + 点击/输入映射(不另起浏览器);VNC 插件未接入
|
||||
takeover: ["get /tabs/{tabId}/screenshot", "post /tabs/{tabId}/click", "post /tabs/{tabId}/type"],
|
||||
takeover: [
|
||||
"get /tabs/{tabId}/screenshot",
|
||||
"post /tabs/{tabId}/click",
|
||||
"post /tabs/{tabId}/type",
|
||||
"post /tabs/{tabId}/evaluate",
|
||||
],
|
||||
// 持久化插件默认开启,无需端点;storage_state 端点属插件,不在 OpenAPI 中
|
||||
persistentProfile: [],
|
||||
};
|
||||
@@ -40,15 +52,47 @@ const ADAPTER_IMPLEMENTED: ReadonlySet<BrowserCapabilityKey> = new Set([
|
||||
|
||||
const STATIC_NOTES: Partial<Record<BrowserCapabilityKey, string>> = {
|
||||
sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;会话以数据库为准",
|
||||
tabs: "新建/关闭/对账(GET /tabs?userId,上游回收的标 lost)",
|
||||
snapshot: "可访问性树文本 + eN 元素引用,支持 offset 分页",
|
||||
interact: "click/type/select + scroll/press(后两者不在 1.17.0 OpenAPI,待核验);坐标点击待核验",
|
||||
tabs: "新建/关闭/对账(GET /tabs?userId);重启或空闲回收后旧标签页 404/410,对账标 lost 后在同一会话开新标签页",
|
||||
snapshot: "ARIA 快照文本 + [eN] 元素引用,支持 offset 分页;密码框值经 evaluate 取值后遮蔽",
|
||||
interact: "click/type/select/scroll/press/后退/前进/刷新;坐标点击经 evaluate 定位元素,焦点输入用 mode=keyboard",
|
||||
extract: "POST /extract 需先 snapshot 建 ref 表(x-ref)",
|
||||
downloadCapture: "GET /downloads?includeData&consume&maxBytes(README;字段形状待核验)",
|
||||
takeover: "原站视图:同一会话定时截图 + 输入映射;坐标点击与无目标输入待核验,VNC 未接入",
|
||||
persistentProfile: "上游持久化插件默认保存 cookies+localStorage(~/.camofox/profiles),重启恢复待验证",
|
||||
downloadCapture: "GET /downloads?includeData&consume&maxBytes → dataBase64;点击下载链接后捕获",
|
||||
takeover: "原站视图:同一会话同一标签页的截图 + 坐标点击/焦点输入(API 层核验);VNC 插件未接入",
|
||||
persistentProfile:
|
||||
"cookies+localStorage 按 profile 落盘:会话空闲回收、关闭与 SIGTERM 停止时写入;SIGKILL/OOM 丢上次落盘后的新登录",
|
||||
};
|
||||
|
||||
/**
|
||||
* 真机核验记录(docs/runbook/browser.md「真机核验」)。只登记核验通过的能力;新版本必须重新核验后再加一行。
|
||||
*/
|
||||
export const CAMOFOX_VERIFIED_RELEASES: Readonly<
|
||||
Record<string, { verifiedAt: string; source: string; keys: readonly BrowserCapabilityKey[] }>
|
||||
> = {
|
||||
"1.17.0": {
|
||||
verifiedAt: "2026-09-28",
|
||||
source: "jo-inc/camofox-browser v1.17.0 (389c996),docker/camofox/Dockerfile 构建",
|
||||
keys: [
|
||||
"sessions",
|
||||
"tabs",
|
||||
"navigate",
|
||||
"snapshot",
|
||||
"screenshot",
|
||||
"interact",
|
||||
"extract",
|
||||
"downloadCapture",
|
||||
"takeover",
|
||||
"persistentProfile",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** 某上游版本的已核验能力 + 配置覆盖(配置里显式 false 可撤销) */
|
||||
export function verifiedFor(version: string | null, config: VerifiedMap = {}): VerifiedMap {
|
||||
const rec = version ? CAMOFOX_VERIFIED_RELEASES[version] : undefined;
|
||||
const base: VerifiedMap = Object.fromEntries((rec?.keys ?? []).map((k) => [k, true]));
|
||||
return { ...base, ...config };
|
||||
}
|
||||
|
||||
export type VerifiedMap = Partial<Record<BrowserCapabilityKey, boolean>>;
|
||||
|
||||
const KEYS = Object.keys(CAMOFOX_ENDPOINTS) as BrowserCapabilityKey[];
|
||||
@@ -84,16 +128,18 @@ export function camofoxCapabilitiesFromOpenApi(
|
||||
// 路径参数名不同的 fork 也视为同一端点
|
||||
const norm = (s: string) => s.replace(/\{[^}]+\}/g, "{}");
|
||||
const haveNorm = new Set([...have].map(norm));
|
||||
const version = typeof d.info?.version === "string" ? d.info.version : null;
|
||||
const effective = verifiedFor(version, verified);
|
||||
const flags = Object.fromEntries(
|
||||
KEYS.map((k) => {
|
||||
const missing = CAMOFOX_ENDPOINTS[k].filter((e) => !haveNorm.has(norm(e)));
|
||||
if (missing.length > 0) return [k, flag(k, false, "probed", verified, `上游缺少端点:${missing.join(", ")}`)];
|
||||
return [k, flag(k, ADAPTER_IMPLEMENTED.has(k), CAMOFOX_ENDPOINTS[k].length > 0 ? "probed" : "static", verified)];
|
||||
if (missing.length > 0) return [k, flag(k, false, "probed", effective, `上游缺少端点:${missing.join(", ")}`)];
|
||||
return [k, flag(k, ADAPTER_IMPLEMENTED.has(k), CAMOFOX_ENDPOINTS[k].length > 0 ? "probed" : "static", effective)];
|
||||
}),
|
||||
) as Record<BrowserCapabilityKey, BrowserCapabilityFlag>;
|
||||
return {
|
||||
...flags,
|
||||
upstreamVersion: typeof d.info?.version === "string" ? d.info.version : null,
|
||||
upstreamVersion: version,
|
||||
probedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -127,6 +127,11 @@ type Json = Record<string, unknown>;
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
||||
const int = (v: unknown) => (typeof v === "number" && Number.isInteger(v) ? v : undefined);
|
||||
|
||||
/** ARIA 快照里带值的输入框:`- textbox [e2]: 值` / `- textbox "标签" [e2]: 值` */
|
||||
const TEXTBOX_WITH_VALUE = /-\s*(?:textbox|searchbox)\b[^\n]*\[e\d+\][^\n]*?:\s*\S/;
|
||||
/** 兜底:遮蔽全部输入框的值(只替换冒号后的值部分) */
|
||||
const TEXTBOX_VALUE_ANY = /(?<=-\s*(?:textbox|searchbox)\b[^\n]*\[e\d+\][^\n]*?:\s*)\S[^\n]*/g;
|
||||
|
||||
const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i;
|
||||
|
||||
export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
@@ -636,7 +641,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
body.structure && typeof body.structure === "object" && !Array.isArray(body.structure)
|
||||
? (body.structure as Json)
|
||||
: null;
|
||||
const red = redactSnapshot({ text, url: rawUrl, structure }, { extra: opts.extraRedactions ?? [] });
|
||||
const pw = await this.passwordRedactions(s, tab, text);
|
||||
const red = redactSnapshot({ text, url: rawUrl, structure }, { extra: [...(opts.extraRedactions ?? []), ...pw] });
|
||||
this.touch(s, rawUrl || s.rawUrl);
|
||||
const at = s.session.lastActivityAt;
|
||||
tab.lastSnapshotAt = at;
|
||||
@@ -660,6 +666,34 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.17.0 实测:快照是 Playwright ARIA 快照(`- textbox [e2]: 值`),密码框与普通文本框同为 textbox 且带明文值,
|
||||
* 按标签名的规则抓不到。快照里出现带值的文本框时,经 evaluate 取页面上 type=password 输入框的当前值,按字面量遮蔽;
|
||||
* evaluate 失败时退而遮蔽全部文本框的值(宁可多遮)。
|
||||
*/
|
||||
private async passwordRedactions(s: SessionState, tab: BrowserTab, text: string | null): Promise<RegExp[]> {
|
||||
if (text !== null && !TEXTBOX_WITH_VALUE.test(text)) return [];
|
||||
const esc = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
try {
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
|
||||
const res = await this.request("POST", path, {
|
||||
body: {
|
||||
userId: s.session.profileKey,
|
||||
expression:
|
||||
"Array.from(document.querySelectorAll('input[type=password]')).map((e) => e.value).filter((v) => v)",
|
||||
},
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const out = await this.json(res, path);
|
||||
const values = Array.isArray(out.result) ? out.result.filter((v): v is string => typeof v === "string") : [];
|
||||
// 过短的值按字面量遮蔽会误伤正文:改为遮蔽全部输入框的值
|
||||
if (values.some((v) => v.length < 4)) return [TEXTBOX_VALUE_ANY, ...values.map((v) => new RegExp(esc(v), "g"))];
|
||||
return values.map((v) => new RegExp(esc(v), "g"));
|
||||
} catch {
|
||||
return [TEXTBOX_VALUE_ANY];
|
||||
}
|
||||
}
|
||||
|
||||
async screenshot(ref: TabRef, opts: { maxBytes?: number } = {}): Promise<BrowserScreenshot> {
|
||||
const s = this.state(ref.sessionId);
|
||||
return this.withProfile(s, async () => {
|
||||
@@ -742,7 +776,9 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
|
||||
/** 视口坐标 → 唯一属性选择器(POST /tabs/{id}/evaluate + elementFromPoint);坐标处无元素报 bad_request */
|
||||
private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise<string> {
|
||||
const nonce = this.newId().replace(/[^a-z0-9]/gi, "").slice(0, 16);
|
||||
const nonce = this.newId()
|
||||
.replace(/[^a-z0-9]/gi, "")
|
||||
.slice(0, 16);
|
||||
const expression = `(() => { const e = document.elementFromPoint(${x}, ${y}); if (!e) return false;
|
||||
for (const o of document.querySelectorAll("[data-umt-pt]")) o.removeAttribute("data-umt-pt");
|
||||
e.setAttribute("data-umt-pt", "${nonce}"); return true; })()`;
|
||||
@@ -864,7 +900,9 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
});
|
||||
const body = await this.json(res, path);
|
||||
const data = (body.data && typeof body.data === "object" && !Array.isArray(body.data) ? body.data : body) as Json;
|
||||
const red = redactSnapshot({ text: "", url: "", structure: data });
|
||||
// x-ref 可能指向密码框:同快照一样按页面上密码框的当前值遮蔽
|
||||
const pw = Object.keys(data).length ? await this.passwordRedactions(s, tab, null) : [];
|
||||
const red = redactSnapshot({ text: "", url: "", structure: data }, { extra: pw });
|
||||
this.touch(s);
|
||||
return (red.structure ?? {}) as Record<string, unknown>;
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@ export const notImplemented = (method: string): BrowserError =>
|
||||
/** HTTP 状态 → 错误种类(camofox OpenAPI 1.17.0 的响应码:400/401/403/404/409/422/429/500/503) */
|
||||
export function classifyHttpStatus(status: number): BrowserErrorKind {
|
||||
if (status === 401 || status === 403) return "auth";
|
||||
if (status === 404) return "not_found";
|
||||
// 410:camofox 1.17.0 对"浏览器重启前存在过的标签页"返回 410 browser_restarted(实测),与 404 同样按标签页已不存在处理
|
||||
if (status === 404 || status === 410) return "not_found";
|
||||
if (status === 408) return "timeout";
|
||||
if (status === 429) return "rate_limited";
|
||||
if (status === 503) return "unavailable";
|
||||
|
||||
@@ -2184,6 +2184,14 @@ export const Api = {
|
||||
body: browser.BrowserProviderUpdate,
|
||||
response: browser.BrowserProviderView,
|
||||
}),
|
||||
deleteProvider: ep({
|
||||
method: "delete",
|
||||
path: "/browser/providers/{id}",
|
||||
scope: "admin",
|
||||
summary: "删除浏览器服务(有未关闭会话或由环境变量登记时拒绝)",
|
||||
params: browser.BrowserProviderParams,
|
||||
response: z.object({ id: z.string() }),
|
||||
}),
|
||||
probeProvider: ep({
|
||||
method: "post",
|
||||
path: "/browser/providers/{id}/probe",
|
||||
|
||||
@@ -553,9 +553,12 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
||||
{
|
||||
id: "sites",
|
||||
label: "站点默认",
|
||||
description: "新站点的限流、快照周期、UA 和代理;站点和凭证在站点页管理",
|
||||
description: "新站点的限流、快照周期、UA 和代理,以及原站视图用的浏览器服务;站点和凭证在站点页管理",
|
||||
legacyTabs: [{ tab: "site-credentials" }],
|
||||
anchors: [{ id: "defaults", label: "站点默认" }],
|
||||
anchors: [
|
||||
{ id: "defaults", label: "站点默认" },
|
||||
{ id: "browser", label: "浏览器服务" },
|
||||
],
|
||||
fields: [
|
||||
"sites.defaultRateLimit",
|
||||
"sites.defaultSnapshotIntervalSec",
|
||||
|
||||
@@ -452,7 +452,11 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
paths[p] = ops;
|
||||
}
|
||||
return {
|
||||
json: { openapi: "3.0.3", info: { title: "camofox-browser", version: opts.version ?? "1.17.0" }, paths },
|
||||
json: {
|
||||
openapi: "3.0.3",
|
||||
info: { title: "camofox-browser", version: opts.version ?? "1.17.0-mock" },
|
||||
paths,
|
||||
},
|
||||
};
|
||||
},
|
||||
],
|
||||
@@ -574,8 +578,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
/^\/tabs\/([^/]+)\/click$/,
|
||||
action("click", (t, b) => {
|
||||
// 1.17.0 实测:只认 ref / selector,坐标点击由适配器先 evaluate 打标记
|
||||
if (typeof b.ref !== "string" && typeof b.selector !== "string")
|
||||
return err(400, "ref or selector required");
|
||||
if (typeof b.ref !== "string" && typeof b.selector !== "string") return err(400, "ref or selector required");
|
||||
const line = findLine(t, b);
|
||||
if (!line) return typeof b.ref === "string" ? err(422, "Unknown ref") : undefined;
|
||||
if (line.role.startsWith("textbox")) t.focus = line.ref;
|
||||
|
||||
@@ -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