Contracts for browser API and browser.* agent capabilities; adapter implements close/list/restore sessions, tabs + reconcile, history, click/type/select/scroll/press, extract, waitForDownload (staged via DownloadSink, sha256), stale-ref guard and automation/human actor check. testkit mock camofox gains an interactive site (login redirect, captcha, click-driven page changes, downloads, PNG screenshots). Co-Authored-By: Claude Opus 5.5 <[email protected]>
976 lines
39 KiB
TypeScript
976 lines
39 KiB
TypeScript
/**
|
|
* camofox-browser 适配器(BrowserProvider 首个实现)。
|
|
* 对照:jo-inc/camofox-browser master openapi.json(info.version 1.17.0)与 README,仅读文档,未连接真实实例。
|
|
*
|
|
* 上游模型与本项目合同的映射:
|
|
* - camofox userId = 一个隔离 BrowserContext(cookies/storage),本项目用 profileKey(站点/账号)充当;
|
|
* - camofox sessionKey = 标签组,本项目用 browser_session.id;
|
|
* - 上游没有"建会话"端点,首个 POST /tabs 隐式创建;会话由本适配器在内存登记,持久化由服务层写 browser_session。
|
|
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
|
|
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
|
|
*
|
|
* 已实现(仅对 mock 与上游文档验证):health、capabilities、会话开/关/列举、标签页增删与对账、navigate(白名单/超时/重试)、
|
|
* 后退/前进/刷新、snapshot(脱敏)、screenshot、click/type/select/scroll/press、extract、waitForDownload、
|
|
* 内存版控制权租约与串行锁。会话/标签/租约的持久化与对账由服务层(apps/server modules/browser)以数据库为准。
|
|
* 未核验的上游行为(坐标点击、无目标输入、downloads 字段形状)见 docs/runbook/browser.md。
|
|
*/
|
|
import type {
|
|
BrowserCapabilities,
|
|
BrowserControlLease,
|
|
BrowserHealth,
|
|
BrowserSession,
|
|
BrowserSnapshot,
|
|
BrowserTab,
|
|
DownloadArtifact,
|
|
NavigationBudget,
|
|
} from "@umt/contracts";
|
|
import { assertNavigable, hostAllowed } from "../budget.ts";
|
|
import { BrowserError, classifyHttpStatus } from "../errors.ts";
|
|
import { InMemoryProfileLock, type ProfileLock } from "../profile-lock.ts";
|
|
import {
|
|
type AcquireControlRequest,
|
|
type ActorKind,
|
|
type BrowserLogger,
|
|
type BrowserProvider,
|
|
type BrowserScreenshot,
|
|
type BrowserSecretResolver,
|
|
type BrowserSessionInfo,
|
|
type ElementTarget,
|
|
type NavigationResult,
|
|
type OpenSessionRequest,
|
|
type OpenSessionResult,
|
|
profileKeyFor,
|
|
type RestoreSessionState,
|
|
type SnapshotOptions,
|
|
type TabRef,
|
|
type TypeInput,
|
|
} from "../provider.ts";
|
|
import { redactBrowserUrl, redactSnapshot, urlHasCredential } from "../redact.ts";
|
|
import { camofoxCapabilitiesFromOpenApi, camofoxStaticCapabilities, type VerifiedMap } from "./capabilities.ts";
|
|
|
|
export interface CamofoxConfig {
|
|
/** browser_provider.id */
|
|
providerId: string;
|
|
/** 例 http://camofox:9377 */
|
|
baseUrl: string;
|
|
secrets: BrowserSecretResolver;
|
|
/** 单次请求默认超时;导航用 budget.timeoutMs */
|
|
timeoutMs?: number;
|
|
/** 同一 profile 串行锁;缺省进程内内存锁 */
|
|
lock?: ProfileLock;
|
|
lockWaitMs?: number;
|
|
/** 已对真实实例验证通过的能力(来自原型验证记录);缺省全部未验证 */
|
|
verified?: VerifiedMap;
|
|
/** 截图/快照默认字节上限(未传 budget 时) */
|
|
defaultMaxBytes?: number;
|
|
/** 下载产物落点(服务层写 DATA_DIR/tmp);缺省进程内内存(memoryDownloadSink) */
|
|
downloadSink?: DownloadSink;
|
|
/** waitForDownload 轮询间隔 */
|
|
downloadPollMs?: number;
|
|
fetch?: typeof fetch;
|
|
logger?: BrowserLogger;
|
|
now?: () => Date;
|
|
newId?: () => string;
|
|
}
|
|
|
|
interface SessionState {
|
|
session: BrowserSession;
|
|
tabs: Map<string, BrowserTab>;
|
|
currentTabId: string;
|
|
/** 未脱敏地址只留在内存,用于判断截图能否进入模型;不对外返回 */
|
|
rawUrl: string | null;
|
|
budget: NavigationBudget;
|
|
/** 最近一次快照之后没有导航/点击/交接:eN 引用可用 */
|
|
refsValid: boolean;
|
|
/** 已见过的下载(consume 失败时防重复) */
|
|
seenDownloads: Set<string>;
|
|
}
|
|
|
|
/** 下载产物的受控暂存:返回引用,字节不进合同/日志 */
|
|
export type DownloadSink = (
|
|
bytes: Uint8Array,
|
|
meta: { id: string; sessionId: string; filename: string; mimeType: string | null },
|
|
) => Promise<{ kind: "blob" | "secret"; ref: string }>;
|
|
|
|
/** 进程内内存暂存(测试/脚本);服务层换成 DATA_DIR/tmp 下的文件 */
|
|
export function memoryDownloadSink(): DownloadSink & { blobs: Map<string, Uint8Array> } {
|
|
const blobs = new Map<string, Uint8Array>();
|
|
const sink = (async (bytes, meta) => {
|
|
const ref = `mem:${meta.id}`;
|
|
blobs.set(ref, bytes);
|
|
return { kind: "blob" as const, ref };
|
|
}) as DownloadSink & { blobs: Map<string, Uint8Array> };
|
|
sink.blobs = blobs;
|
|
return sink;
|
|
}
|
|
|
|
export function isLoginUrl(raw: string | null): boolean {
|
|
if (!raw) return false;
|
|
try {
|
|
return LOGIN_PATH.test(new URL(raw).pathname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function b64decode(b64: string): Uint8Array {
|
|
return Uint8Array.from(atob(b64.replace(/^data:[^,]*,/, "").replace(/\s+/g, "")), (c) => c.charCodeAt(0));
|
|
}
|
|
|
|
async function sha256Hex(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("");
|
|
}
|
|
|
|
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);
|
|
|
|
const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i;
|
|
|
|
export class CamofoxBrowserProvider implements BrowserProvider {
|
|
readonly kind = "camofox" as const;
|
|
private readonly base: string;
|
|
private readonly fetchImpl: typeof fetch;
|
|
private readonly lock: ProfileLock;
|
|
private readonly sessions = new Map<string, SessionState>();
|
|
private readonly leases = new Map<string, BrowserControlLease>();
|
|
private readonly sink: DownloadSink;
|
|
private readonly now: () => Date;
|
|
private readonly newId: () => string;
|
|
|
|
constructor(private readonly cfg: CamofoxConfig) {
|
|
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
|
this.fetchImpl = cfg.fetch ?? fetch;
|
|
this.lock = cfg.lock ?? new InMemoryProfileLock(cfg.lockWaitMs);
|
|
this.now = cfg.now ?? (() => new Date());
|
|
this.newId = cfg.newId ?? (() => crypto.randomUUID());
|
|
this.sink = cfg.downloadSink ?? memoryDownloadSink();
|
|
}
|
|
|
|
// ---------- HTTP ----------
|
|
|
|
private async authHeader(): Promise<Record<string, string>> {
|
|
const key = await this.cfg.secrets.resolve({ kind: "provider_access_key", providerId: this.cfg.providerId });
|
|
return key ? { authorization: `Bearer ${key}` } : {};
|
|
}
|
|
|
|
private async request(
|
|
method: "GET" | "POST" | "DELETE",
|
|
path: string,
|
|
opts: { query?: Record<string, string | undefined>; body?: Json; timeoutMs?: number; auth?: boolean } = {},
|
|
): Promise<Response> {
|
|
const u = new URL(this.base + path);
|
|
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) u.searchParams.set(k, v);
|
|
const headers: Record<string, string> = {
|
|
accept: "application/json",
|
|
...(opts.auth === false ? {} : await this.authHeader()),
|
|
};
|
|
if (opts.body) headers["content-type"] = "application/json";
|
|
const timeoutMs = opts.timeoutMs ?? this.cfg.timeoutMs ?? 30_000;
|
|
const started = Date.now();
|
|
let res: Response;
|
|
try {
|
|
res = await this.fetchImpl(u, {
|
|
method,
|
|
headers,
|
|
...(opts.body ? { body: JSON.stringify(opts.body) } : {}),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
} catch (e) {
|
|
const name = (e as { name?: string })?.name;
|
|
const kind = name === "TimeoutError" || name === "AbortError" ? "timeout" : "network";
|
|
this.cfg.logger?.({ level: "warn", msg: `camofox ${kind}`, method, path, ms: Date.now() - started });
|
|
throw new BrowserError(
|
|
kind,
|
|
`camofox ${method} ${path} ${kind === "timeout" ? `超时(${timeoutMs}ms)` : "网络错误"}`,
|
|
);
|
|
}
|
|
this.cfg.logger?.({ level: "debug", msg: "camofox", method, path, status: res.status, ms: Date.now() - started });
|
|
if (!res.ok) {
|
|
await res.body?.cancel();
|
|
throw new BrowserError(classifyHttpStatus(res.status), `camofox ${method} ${path} → HTTP ${res.status}`, {
|
|
httpStatus: res.status,
|
|
});
|
|
}
|
|
return res;
|
|
}
|
|
|
|
private async json(res: Response, path: string): Promise<Json> {
|
|
try {
|
|
const v = (await res.json()) as unknown;
|
|
if (v && typeof v === "object" && !Array.isArray(v)) return v as Json;
|
|
} catch {
|
|
/* fallthrough */
|
|
}
|
|
throw new BrowserError("protocol", `camofox ${path} 返回非 JSON 对象`);
|
|
}
|
|
|
|
// ---------- 状态 ----------
|
|
|
|
private state(sessionId: string): SessionState {
|
|
const s = this.sessions.get(sessionId);
|
|
if (!s) throw new BrowserError("not_found", "会话不存在或已关闭");
|
|
return s;
|
|
}
|
|
|
|
private tabOf(s: SessionState, ref: TabRef): BrowserTab {
|
|
const t = s.tabs.get(ref.tabId ?? s.currentTabId);
|
|
if (t?.status !== "open") throw new BrowserError("not_found", "标签页不存在或已关闭");
|
|
return t;
|
|
}
|
|
|
|
/** 动作发起方必须与会话控制者一致:接管/等待/关闭中的会话拒绝自动化动作(REQ §4.7.3) */
|
|
private assertActor(s: SessionState, as: ActorKind = "automation"): void {
|
|
const c = s.session.controller;
|
|
if (c === as) return;
|
|
if (c === "closed") throw new BrowserError("control_conflict", "会话已关闭");
|
|
if (as === "automation")
|
|
throw new BrowserError(
|
|
"control_conflict",
|
|
c === "human" ? "会话正由人工接管,自动化动作已停止" : "会话在等待人工处理,自动化动作已停止",
|
|
);
|
|
throw new BrowserError("control_conflict", "先接管会话再操作");
|
|
}
|
|
|
|
/** 用 eN 引用前确认快照仍有效:导航、点击、交接之后旧引用一律作废,不重放 */
|
|
private assertTarget(s: SessionState, target: ElementTarget): Json {
|
|
if ("ref" in target) {
|
|
if (!s.refsValid) throw new BrowserError("stale_ref", "元素引用已失效(页面已变化或控制权已交接),先重新读取页面");
|
|
return { ref: target.ref };
|
|
}
|
|
if ("selector" in target) return { selector: target.selector };
|
|
if (!Number.isFinite(target.x) || !Number.isFinite(target.y) || target.x < 0 || target.y < 0)
|
|
throw new BrowserError("bad_request", "坐标无效");
|
|
return { x: Math.round(target.x), y: Math.round(target.y) };
|
|
}
|
|
|
|
private touch(s: SessionState, rawUrl?: string | null): void {
|
|
const at = this.now().toISOString();
|
|
if (rawUrl !== undefined) {
|
|
s.rawUrl = rawUrl;
|
|
s.session.currentUrl = rawUrl === null ? null : redactBrowserUrl(rawUrl);
|
|
}
|
|
s.session.lastActivityAt = at;
|
|
s.session.updatedAt = at;
|
|
}
|
|
|
|
private withProfile<T>(s: SessionState | string, fn: () => Promise<T>): Promise<T> {
|
|
const key = typeof s === "string" ? s : s.session.profileKey;
|
|
return this.lock.run(key, fn, this.cfg.lockWaitMs === undefined ? {} : { waitMs: this.cfg.lockWaitMs });
|
|
}
|
|
|
|
/** 服务层把会话控制权变更(接管/交还/等待)同步给适配器;租约本身由服务层持久化。任何变更都让旧引用失效 */
|
|
setController(sessionId: string, controller: BrowserSession["controller"]): void {
|
|
const s = this.state(sessionId);
|
|
if (s.session.controller !== controller) s.refsValid = false;
|
|
s.session.controller = controller;
|
|
if (controller === "closed" && !s.session.closedAt) s.session.closedAt = this.now().toISOString();
|
|
this.touch(s);
|
|
}
|
|
|
|
restoreSession(st: RestoreSessionState): void {
|
|
const tabs = new Map(st.tabs.map((t) => [t.upstreamTabId, { ...t }]));
|
|
const open = st.tabs.find((t) => t.status === "open");
|
|
this.sessions.set(st.session.id, {
|
|
session: { ...st.session },
|
|
tabs,
|
|
currentTabId: st.currentTabId && tabs.has(st.currentTabId) ? st.currentTabId : (open?.upstreamTabId ?? ""),
|
|
rawUrl: st.session.currentUrl,
|
|
budget: st.budget,
|
|
refsValid: false,
|
|
seenDownloads: new Set(),
|
|
});
|
|
}
|
|
|
|
sessionInfo(sessionId: string): BrowserSessionInfo | null {
|
|
const s = this.sessions.get(sessionId);
|
|
if (!s) return null;
|
|
return {
|
|
session: { ...s.session },
|
|
tabs: [...s.tabs.values()].map((t) => ({ ...t })),
|
|
currentTabId: s.currentTabId || null,
|
|
refsValid: s.refsValid,
|
|
loginPage: isLoginUrl(s.rawUrl),
|
|
};
|
|
}
|
|
|
|
async drain(sessionId: string): Promise<void> {
|
|
const s = this.state(sessionId);
|
|
await this.withProfile(s, async () => {});
|
|
}
|
|
|
|
// ---------- 健康与能力 ----------
|
|
|
|
async health(): Promise<BrowserHealth> {
|
|
const checkedAt = this.now().toISOString();
|
|
const u = `${this.base}/health`;
|
|
try {
|
|
// /health 在 CAMOFOX_ACCESS_KEY 下也免认证(README);503 表示恢复中
|
|
const res = await this.fetchImpl(u, { signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000) });
|
|
const body = (await res.json().catch(() => ({}))) as Json;
|
|
const detail: Json = {
|
|
httpStatus: res.status,
|
|
engine: str(body.engine) ?? null,
|
|
browserConnected: body.browserConnected ?? null,
|
|
browserRunning: body.browserRunning ?? null,
|
|
activeTabs: int(body.activeTabs) ?? null,
|
|
activeSessions: int(body.activeSessions) ?? null,
|
|
consecutiveFailures: int(body.consecutiveFailures) ?? null,
|
|
recovering: body.recovering ?? null,
|
|
};
|
|
if (res.status === 503) return { status: "degraded", kind: "camofox", detail, checkedAt };
|
|
if (!res.ok || body.ok === false) return { status: "down", kind: "camofox", detail, checkedAt };
|
|
return { status: "ok", kind: "camofox", detail, checkedAt };
|
|
} catch (e) {
|
|
const name = (e as { name?: string })?.name;
|
|
return {
|
|
status: "down",
|
|
kind: "camofox",
|
|
detail: { error: name === "TimeoutError" ? "timeout" : "network" },
|
|
checkedAt,
|
|
};
|
|
}
|
|
}
|
|
|
|
/** probe=true 时读取上游 /openapi.json 推导;失败回退静态声明(note 标注探测失败) */
|
|
async capabilities(opts: { probe?: boolean } = {}): Promise<BrowserCapabilities> {
|
|
const verified = this.cfg.verified ?? {};
|
|
if (!opts.probe) return camofoxStaticCapabilities(verified);
|
|
try {
|
|
const res = await this.request("GET", "/openapi.json");
|
|
return camofoxCapabilitiesFromOpenApi(await this.json(res, "/openapi.json"), verified, this.now());
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.kind === "auth") throw e;
|
|
return camofoxStaticCapabilities(verified);
|
|
}
|
|
}
|
|
|
|
// ---------- 会话 ----------
|
|
|
|
async openSession(req: OpenSessionRequest): Promise<OpenSessionResult> {
|
|
const profileKey = req.profileKey ?? profileKeyFor(req.siteId, req.accountId);
|
|
if (req.initialUrl) assertNavigable(req.initialUrl, req.budget);
|
|
return this.withProfile(profileKey, async () => {
|
|
for (const s of this.sessions.values())
|
|
if (s.session.profileKey === profileKey && s.session.controller !== "closed")
|
|
throw new BrowserError("control_conflict", "该站点/账号已有未关闭的浏览器会话");
|
|
const sessionId = this.newId();
|
|
const res = await this.request("POST", "/tabs", {
|
|
body: { userId: profileKey, sessionKey: sessionId, ...(req.initialUrl ? { url: req.initialUrl } : {}) },
|
|
timeoutMs: req.budget.timeoutMs,
|
|
});
|
|
const body = await this.json(res, "/tabs");
|
|
const tabId = str(body.tabId);
|
|
if (!tabId) throw new BrowserError("protocol", "camofox POST /tabs 未返回 tabId");
|
|
const rawUrl = str(body.url) ?? req.initialUrl ?? null;
|
|
const at = this.now().toISOString();
|
|
const session: BrowserSession = {
|
|
id: sessionId,
|
|
providerId: this.cfg.providerId,
|
|
siteId: req.siteId,
|
|
accountId: req.accountId,
|
|
profileKey,
|
|
controller: "automation",
|
|
currentUrl: rawUrl === null ? null : redactBrowserUrl(rawUrl),
|
|
lastActivityAt: at,
|
|
upstreamRef: { userId: profileKey, sessionKey: sessionId },
|
|
recoveryState: {},
|
|
closedAt: null,
|
|
closeReason: null,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
};
|
|
const tab: BrowserTab = {
|
|
id: this.newId(),
|
|
sessionId,
|
|
upstreamTabId: tabId,
|
|
url: session.currentUrl,
|
|
title: null,
|
|
status: "open",
|
|
lastSnapshotAt: null,
|
|
closedAt: null,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
};
|
|
const st: SessionState = {
|
|
session,
|
|
tabs: new Map([[tabId, tab]]),
|
|
currentTabId: tabId,
|
|
rawUrl,
|
|
budget: req.budget,
|
|
refsValid: false,
|
|
seenDownloads: new Set(),
|
|
};
|
|
this.sessions.set(sessionId, st);
|
|
let navigation: NavigationResult | null = null;
|
|
if (req.initialUrl) {
|
|
navigation = {
|
|
url: session.currentUrl ?? "",
|
|
httpStatus: int(body.httpStatus) ?? null,
|
|
ok: body.navigationOk !== false,
|
|
attempts: 1,
|
|
};
|
|
this.assertLanded(rawUrl, req.budget);
|
|
}
|
|
return { session: { ...session }, tab: { ...tab }, navigation };
|
|
});
|
|
}
|
|
|
|
/** 重定向落到白名单外:报 domain_blocked(页面已打开,调用方应停止后续动作并转人工) */
|
|
private assertLanded(rawUrl: string | null, budget: NavigationBudget): void {
|
|
if (!rawUrl) return;
|
|
let host: string;
|
|
try {
|
|
host = new URL(rawUrl).hostname;
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!hostAllowed(host, budget.domainAllowlist))
|
|
throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`);
|
|
}
|
|
|
|
/** DELETE /sessions/{userId}:关闭该 profile 的全部上游标签页;上游 404(已被回收)视为已关闭 */
|
|
async closeSession(sessionId: string, reason = "closed"): Promise<void> {
|
|
const s = this.state(sessionId);
|
|
await this.withProfile(s, async () => {
|
|
try {
|
|
await this.request("DELETE", `/sessions/${encodeURIComponent(s.session.profileKey)}`);
|
|
} catch (e) {
|
|
if (!(e instanceof BrowserError && e.kind === "not_found")) throw e;
|
|
}
|
|
const at = this.now().toISOString();
|
|
s.session.controller = "closed";
|
|
s.session.closedAt = at;
|
|
s.session.closeReason = reason;
|
|
s.refsValid = false;
|
|
for (const t of s.tabs.values())
|
|
if (t.status === "open") {
|
|
t.status = "closed";
|
|
t.closedAt = at;
|
|
t.updatedAt = at;
|
|
}
|
|
for (const l of this.leases.values())
|
|
if (l.sessionId === sessionId && l.status === "active") {
|
|
l.status = "revoked";
|
|
l.releasedAt = at;
|
|
l.releaseReason = "session_closed";
|
|
}
|
|
this.sessions.delete(sessionId);
|
|
});
|
|
}
|
|
|
|
/** 本进程登记的未关闭会话;服务层以数据库为准,这里只是内存镜像 */
|
|
async listSessions(): Promise<BrowserSession[]> {
|
|
return [...this.sessions.values()].map((s) => ({ ...s.session }));
|
|
}
|
|
|
|
// ---------- 标签页 ----------
|
|
|
|
async openTab(
|
|
sessionId: string,
|
|
opts: { url?: string; budget: NavigationBudget; as?: ActorKind },
|
|
): Promise<BrowserTab> {
|
|
const s = this.state(sessionId);
|
|
this.assertActor(s, opts.as);
|
|
if (opts.url) assertNavigable(opts.url, opts.budget);
|
|
return this.withProfile(s, async () => {
|
|
this.assertActor(s, opts.as);
|
|
const res = await this.request("POST", "/tabs", {
|
|
body: { userId: s.session.profileKey, sessionKey: s.session.id, ...(opts.url ? { url: opts.url } : {}) },
|
|
timeoutMs: opts.budget.timeoutMs,
|
|
});
|
|
const body = await this.json(res, "/tabs");
|
|
const tabId = str(body.tabId);
|
|
if (!tabId) throw new BrowserError("protocol", "camofox POST /tabs 未返回 tabId");
|
|
const rawUrl = str(body.url) ?? opts.url ?? null;
|
|
this.touch(s, rawUrl);
|
|
const at = s.session.lastActivityAt;
|
|
const tab: BrowserTab = {
|
|
id: this.newId(),
|
|
sessionId,
|
|
upstreamTabId: tabId,
|
|
url: s.session.currentUrl,
|
|
title: null,
|
|
status: "open",
|
|
lastSnapshotAt: null,
|
|
closedAt: null,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
};
|
|
s.tabs.set(tabId, tab);
|
|
s.currentTabId = tabId;
|
|
s.refsValid = false;
|
|
s.budget = opts.budget;
|
|
if (opts.url) this.assertLanded(rawUrl, opts.budget);
|
|
return { ...tab };
|
|
});
|
|
}
|
|
|
|
async closeTab(ref: TabRef): Promise<void> {
|
|
const s = this.state(ref.sessionId);
|
|
this.assertActor(s, ref.as);
|
|
await this.withProfile(s, async () => {
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}`;
|
|
try {
|
|
await this.request("DELETE", path, { query: { userId: s.session.profileKey } });
|
|
} catch (e) {
|
|
if (!(e instanceof BrowserError && e.kind === "not_found")) throw e;
|
|
}
|
|
const at = this.now().toISOString();
|
|
tab.status = "closed";
|
|
tab.closedAt = at;
|
|
tab.updatedAt = at;
|
|
if (s.currentTabId === tab.upstreamTabId) {
|
|
const next = [...s.tabs.values()].reverse().find((t) => t.status === "open");
|
|
s.currentTabId = next?.upstreamTabId ?? "";
|
|
s.rawUrl = next?.url ?? null;
|
|
s.session.currentUrl = next?.url ?? null;
|
|
}
|
|
s.refsValid = false;
|
|
this.touch(s);
|
|
});
|
|
}
|
|
|
|
/** GET /tabs?userId:上游已回收(空闲超时/上限淘汰/重启)的标签页标 lost,不再对它发动作 */
|
|
async reconcileTabs(sessionId: string): Promise<{ alive: string[]; lost: string[] }> {
|
|
const s = this.state(sessionId);
|
|
return this.withProfile(s, async () => {
|
|
const res = await this.request("GET", "/tabs", { query: { userId: s.session.profileKey } });
|
|
const body = await this.json(res, "/tabs");
|
|
const list = Array.isArray(body.tabs) ? (body.tabs as Json[]) : [];
|
|
const upstream = new Set(list.map((t) => str(t.tabId) ?? str(t.targetId)).filter((x): x is string => !!x));
|
|
const alive: string[] = [];
|
|
const lost: string[] = [];
|
|
const at = this.now().toISOString();
|
|
for (const t of s.tabs.values()) {
|
|
if (t.status !== "open") continue;
|
|
if (upstream.has(t.upstreamTabId)) alive.push(t.upstreamTabId);
|
|
else {
|
|
t.status = "lost";
|
|
t.updatedAt = at;
|
|
lost.push(t.upstreamTabId);
|
|
}
|
|
}
|
|
if (lost.length) s.refsValid = false;
|
|
if (s.tabs.get(s.currentTabId)?.status !== "open") s.currentTabId = alive[0] ?? "";
|
|
return { alive, lost };
|
|
});
|
|
}
|
|
|
|
// ---------- 页面 ----------
|
|
|
|
async navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise<NavigationResult> {
|
|
const s = this.state(ref.sessionId);
|
|
this.assertActor(s, ref.as);
|
|
assertNavigable(url, budget);
|
|
return this.withProfile(s, async () => {
|
|
this.assertActor(s, ref.as);
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/navigate`;
|
|
let attempts = 0;
|
|
for (;;) {
|
|
attempts++;
|
|
try {
|
|
const res = await this.request("POST", path, {
|
|
body: { userId: s.session.profileKey, url },
|
|
timeoutMs: budget.timeoutMs,
|
|
});
|
|
const body = await this.json(res, path);
|
|
const rawUrl = str(body.url) ?? url;
|
|
this.touch(s, rawUrl);
|
|
tab.url = s.session.currentUrl;
|
|
tab.updatedAt = s.session.updatedAt;
|
|
s.budget = budget;
|
|
s.currentTabId = tab.upstreamTabId;
|
|
s.refsValid = false;
|
|
this.assertLanded(rawUrl, budget);
|
|
return {
|
|
url: redactBrowserUrl(rawUrl),
|
|
httpStatus: int(body.httpStatus) ?? null,
|
|
ok: body.ok !== false && body.navigationOk !== false,
|
|
attempts,
|
|
};
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.retryable && attempts <= budget.maxRetries) continue;
|
|
throw e;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async snapshot(ref: TabRef, opts: SnapshotOptions = {}): Promise<BrowserSnapshot> {
|
|
const s = this.state(ref.sessionId);
|
|
return this.withProfile(s, async () => {
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/snapshot`;
|
|
const res = await this.request("GET", path, {
|
|
query: {
|
|
userId: s.session.profileKey,
|
|
format: "text",
|
|
...(opts.offset !== undefined ? { offset: String(opts.offset) } : {}),
|
|
},
|
|
timeoutMs: s.budget.timeoutMs,
|
|
});
|
|
const body = await this.json(res, path);
|
|
let text = str(body.snapshot) ?? "";
|
|
let truncated = body.truncated === true || body.hasMore === true;
|
|
const maxBytes = opts.maxBytes ?? s.budget.maxBytes;
|
|
const enc = new TextEncoder();
|
|
if (enc.encode(text).byteLength > maxBytes) {
|
|
// 按字节预算截断;在字符边界上截,避免半个 UTF-8 字符
|
|
let lo = 0;
|
|
let hi = text.length;
|
|
while (lo < hi) {
|
|
const mid = Math.ceil((lo + hi) / 2);
|
|
if (enc.encode(text.slice(0, mid)).byteLength <= maxBytes) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
text = text.slice(0, lo);
|
|
truncated = true;
|
|
}
|
|
const rawUrl = str(body.url) ?? s.rawUrl ?? "";
|
|
const structure =
|
|
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 ?? [] });
|
|
this.touch(s, rawUrl || s.rawUrl);
|
|
const at = s.session.lastActivityAt;
|
|
tab.lastSnapshotAt = at;
|
|
tab.url = s.session.currentUrl;
|
|
// 只有当前标签页的最新快照让引用生效
|
|
if (tab.upstreamTabId === s.currentTabId && s.session.controller !== "closed") s.refsValid = true;
|
|
const refsCount = int(body.refsCount) ?? new Set(text.match(/\be\d+\b/g) ?? []).size;
|
|
return {
|
|
sessionId: s.session.id,
|
|
tabId: tab.upstreamTabId,
|
|
url: red.url,
|
|
format: "a11y_text",
|
|
text: red.text,
|
|
structure: red.structure,
|
|
refsCount,
|
|
truncated,
|
|
nextOffset: truncated ? (int(body.nextOffset) ?? null) : null,
|
|
redactions: red.redactions,
|
|
capturedAt: at,
|
|
};
|
|
});
|
|
}
|
|
|
|
async screenshot(ref: TabRef, opts: { maxBytes?: number } = {}): Promise<BrowserScreenshot> {
|
|
const s = this.state(ref.sessionId);
|
|
return this.withProfile(s, async () => {
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/screenshot`;
|
|
const res = await this.request("GET", path, {
|
|
query: { userId: s.session.profileKey },
|
|
timeoutMs: s.budget.timeoutMs,
|
|
});
|
|
const maxBytes = opts.maxBytes ?? s.budget.maxBytes ?? this.cfg.defaultMaxBytes ?? 8 * 1024 * 1024;
|
|
const declared = Number(res.headers.get("content-length") ?? "NaN");
|
|
if (Number.isFinite(declared) && declared > maxBytes * 1.4) {
|
|
await res.body?.cancel();
|
|
throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`);
|
|
}
|
|
const ctype = res.headers.get("content-type") ?? "";
|
|
let data: Uint8Array;
|
|
let mimeType: string;
|
|
if (ctype.startsWith("image/")) {
|
|
// 部分版本/fork 直接返回二进制图片(待核验)
|
|
data = new Uint8Array(await res.arrayBuffer());
|
|
mimeType = ctype.split(";")[0]!.trim();
|
|
} else {
|
|
// OpenAPI 1.17.0:{ screenshot: { data: base64, mimeType } }
|
|
const body = await this.json(res, path);
|
|
const shot = (body.screenshot && typeof body.screenshot === "object" ? body.screenshot : body) as Json;
|
|
const b64 = str(shot.data);
|
|
if (!b64) throw new BrowserError("protocol", "camofox 截图响应缺少 data");
|
|
try {
|
|
data = b64decode(b64);
|
|
} catch {
|
|
throw new BrowserError("protocol", "camofox 截图不是合法 base64");
|
|
}
|
|
mimeType = str(shot.mimeType) ?? "image/png";
|
|
}
|
|
if (data.byteLength > maxBytes) throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`);
|
|
this.touch(s);
|
|
const raw = s.rawUrl;
|
|
const loginPage = isLoginUrl(raw);
|
|
return {
|
|
mimeType,
|
|
data,
|
|
url: raw === null ? null : redactBrowserUrl(raw),
|
|
modelSafe: raw !== null && !urlHasCredential(raw) && !loginPage,
|
|
capturedAt: s.session.lastActivityAt,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---------- 交互 ----------
|
|
|
|
/** 交互动作的公共路径:控制者校验 → 串行锁 → POST /tabs/{id}/<action> */
|
|
private async act(ref: TabRef, action: string, body: (s: SessionState) => Json, after?: (s: SessionState) => void) {
|
|
const s = this.state(ref.sessionId);
|
|
this.assertActor(s, ref.as);
|
|
const payload = body(s);
|
|
await this.withProfile(s, async () => {
|
|
this.assertActor(s, ref.as);
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/${action}`;
|
|
const res = await this.request("POST", path, {
|
|
body: { userId: s.session.profileKey, ...payload },
|
|
timeoutMs: s.budget.timeoutMs,
|
|
});
|
|
const out = await res.json().catch(() => ({}) as Json);
|
|
const rawUrl = out && typeof out === "object" ? str((out as Json).url) : undefined;
|
|
this.touch(s, rawUrl ?? s.rawUrl);
|
|
if (rawUrl) tab.url = s.session.currentUrl;
|
|
s.currentTabId = tab.upstreamTabId;
|
|
after?.(s);
|
|
});
|
|
}
|
|
|
|
/** 点击可能跳转或改动页面:之后旧引用作废 */
|
|
async click(ref: TabRef, target: ElementTarget): Promise<void> {
|
|
await this.act(
|
|
ref,
|
|
"click",
|
|
(s) => this.assertTarget(s, target),
|
|
(s) => {
|
|
s.refsValid = false;
|
|
},
|
|
);
|
|
}
|
|
|
|
/** 凭证型输入在服务端解析后直接下发;明文不进日志、错误信息与返回值 */
|
|
async type(ref: TabRef, target: ElementTarget | null, input: TypeInput): Promise<void> {
|
|
let text: string;
|
|
if (input.kind === "secret") {
|
|
const v = await this.cfg.secrets.resolve({
|
|
kind: "site_login",
|
|
siteId: input.siteId,
|
|
accountId: input.accountId,
|
|
field: input.field,
|
|
});
|
|
if (!v)
|
|
throw new BrowserError(
|
|
"auth",
|
|
`站点账号没有可用的${input.field === "username" ? "用户名" : input.field === "password" ? "密码" : "TOTP"}凭证`,
|
|
);
|
|
text = v;
|
|
} else text = input.text;
|
|
await this.act(
|
|
ref,
|
|
"type",
|
|
(s) => ({
|
|
...(target ? this.assertTarget(s, target) : {}),
|
|
text,
|
|
...(input.clear !== undefined ? { clear: input.clear } : {}),
|
|
...(input.submit !== undefined ? { submit: input.submit } : {}),
|
|
}),
|
|
(s) => {
|
|
if (input.submit) s.refsValid = false;
|
|
},
|
|
);
|
|
}
|
|
|
|
async select(ref: TabRef, target: ElementTarget, value: string): Promise<void> {
|
|
await this.act(ref, "select", (s) => ({ ...this.assertTarget(s, target), value }));
|
|
}
|
|
|
|
async scroll(ref: TabRef, opts: { direction: "up" | "down" | "left" | "right"; amount: number }): Promise<void> {
|
|
await this.act(ref, "scroll", () => ({ direction: opts.direction, amount: opts.amount }));
|
|
}
|
|
|
|
/** 单个按键(Enter / Tab / Backspace …);Enter 可能提交表单,之后旧引用作废 */
|
|
async press(ref: TabRef, key: string): Promise<void> {
|
|
await this.act(
|
|
ref,
|
|
"press",
|
|
() => ({ key }),
|
|
(s) => {
|
|
s.refsValid = false;
|
|
},
|
|
);
|
|
}
|
|
|
|
async history(
|
|
ref: TabRef,
|
|
action: "back" | "forward" | "reload",
|
|
budget: NavigationBudget,
|
|
): Promise<NavigationResult> {
|
|
const s = this.state(ref.sessionId);
|
|
this.assertActor(s, ref.as);
|
|
return this.withProfile(s, async () => {
|
|
this.assertActor(s, ref.as);
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/${action === "reload" ? "refresh" : action}`;
|
|
const res = await this.request("POST", path, {
|
|
body: { userId: s.session.profileKey },
|
|
timeoutMs: budget.timeoutMs,
|
|
});
|
|
const body = await this.json(res, path);
|
|
const rawUrl = str(body.url) ?? s.rawUrl;
|
|
this.touch(s, rawUrl);
|
|
tab.url = s.session.currentUrl;
|
|
s.refsValid = false;
|
|
this.assertLanded(rawUrl, budget);
|
|
return {
|
|
url: rawUrl ? redactBrowserUrl(rawUrl) : "",
|
|
httpStatus: int(body.httpStatus) ?? null,
|
|
ok: body.ok !== false && body.navigationOk !== false,
|
|
attempts: 1,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** POST /tabs/{id}/extract {schema}:属性 x-ref 指向快照元素,需先 snapshot;结果按快照同样规则脱敏 */
|
|
async extract(ref: TabRef, schema: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
const s = this.state(ref.sessionId);
|
|
return this.withProfile(s, async () => {
|
|
if (!s.refsValid) throw new BrowserError("stale_ref", "提取依赖最新快照,先重新读取页面");
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/extract`;
|
|
const res = await this.request("POST", path, {
|
|
body: { userId: s.session.profileKey, schema },
|
|
timeoutMs: s.budget.timeoutMs,
|
|
});
|
|
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 });
|
|
this.touch(s);
|
|
return (red.structure ?? {}) as Record<string, unknown>;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 轮询 GET /tabs/{id}/downloads?includeData&consume&maxBytes,拿到第一个未见过的完成下载:
|
|
* 字节交 downloadSink 暂存、算 sha256,只返回引用。超时报 timeout,超限报 budget_exceeded。
|
|
*/
|
|
async waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact> {
|
|
const s = this.state(ref.sessionId);
|
|
const deadline = Date.now() + opts.timeoutMs;
|
|
const poll = this.cfg.downloadPollMs ?? 500;
|
|
for (;;) {
|
|
const found = await this.withProfile(s, async () => {
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/downloads`;
|
|
const res = await this.request("GET", path, {
|
|
query: {
|
|
userId: s.session.profileKey,
|
|
includeData: "true",
|
|
consume: "true",
|
|
maxBytes: String(opts.maxBytes),
|
|
},
|
|
timeoutMs: Math.max(1_000, Math.min(this.cfg.timeoutMs ?? 30_000, deadline - Date.now() + 1_000)),
|
|
});
|
|
const body = (await res.json().catch(() => null)) as unknown;
|
|
const list = (
|
|
Array.isArray(body) ? body : Array.isArray((body as Json | null)?.downloads) ? (body as Json).downloads : []
|
|
) as Json[];
|
|
for (const d of list) {
|
|
const key = str(d.id) ?? `${str(d.filename) ?? str(d.suggestedFilename)}|${str(d.url)}|${d.size}`;
|
|
if (s.seenDownloads.has(key)) continue;
|
|
const status = str(d.status) ?? str(d.state);
|
|
if (status && !["completed", "complete", "done", "finished"].includes(status)) continue;
|
|
const b64 = str(d.data) ?? str(d.base64) ?? str(d.content);
|
|
const size = int(d.size) ?? int(d.bytes) ?? null;
|
|
if (size !== null && size > opts.maxBytes)
|
|
throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
|
|
if (!b64) {
|
|
if (d.truncated === true || d.tooLarge === true)
|
|
throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
|
|
continue;
|
|
}
|
|
let bytes: Uint8Array;
|
|
try {
|
|
bytes = b64decode(b64);
|
|
} catch {
|
|
throw new BrowserError("protocol", "camofox 下载内容不是合法 base64");
|
|
}
|
|
if (bytes.byteLength > opts.maxBytes)
|
|
throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
|
|
s.seenDownloads.add(key);
|
|
const id = this.newId();
|
|
const rawName = (str(d.filename) ?? str(d.suggestedFilename) ?? str(d.name) ?? "download.bin").slice(0, 255);
|
|
// 去掉路径分隔符与控制字符
|
|
const filename = [...rawName].map((ch) => (ch < " " || ch === "/" || ch === "\\" ? "_" : ch)).join("");
|
|
const mimeType = str(d.mimeType) ?? str(d.contentType) ?? null;
|
|
const blob = await this.sink(bytes, { id, sessionId: s.session.id, filename, mimeType });
|
|
const srcUrl = str(d.url);
|
|
this.touch(s);
|
|
return {
|
|
id,
|
|
sessionId: s.session.id,
|
|
filename,
|
|
mimeType,
|
|
sizeBytes: bytes.byteLength,
|
|
bytes: blob,
|
|
sha256: await sha256Hex(bytes),
|
|
sourceUrl: srcUrl ? redactBrowserUrl(srcUrl) : null,
|
|
capturedAt: this.now().toISOString(),
|
|
} satisfies DownloadArtifact;
|
|
}
|
|
return null;
|
|
});
|
|
if (found) return found;
|
|
if (Date.now() + poll > deadline) throw new BrowserError("timeout", `等待下载超时(${opts.timeoutMs}ms)`);
|
|
await new Promise((ok) => setTimeout(ok, poll));
|
|
}
|
|
}
|
|
|
|
// ---------- 控制权(内存版;服务层以 browser_control_lease 持久化) ----------
|
|
|
|
/** 接管:先把控制者切走(新的自动化输入立刻被拒),再等进行中的操作收尾,然后发放租约 */
|
|
async acquireControl(req: AcquireControlRequest): Promise<BrowserControlLease> {
|
|
const s = this.state(req.sessionId);
|
|
if (s.session.controller === "closed") throw new BrowserError("control_conflict", "会话已关闭");
|
|
for (const l of this.leases.values())
|
|
if (l.sessionId === req.sessionId && l.status === "active") {
|
|
if (l.holderKind === req.holderKind && l.holder === req.holder) return { ...l };
|
|
if (l.holderKind === "human" && req.holderKind === "human")
|
|
throw new BrowserError("control_conflict", "会话已被他人接管");
|
|
l.status = "revoked";
|
|
l.releasedAt = this.now().toISOString();
|
|
l.releaseReason = `${req.holderKind}_takeover`;
|
|
}
|
|
this.setController(req.sessionId, req.holderKind);
|
|
await this.drain(req.sessionId);
|
|
const at = this.now();
|
|
const lease: BrowserControlLease = {
|
|
id: this.newId(),
|
|
sessionId: req.sessionId,
|
|
holderKind: req.holderKind,
|
|
holder: req.holder,
|
|
reason: req.reason,
|
|
status: "active",
|
|
acquiredAt: at.toISOString(),
|
|
expiresAt: new Date(at.getTime() + req.ttlMs).toISOString(),
|
|
releasedAt: null,
|
|
releaseReason: null,
|
|
createdAt: at.toISOString(),
|
|
updatedAt: at.toISOString(),
|
|
};
|
|
this.leases.set(lease.id, lease);
|
|
return { ...lease };
|
|
}
|
|
|
|
/** 交还:人工租约释放后回到自动化,旧引用失效(Agent 必须重新读取页面) */
|
|
async releaseControl(leaseId: string, reason = "released"): Promise<void> {
|
|
const l = this.leases.get(leaseId);
|
|
if (l?.status !== "active") throw new BrowserError("not_found", "租约不存在或已结束");
|
|
const at = this.now().toISOString();
|
|
l.status = "released";
|
|
l.releasedAt = at;
|
|
l.releaseReason = reason;
|
|
l.updatedAt = at;
|
|
if (this.sessions.has(l.sessionId) && l.holderKind === "human") this.setController(l.sessionId, "automation");
|
|
}
|
|
}
|
|
|
|
export function createCamofoxBrowserProvider(cfg: CamofoxConfig): CamofoxBrowserProvider {
|
|
return new CamofoxBrowserProvider(cfg);
|
|
}
|