1493 lines
62 KiB
TypeScript
1493 lines
62 KiB
TypeScript
/**
|
|
* camofox-browser 适配器(BrowserProvider 首个实现)。
|
|
* 对照:jo-inc/camofox-browser v1.17.0(commit 389c996)openapi.json、README 与 server.js;2026-09-28 对真实容器核验,
|
|
* 结果见 docs/runbook/browser.md「真机核验」。
|
|
*
|
|
* 上游模型与本项目合同的映射:
|
|
* - camofox userId = 一个隔离 BrowserContext(cookies/storage),本项目用 profileKey(站点/账号)充当;
|
|
* - camofox sessionKey = 标签组,本项目用 browser_session.id;
|
|
* - 上游没有"建会话"端点,首个 POST /tabs 隐式创建;会话由本适配器在内存登记,持久化由服务层写 browser_session。
|
|
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
|
|
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
|
|
*
|
|
* 已实现:health、capabilities、会话开/关/列举、标签页增删与对账、navigate(白名单/超时/重试)、
|
|
* 后退/前进/刷新、snapshot(脱敏)、screenshot、click/type/select/scroll/press、extract、waitForDownload、
|
|
* 内存版控制权租约与串行锁。会话/标签/租约的持久化与对账由服务层(apps/server modules/browser)以数据库为准。
|
|
* 与文档不同的实测行为(截图直接返回 image/png、click 不接受坐标、焦点输入需 mode=keyboard、downloads 字段名)已按实测适配。
|
|
*/
|
|
import type {
|
|
BrowserCapabilities,
|
|
BrowserControlLease,
|
|
BrowserCookie,
|
|
BrowserEgressProxy,
|
|
BrowserHealth,
|
|
BrowserIdentity,
|
|
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,
|
|
type PageContent,
|
|
type PageMetrics,
|
|
profileKeyFor,
|
|
type ReopenSessionRequest,
|
|
type ReopenSessionResult,
|
|
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?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
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);
|
|
|
|
/** 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;
|
|
|
|
/** 页面度量表达式(mock 按 umt-metrics 标记识别) */
|
|
const PAGE_METRICS_EXPR = `/* umt-metrics */ (() => ({ w: innerWidth, h: innerHeight, dpr: devicePixelRatio,
|
|
pw: !!document.querySelector("input[type=password]"),
|
|
text: String((document.body && document.body.innerText) || "").slice(0, 4000) }))()`;
|
|
|
|
/** 页面内容表达式(mock 按 umt-page 标记识别) */
|
|
function pageContentExpr(maxChars: number): string {
|
|
return `/* umt-page */ (() => ({ url: location.href, title: document.title,
|
|
html: document.documentElement ? document.documentElement.outerHTML.slice(0, ${maxChars}) : "",
|
|
text: String((document.body && document.body.innerText) || "").slice(0, 20000),
|
|
ua: navigator.userAgent }))()`;
|
|
}
|
|
|
|
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: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
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;
|
|
/** Cookie 接口:plugin = umt-cookies 插件(读写);upstream = 上游导入端点(只写,要求 CAMOFOX_API_KEY 与访问凭据相同);none = 都没有 */
|
|
private cookieApi: "plugin" | "upstream" | "none" | null = null;
|
|
/** 实例标识接口:plugin = umt 插件 GET /umt/instance;health = 只能从 /health 里找;null = 未探测 */
|
|
private instanceApi: "plugin" | "health" | null = null;
|
|
/** 真实鼠标坐标点击(CF-2):plugin = umt 插件 `POST /umt/tabs/{id}/mouse-click`;none = 插件没有,退回 elementFromPoint;null = 未探测 */
|
|
private mouseApi: "plugin" | "none" | null = null;
|
|
|
|
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" | "PUT" | "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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 实例标识(BR-3 重启检测):umt 插件 `GET /umt/instance` 的 instanceId(插件加载时生成,进程重启即变);
|
|
* 插件不在(404)退回 `/health` 里的 instanceId / bootId / startedAt;都没有返回 null。
|
|
*/
|
|
async instanceId(): Promise<string | null> {
|
|
if (this.instanceApi !== "health") {
|
|
try {
|
|
const body = await this.json(await this.request("GET", "/umt/instance", { timeoutMs: 5_000 }), "/umt/instance");
|
|
this.instanceApi = "plugin";
|
|
return str(body.instanceId) ?? str(body.startedAt) ?? null;
|
|
} catch (e) {
|
|
if (!(e instanceof BrowserError && e.httpStatus === 404)) throw e;
|
|
this.instanceApi = "health";
|
|
}
|
|
}
|
|
const res = await this.fetchImpl(`${this.base}/health`, { signal: AbortSignal.timeout(5_000) });
|
|
const body = (await res.json().catch(() => ({}))) as Json;
|
|
const v = body.instanceId ?? body.bootId ?? body.startedAt;
|
|
return typeof v === "string" || typeof v === "number" ? String(v) : null;
|
|
}
|
|
|
|
/**
|
|
* 固定浏览器身份(BR-4):umt 插件 `GET /umt/identity`(UA、OS、版本、生成时间,不含指纹细节);
|
|
* 插件不在或身份关闭(404)返回 null。
|
|
*/
|
|
async identity(): Promise<BrowserIdentity | null> {
|
|
let body: Json;
|
|
try {
|
|
body = await this.json(await this.request("GET", "/umt/identity", { timeoutMs: 10_000 }), "/umt/identity");
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.httpStatus === 404) return null;
|
|
throw e;
|
|
}
|
|
const userAgent = str(body.userAgent);
|
|
if (!userAgent) return null;
|
|
return {
|
|
userAgent,
|
|
os: str(body.os) ?? null,
|
|
platform: str(body.platform) ?? null,
|
|
firefoxVersion: str(body.firefoxVersion) ?? null,
|
|
camoufoxVersion: str(body.camoufoxVersion) ?? null,
|
|
fingerprintId: str(body.fingerprintId) ?? null,
|
|
revision: int(body.revision) ?? 1,
|
|
createdAt: str(body.createdAt) ?? null,
|
|
updatedAt: str(body.updatedAt) ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 该 profile 的出口(BR-4 出口 IP 按站点统一):umt 插件 `PUT /umt/sessions/{userId}/proxy`。
|
|
* proxy = null 直连;活动上下文出口不同时插件会先关掉它(落盘登录状态)。插件不在报 not_implemented。
|
|
*/
|
|
async setEgress(
|
|
profileKey: string,
|
|
proxy: BrowserEgressProxy | null,
|
|
): Promise<{ restarted: boolean; mode?: string }> {
|
|
try {
|
|
const body = await this.json(
|
|
await this.request("PUT", `/umt/sessions/${encodeURIComponent(profileKey)}/proxy`, {
|
|
body: { proxy } as unknown as Json,
|
|
timeoutMs: 15_000,
|
|
}),
|
|
"/umt/sessions/{userId}/proxy",
|
|
);
|
|
const mode = str(body.mode);
|
|
return { restarted: body.restarted === true, ...(mode ? { mode } : {}) };
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.httpStatus === 404)
|
|
throw new BrowserError("not_implemented", "浏览器服务不支持按站点设出口(镜像未带新版 umt-cookies 插件)");
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 重建会话的上游上下文(BR-3):上游重启 / 回收后旧标签页都不在了。先(可选)注入 Cookie,再用同一 userId / sessionKey
|
|
* 开新标签页并导航到 url;旧的打开中标签页标 lost,旧元素引用作废。不检查控制者:接管中的会话重连后仍归原控制者。
|
|
*/
|
|
async reopenSession(sessionId: string, req: ReopenSessionRequest): Promise<ReopenSessionResult> {
|
|
const s = this.state(sessionId);
|
|
assertNavigable(req.url, req.budget);
|
|
return this.withProfile(s, async () => {
|
|
let cookies: ReopenSessionResult["cookies"];
|
|
if (req.cookies?.length) {
|
|
try {
|
|
cookies = { status: "injected", count: (await this.putCookies(s.session.profileKey, req.cookies)).count };
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.kind === "not_implemented") cookies = { status: "unsupported", count: 0 };
|
|
else if (e instanceof BrowserError && e.kind !== "auth") cookies = { status: "failed", count: 0 };
|
|
else throw e;
|
|
}
|
|
}
|
|
const res = await this.request("POST", "/tabs", {
|
|
body: { userId: s.session.profileKey, sessionKey: s.session.id, url: req.url },
|
|
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.url;
|
|
this.touch(s, rawUrl);
|
|
const at = s.session.lastActivityAt;
|
|
const lost: string[] = [];
|
|
for (const t of s.tabs.values())
|
|
if (t.status === "open") {
|
|
t.status = "lost";
|
|
t.updatedAt = at;
|
|
lost.push(t.upstreamTabId);
|
|
}
|
|
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 = req.budget;
|
|
return { tab: { ...tab }, lost, ...(cookies ? { cookies } : {}) };
|
|
});
|
|
}
|
|
|
|
// ---------- 会话 ----------
|
|
|
|
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();
|
|
let cookies: OpenSessionResult["cookies"];
|
|
if (req.cookies?.length) {
|
|
try {
|
|
cookies = { status: "injected", count: (await this.putCookies(profileKey, req.cookies)).count };
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.kind === "not_implemented") cookies = { status: "unsupported", count: 0 };
|
|
else if (e instanceof BrowserError && e.kind !== "auth") cookies = { status: "failed", count: 0 };
|
|
else throw e;
|
|
}
|
|
}
|
|
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, ...(cookies ? { cookies } : {}) };
|
|
});
|
|
}
|
|
|
|
/** 重定向落到白名单外:报 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)}`);
|
|
}
|
|
|
|
// ---------- Cookie(BR-2 凭据统一) ----------
|
|
|
|
/**
|
|
* 写 Cookie:先走 umt-cookies 插件 `POST /umt/sessions/{userId}/cookies`(docker/browser-desktop 自带,访问凭据即可);
|
|
* 插件不在(404)再试上游 `POST /sessions/{userId}/cookies`(1.17.0 要求 CAMOFOX_API_KEY,与访问凭据相同时可用);
|
|
* 都不行报 not_implemented。两条路上游都会立即把 profile 落盘。
|
|
*/
|
|
setCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> {
|
|
return this.withProfile(profileKey, () => this.putCookies(profileKey, cookies));
|
|
}
|
|
|
|
private async putCookies(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }> {
|
|
const list = cookies.slice(0, 500);
|
|
const body = { cookies: list } as unknown as Json;
|
|
const path = `/sessions/${encodeURIComponent(profileKey)}/cookies`;
|
|
if (this.cookieApi === null || this.cookieApi === "plugin") {
|
|
try {
|
|
const res = await this.json(
|
|
await this.request("POST", `/umt${path}`, { body }),
|
|
"/umt/sessions/{userId}/cookies",
|
|
);
|
|
this.cookieApi = "plugin";
|
|
return { count: int(res.count) ?? list.length };
|
|
} catch (e) {
|
|
if (!(e instanceof BrowserError && e.httpStatus === 404)) throw e;
|
|
this.cookieApi = null;
|
|
}
|
|
}
|
|
if (this.cookieApi === null || this.cookieApi === "upstream") {
|
|
try {
|
|
const res = await this.json(await this.request("POST", path, { body }), "/sessions/{userId}/cookies");
|
|
this.cookieApi = "upstream";
|
|
return { count: int(res.count) ?? list.length };
|
|
} catch (e) {
|
|
// 403:上游导入口令(CAMOFOX_API_KEY)未设置或与访问凭据不同
|
|
if (!(e instanceof BrowserError && (e.httpStatus === 403 || e.httpStatus === 404))) throw e;
|
|
this.cookieApi = "none";
|
|
}
|
|
}
|
|
throw new BrowserError("not_implemented", "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件)");
|
|
}
|
|
|
|
/** 读 Cookie:只有 umt-cookies 插件提供(上游 1.17.0 没有读取端点);返回作用于这些地址的 Cookie */
|
|
async getCookies(profileKey: string, urls: string[]): Promise<BrowserCookie[]> {
|
|
if (this.cookieApi === "upstream" || this.cookieApi === "none")
|
|
throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)");
|
|
let res: Json;
|
|
try {
|
|
res = await this.json(
|
|
await this.request("GET", `/umt/sessions/${encodeURIComponent(profileKey)}/cookies`, {
|
|
query: { urls: urls.join(",") },
|
|
}),
|
|
"/umt/sessions/{userId}/cookies",
|
|
);
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.httpStatus === 404) {
|
|
throw new BrowserError("not_implemented", "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件)");
|
|
}
|
|
throw e;
|
|
}
|
|
this.cookieApi = "plugin";
|
|
const list = Array.isArray(res.cookies) ? res.cookies : [];
|
|
const out: BrowserCookie[] = [];
|
|
for (const c of list) {
|
|
if (!c || typeof c !== "object") continue;
|
|
const o = c as Json;
|
|
const name = str(o.name);
|
|
const domain = str(o.domain);
|
|
const value = str(o.value);
|
|
if (!name || !domain || value === undefined) continue;
|
|
const sameSite = str(o.sameSite);
|
|
out.push({
|
|
name,
|
|
value,
|
|
domain,
|
|
path: str(o.path) ?? "/",
|
|
expires: typeof o.expires === "number" ? o.expires : -1,
|
|
httpOnly: o.httpOnly === true,
|
|
secure: o.secure === true,
|
|
...(sameSite === "Strict" || sameSite === "Lax" || sameSite === "None" ? { sameSite } : {}),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 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] ?? "";
|
|
// 桌面模式下人在画面里导航不经本适配器:按上游报告的当前地址同步(登录页判定、回写登录态前要用最新地址)
|
|
const cur = list.find((t) => (str(t.tabId) ?? str(t.targetId)) === s.currentTabId);
|
|
const curUrl = cur ? str(cur.url) : undefined;
|
|
if (curUrl && curUrl !== s.rawUrl && /^https?:/.test(curUrl)) {
|
|
s.refsValid = false;
|
|
this.touch(s, curUrl);
|
|
const tab = s.tabs.get(s.currentTabId);
|
|
if (tab) {
|
|
tab.url = s.session.currentUrl;
|
|
tab.updatedAt = at;
|
|
}
|
|
}
|
|
return { alive, lost };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* AG-4 保活:camofox 1.17 没有 keepalive / touch 接口。`GET /tabs/{id}/stats` 只刷新会话的 lastAccess,
|
|
* 不算标签页活动(标签页回收看 toolCalls 有没有变化);最轻的会让 toolCalls 加一的只读调用是
|
|
* `GET /tabs/{id}/downloads`(不带 includeData / consume:只列下载元数据,不读页面、不清下载记录)。
|
|
*/
|
|
async keepAlive(ref: TabRef): Promise<void> {
|
|
const s = this.state(ref.sessionId);
|
|
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 },
|
|
timeoutMs: Math.min(this.cfg.timeoutMs ?? 30_000, 10_000),
|
|
});
|
|
await res.body?.cancel();
|
|
});
|
|
}
|
|
|
|
// ---------- 页面 ----------
|
|
|
|
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 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;
|
|
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,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 () => {
|
|
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,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 视口 / DPR / 密码框 / 文字样本:一次 evaluate;失败(旧版本、页面禁止脚本)返回 null */
|
|
async pageMetrics(ref: TabRef): Promise<PageMetrics | null> {
|
|
const s = this.state(ref.sessionId);
|
|
return this.withProfile(s, async () => {
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
|
|
try {
|
|
const res = await this.request("POST", path, {
|
|
body: { userId: s.session.profileKey, expression: PAGE_METRICS_EXPR },
|
|
timeoutMs: s.budget.timeoutMs,
|
|
});
|
|
const out = await this.json(res, path);
|
|
const r = (out.result && typeof out.result === "object" ? out.result : null) as Json | null;
|
|
const w = typeof r?.w === "number" ? r.w : NaN;
|
|
const h = typeof r?.h === "number" ? r.h : NaN;
|
|
if (!(w > 0) || !(h > 0)) return null;
|
|
return {
|
|
viewportWidth: w,
|
|
viewportHeight: h,
|
|
devicePixelRatio: typeof r?.dpr === "number" && r.dpr > 0 ? r.dpr : 1,
|
|
passwordField: r?.pw === true,
|
|
textSample: typeof r?.text === "string" ? r.text.slice(0, 4000) : "",
|
|
};
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.kind === "not_found") throw e;
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------- 交互 ----------
|
|
|
|
/** 交互动作的公共路径:控制者校验 → 串行锁 → 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);
|
|
// 坐标点击先走 umt 插件的真实鼠标事件(page.mouse.click):跨域 iframe 里的控件(Cloudflare Turnstile)与画布也点得到(CF-2)
|
|
if (
|
|
action === "click" &&
|
|
typeof payload.x === "number" &&
|
|
typeof payload.y === "number" &&
|
|
this.mouseApi !== "none"
|
|
) {
|
|
const done = await this.mouseClick(s, tab, payload.x, payload.y);
|
|
if (done) {
|
|
this.touch(s, done.url ?? s.rawUrl);
|
|
if (done.url) tab.url = s.session.currentUrl;
|
|
s.currentTabId = tab.upstreamTabId;
|
|
after?.(s);
|
|
return;
|
|
}
|
|
}
|
|
// 上游 click/type 只认 ref/selector(1.17.0 实测不接受 {x, y}):坐标先经 evaluate 标记该点元素,再按选择器操作,
|
|
// 这样仍走上游完整的鼠标事件序列与下载守卫,而不是页面内合成 click
|
|
if (typeof payload.x === "number" && typeof payload.y === "number") {
|
|
payload.selector = await this.pointSelector(s, tab, payload.x, payload.y);
|
|
delete payload.x;
|
|
delete payload.y;
|
|
}
|
|
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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 插件的真实鼠标点击:`POST /umt/tabs/{id}/mouse-click {userId, x, y}`(视口 CSS 像素)。插件没有这个端点(404)时记下,
|
|
* 返回 null 让调用方退回 elementFromPoint。
|
|
*/
|
|
private async mouseClick(
|
|
s: SessionState,
|
|
tab: BrowserTab,
|
|
x: number,
|
|
y: number,
|
|
): Promise<{ url: string | undefined } | null> {
|
|
const path = `/umt/tabs/${encodeURIComponent(tab.upstreamTabId)}/mouse-click`;
|
|
try {
|
|
const res = await this.request("POST", path, {
|
|
body: { userId: s.session.profileKey, x, y },
|
|
timeoutMs: s.budget.timeoutMs,
|
|
});
|
|
const out = (await res.json().catch(() => ({}))) as Json;
|
|
this.mouseApi = "plugin";
|
|
return { url: str(out.url) };
|
|
} catch (e) {
|
|
// 404 = 插件没有这个端点(旧镜像);插件对"标签页不在"回 422,按标签页已不存在处理(BR-3 自愈)
|
|
if (e instanceof BrowserError && e.httpStatus === 404) {
|
|
this.mouseApi = "none";
|
|
return null;
|
|
}
|
|
if (e instanceof BrowserError && e.httpStatus === 422)
|
|
throw new BrowserError("not_found", "标签页不存在或已关闭", { httpStatus: 404 });
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 当前页面内容(CF-2):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。只读,不要求自动化控制权;
|
|
* 上游不支持 evaluate 时返回 null。HTML 只给服务端解析器用,不进模型与日志。
|
|
*/
|
|
async pageContent(
|
|
ref: TabRef,
|
|
opts: { maxHtmlBytes?: number; currentVisible?: boolean } = {},
|
|
): Promise<PageContent | null> {
|
|
const s = this.state(ref.sessionId);
|
|
const max = Math.max(1024, Math.min(opts.maxHtmlBytes ?? 8 * 1024 * 1024, 16 * 1024 * 1024));
|
|
return this.withProfile(s, async () => {
|
|
// The desktop can create popups outside this adapter. Resolve its visible tab only
|
|
// for the explicit resource handoff; regular Agent actions keep their selected tab.
|
|
if (opts.currentVisible && !ref.tabId) {
|
|
const listed = await this.json(
|
|
await this.request("GET", "/tabs", { query: { userId: s.session.profileKey } }),
|
|
"/tabs",
|
|
);
|
|
const tabs = Array.isArray(listed.tabs) ? (listed.tabs as Json[]) : [];
|
|
const visible: string[] = [];
|
|
let uncertain = false;
|
|
for (const upstream of tabs) {
|
|
const id = str(upstream.tabId) ?? str(upstream.targetId);
|
|
if (!id) continue;
|
|
const at = this.now().toISOString();
|
|
const url = str(upstream.url);
|
|
if (!s.tabs.has(id))
|
|
s.tabs.set(id, {
|
|
id: this.newId(),
|
|
sessionId: ref.sessionId,
|
|
upstreamTabId: id,
|
|
url: url ? redactBrowserUrl(url) : null,
|
|
title: str(upstream.title) ?? null,
|
|
status: "open",
|
|
lastSnapshotAt: null,
|
|
closedAt: null,
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
});
|
|
try {
|
|
const probePath = `/tabs/${encodeURIComponent(id)}/evaluate`;
|
|
const probe = await this.json(
|
|
await this.request("POST", probePath, {
|
|
body: {
|
|
userId: s.session.profileKey,
|
|
expression: '/* umt-visible-tab */ (() => ({ visible: document.visibilityState === "visible" }))()',
|
|
},
|
|
timeoutMs: s.budget.timeoutMs,
|
|
}),
|
|
probePath,
|
|
);
|
|
const state = probe.result && typeof probe.result === "object" ? (probe.result as Json).visible : undefined;
|
|
if (state === true) visible.push(id);
|
|
else if (state !== false) uncertain = true;
|
|
} catch {
|
|
// Missing/unsupported visibility cannot prove that the old tab is current.
|
|
uncertain = true;
|
|
}
|
|
}
|
|
if (uncertain || visible.length !== 1)
|
|
throw new BrowserError("bad_request", "无法唯一确定桌面当前标签页,请回到资源详情页后重试,或指定标签页");
|
|
if (s.currentTabId !== visible[0]) s.refsValid = false;
|
|
s.currentTabId = visible[0]!;
|
|
}
|
|
const tab = this.tabOf(s, ref);
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
|
|
let out: Json;
|
|
try {
|
|
const res = await this.request("POST", path, {
|
|
body: { userId: s.session.profileKey, expression: pageContentExpr(max) },
|
|
timeoutMs: s.budget.timeoutMs,
|
|
});
|
|
out = await this.json(res, path);
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.kind === "not_found") throw e;
|
|
return null;
|
|
}
|
|
const r = (out.result && typeof out.result === "object" ? out.result : null) as Json | null;
|
|
if (!r || typeof r.html !== "string") return null;
|
|
const rawUrl = str(r.url) ?? s.rawUrl;
|
|
this.touch(s, rawUrl ?? s.rawUrl);
|
|
tab.url = s.session.currentUrl;
|
|
return {
|
|
url: rawUrl ? redactBrowserUrl(rawUrl) : null,
|
|
title: str(r.title) ?? "",
|
|
html: r.html,
|
|
text: str(r.text) ?? "",
|
|
userAgent: str(r.ua) ?? null,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 视口坐标 → 唯一属性选择器(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 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; })()`;
|
|
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
|
|
const res = await this.request("POST", path, {
|
|
body: { userId: s.session.profileKey, expression },
|
|
timeoutMs: s.budget.timeoutMs,
|
|
});
|
|
const out = await this.json(res, path);
|
|
if (out.result !== true) throw new BrowserError("bad_request", "该坐标处没有可操作的元素");
|
|
return `[data-umt-pt="${nonce}"]`;
|
|
}
|
|
|
|
/** 点击可能跳转或改动页面:之后旧引用作废 */
|
|
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) => ({
|
|
// 无目标 = 输入到当前焦点:上游 mode=fill 必须带 ref/selector,焦点输入要 mode=keyboard(1.17.0 实测)
|
|
...(target ? this.assertTarget(s, target) : { mode: "keyboard" }),
|
|
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;
|
|
// 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>;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 轮询 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;
|
|
// 1.17.0 实测字段:{ id, url, suggestedFilename, mimeType, bytes, createdAt, failure, dataBase64 | dataSkipped | readError }
|
|
if (d.failure) {
|
|
s.seenDownloads.add(key);
|
|
throw new BrowserError("upstream", "浏览器下载失败");
|
|
}
|
|
if (d.dataSkipped) throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
|
|
const b64 = str(d.dataBase64) ?? 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);
|
|
}
|