Files
UHD_Media_Tool/apps/server/src/modules/browser/service.ts
T
rootandClaude Opus 5.5 adc853aa64 feat(sites): site requests follow browser UA, smooth UA switch, CF cookies browser-only (BR-4)
- every outbound site request (adapter list/search/detail/download/health,
  RSS, login, user data) carries the account UA via SourcesRuntime.siteFetch;
  no more Deno default UA
- priority: account manual > pending > global setting > browser identity
  (fallback: same-shape Windows Firefox UA); settings UA default is empty
- migration 0056: existing cookie/password accounts keep their old UA as
  ua_pending until re-login or browser cookie write-back switches cookie+UA
  in one transaction; browser_provider.identity caches the browser identity
- cf_clearance/__cf_bm stripped on browser write-back (names kept only)
- browser sessions get per-site egress (same proxy as HTTP, or direct)
- site account page shows UA source; raw UA in tech info

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 03:17:13 +08:00

2061 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import {
BrowserError,
type BrowserProvider,
type BrowserSecretResolver,
CAMOFOX_VERIFIED_RELEASES,
cookiesFromHeader,
createCamofoxBrowserProvider,
type DownloadSink,
type ElementTarget,
getSiteDefinition,
headerFromCookies,
hostAllowed,
imageDimensions,
type PageMetrics,
parseTorrent,
prepareImage,
profileKeyFor,
type TabRef,
toBase64,
totpCode,
} from "@umt/adapters";
import type {
BrowserCapabilities,
BrowserControlLease,
BrowserController,
BrowserCookie,
BrowserCoordinateSpace,
BrowserCredentialState,
BrowserDownloadVerification,
BrowserEgressProxy,
BrowserEvidenceRef,
BrowserHealth,
BrowserIdentity,
BrowserProviderView,
BrowserReconnectState,
BrowserSession,
BrowserSessionView,
BrowserTab,
DownloadArtifact,
NavigationBudget,
} from "@umt/contracts";
import { secretInput } from "@umt/contracts";
import {
type BrowserControlLeaseRow,
type BrowserProviderRow,
type BrowserSessionRow,
type BrowserTabRow,
type SiteRow,
sql,
} from "@umt/db";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { refreshSiteAccount } from "../sources-identity/service/account.ts";
import {
readAccountCookie,
registerBrowserCookieReader,
writeBrowserCookie,
} from "../sources-identity/service/credentials.ts";
import { registerBrowserIdentityReader } from "../sources-identity/service/user-agent.ts";
import type { ServerContext } from "../types.ts";
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
// 站点域名白名单 + 站点限流、凭证注入、接管状态机、下载产物暂存核验。
// 适配器只是内存镜像:服务重启后按数据库恢复,旧元素引用一律失效。
export type Actor = { kind: "automation" | "human"; holder: string };
export interface BrowserModuleOptions {
/** camofox 请求用的 fetch(测试指向 mock) */
fetch?: typeof globalThis.fetch;
/** 站点限流器所在的运行时(与快照/实时搜索共享) */
runtime?: (ctx: ServerContext) => SourcesRuntime;
now?: () => Date;
/** 下载产物暂存目录;缺省 DATA_DIR/tmp/browser-downloads */
stagingDir?: string;
downloadPollMs?: number;
/** 视觉自动化每步证据缩略图目录;缺省 DATA_DIR/tmp/evidence */
evidenceDir?: string;
/** 坐标动作之后等页面稳定再截图的毫秒数;缺省 400 */
settleMs?: number;
/** 测试替换 provider 实现 */
providerFactory?: (row: BrowserProviderRow, deps: ProviderDeps) => BrowserProvider;
}
export interface ProviderDeps {
secrets: BrowserSecretResolver;
sink: DownloadSink;
fetch?: typeof globalThis.fetch;
downloadPollMs?: number;
}
/** 业务错误(路由层转 ApiException,能力层转 CapabilityError) */
export class BrowserServiceError extends Error {
constructor(
readonly code:
| "not_found"
| "conflict"
| "precondition"
| "bad_request"
| "upstream"
| "rate_limited"
| "reconnect_failed",
message: string,
) {
super(message);
this.name = "BrowserServiceError";
}
}
/** BrowserError → 业务错误;消息只含方法/路径/状态,不含凭证 */
export function fromBrowserError(e: unknown): unknown {
if (!(e instanceof BrowserError)) return e;
switch (e.kind) {
case "control_conflict":
case "lock_timeout":
return new BrowserServiceError("conflict", e.message);
case "stale_ref":
return new BrowserServiceError("conflict", e.message);
case "not_found":
return new BrowserServiceError("not_found", e.message);
case "domain_blocked":
case "bad_request":
case "budget_exceeded":
return new BrowserServiceError("bad_request", e.message);
case "auth":
return new BrowserServiceError("precondition", e.message);
case "rate_limited":
return new BrowserServiceError("rate_limited", e.message);
default:
return new BrowserServiceError("upstream", `浏览器服务不可用:${e.message}`);
}
}
const HEALTH: Record<BrowserHealth["status"], "healthy" | "degraded" | "offline" | "unknown"> = {
ok: "healthy",
degraded: "degraded",
down: "offline",
disabled: "unknown",
};
/** 验证码 / 二次认证 / Cloudflare 挑战:自动化不能完成,转等待处理 */
const HUMAN_CHALLENGE =
/验证码|captcha|二次验证|两步验证|二步验证|2fa|two-?step|one-time code|cf-challenge|just a moment|checking your browser/i;
/** 二次验证 / 短信 / 本人设备确认:任何情况下都不自动处理(视觉子流程里也一样),立即转人工 */
export const TWO_FACTOR =
/二次验证|两步验证|二步验证|双重验证|短信验证码|验证码已发送|本人设备|设备确认|身份验证器|2fa|two-?step|two-?factor|one-time (?:code|password)|authenticator app|verification code (?:has been |was )?sent|confirm (?:it'?s|this is) you|approve (?:the )?sign.?in/i;
const iso = (d: Date | string | null | undefined) => (d ? new Date(d).toISOString() : null);
type Json = Record<string, unknown>;
const obj = (v: unknown): Json => (v && typeof v === "object" && !Array.isArray(v) ? (v as Json) : {});
export interface RecoveryState {
currentTabId?: string | null;
needsSnapshot?: boolean;
waitingReason?: string | null;
waitingDecisionId?: string | null;
releasedAt?: string;
downloads?: { sha256: string; artifactId: string; sourceReleaseId: string | null; planId: string | null }[];
/** BR-2:打开时的凭据注入结果与最近一次回写检查 */
credential?: BrowserCredentialState;
/** BR-3:上游上下文已不在(浏览器服务重启),下一次操作前先重连 */
needsReconnect?: boolean;
reconnectTrigger?: BrowserReconnectState["trigger"];
/** 打开 / 重连时浏览器服务的实例标识;变了 = 浏览器服务重启过 */
instanceId?: string | null;
reconnect?: BrowserReconnectState;
/** 最近一次自动化(Agent 能力)动作成功的时间;原站视图据此区分「Agent 正在操作」与「无人操作」 */
lastAutomationAt?: string;
}
/** 同一浏览器服务的实例标识多久查一次 */
const INSTANCE_CHECK_MS = 5_000;
/** 自动重连失败后,操作触发的重连至少间隔多久 */
const RECONNECT_BACKOFF_MS = 10_000;
/** 上游说标签页 / 会话不在了:404 / 410,或适配器内存里已没有打开的标签页 */
function isGone(e: unknown): boolean {
return (
e instanceof BrowserError &&
e.kind === "not_found" &&
(e.httpStatus === undefined || e.httpStatus === 404 || e.httpStatus === 410)
);
}
/** 有活动的会话每隔多久从浏览器读回一次 Cookie */
export const COOKIE_SYNC_INTERVAL_MS = 10 * 60_000;
/** 环境变量(BROWSER_PROVIDER_URL)登记的浏览器服务 */
export const ENV_PROVIDER_NAME = "camofox-env";
const ENV_MANAGED = "env";
export class BrowserService {
private readonly providers = new Map<string, { sig: string; provider: BrowserProvider }>();
private envSync: Promise<void> | undefined;
readonly now: () => Date;
constructor(
readonly ctx: ServerContext,
readonly opts: BrowserModuleOptions = {},
) {
this.now = opts.now ?? (() => new Date());
// refresh-login 发现 Cookie 失效时从浏览器 profile 读回一次(sources-identity 经钩子调用,不直接依赖本模块)
registerBrowserCookieReader(ctx, (siteId, accountId) => this.readProfileCookie(siteId, accountId));
// 站点请求 UA 跟随浏览器固定身份(BR-4):sources-identity 经钩子取
registerBrowserIdentityReader(ctx, () => this.identityUserAgent());
}
// ---------- BR-4 浏览器身份与出口 ----------
/** 身份读取失败后多久再试(浏览器服务没起 / 旧镜像时不每条站点请求都去问) */
private identityRetryAt = 0;
/**
* 浏览器固定身份的 UA:库里 browser_provider.identity 6 小时内读过就用它;否则问浏览器服务并写回。
* 没有启用的浏览器服务 / 插件不支持时返回库里的旧值或 null。
*/
async identityUserAgent(): Promise<string | null> {
const prow = await this.providerRow().catch(() => null);
if (!prow) return null;
const stored = obj(prow.identity) as { userAgent?: string; fetchedAt?: string };
const age = stored.fetchedAt ? Date.now() - Date.parse(stored.fetchedAt) : Number.POSITIVE_INFINITY;
if (stored.userAgent && age < 6 * 3_600_000) return stored.userAgent;
if (Date.now() < this.identityRetryAt) return stored.userAgent ?? null;
const fresh = await this.refreshIdentity(prow).catch(() => null);
return fresh?.userAgent ?? stored.userAgent ?? null;
}
/**
* 读浏览器服务的固定身份写进 browser_provider.identity(UA / OS / 版本 / 生成时间 + 读取时间)。
* maxAgeMs:库里的值比这新就不问。失败 5 分钟内不重试。
*/
async refreshIdentity(prow: BrowserProviderRow, opts: { maxAgeMs?: number } = {}): Promise<BrowserIdentity | null> {
const stored = obj(prow.identity) as Partial<BrowserIdentity> & { fetchedAt?: string };
if (opts.maxAgeMs !== undefined && stored.userAgent && stored.fetchedAt) {
if (Date.now() - Date.parse(stored.fetchedAt) < opts.maxAgeMs) return stored as BrowserIdentity;
}
const provider = this.providerFor(prow);
if (!provider.identity) return null;
let id: BrowserIdentity | null;
try {
id = await provider.identity();
} catch (e) {
this.identityRetryAt = Date.now() + 5 * 60_000;
throw e;
}
if (!id) {
this.identityRetryAt = Date.now() + 5 * 60_000;
return null;
}
this.identityRetryAt = 0;
await this.ctx.db
.updateTable("browser_provider")
.set({ identity: JSON.stringify({ ...id, fetchedAt: this.now().toISOString() }) })
.where("id", "=", prow.id)
.execute();
if (stored.userAgent !== id.userAgent)
this.ctx.logger.info(
{ component: "browser", providerId: prow.id, os: id.os, firefoxVersion: id.firefoxVersion },
"浏览器身份已更新,站点请求 UA 跟随",
);
return id;
}
/**
* 出口按站点统一(BR-4):站点开了代理且设置了代理地址 → 该 profile 走同一代理;否则直连。
* 旧镜像(插件没有出口接口)只记日志,浏览器照旧走容器环境变量里的代理。
*/
private async applyEgress(provider: BrowserProvider, site: SiteRow, profileKey: string): Promise<void> {
if (!provider.setEgress) return;
const rt = this.opts.runtime?.(this.ctx);
const proxyUrl = rt
? (await rt.siteDefaults()).proxyUrl
: ((await this.ctx.settings.get("sites").catch(() => null))?.proxyUrl ?? null);
const proxy = site.proxy && proxyUrl ? egressProxy(proxyUrl) : null;
try {
const r = await provider.setEgress(profileKey, proxy);
if (r.restarted)
this.ctx.logger.info(
{ component: "browser", siteId: site.id, egress: proxy ? "proxy" : "direct" },
"浏览器会话出口已变更,按新出口重建",
);
} catch (e) {
this.ctx.logger.warn(
{
component: "browser",
siteId: site.id,
egress: proxy ? "proxy" : "direct",
err: String((e as Error)?.message ?? e),
},
"设置浏览器会话出口失败,浏览器按容器环境变量出网",
);
}
}
// ---------- 浏览器服务 ----------
private stagingDir(): string {
if (this.opts.stagingDir) return this.opts.stagingDir;
const dataDir = (this.ctx.config as { DATA_DIR?: string }).DATA_DIR ?? "./data";
return join(resolve(dataDir), "tmp", "browser-downloads");
}
/** 下载产物落 DATA_DIR/tmp/browser-downloads(0600);超过 24 小时的顺手清掉 */
private sink(): DownloadSink {
return async (bytes, meta) => {
const dir = this.stagingDir();
await mkdir(dir, { recursive: true, mode: 0o700 });
await this.cleanupStaging(dir);
const name = `${meta.id}.bin`;
await writeFile(join(dir, name), bytes, { mode: 0o600 });
return { kind: "blob", ref: `browser-downloads/${name}` };
};
}
private async cleanupStaging(dir: string): Promise<void> {
const cutoff = Date.now() - 24 * 3_600_000;
for (const f of await readdir(dir).catch(() => [] as string[])) {
const p = join(dir, f);
const st = await stat(p).catch(() => null);
if (st && st.mtimeMs < cutoff) await rm(p, { force: true }).catch(() => {});
}
}
stagedPath(ref: string): string {
return join(this.stagingDir(), ref.replace(/^browser-downloads\//, "").replace(/[/\\]/g, "_"));
}
/** 凭证解析:浏览器服务访问凭据与站点账号登录字段;明文不缓存、不入日志 */
private secretsFor(row: BrowserProviderRow): BrowserSecretResolver {
const { db, secrets } = this.ctx;
return {
resolve: async (req) => {
if (req.kind === "provider_access_key") {
if (row.secret_ref) return secrets.resolve(row.secret_ref);
// 环境变量登记的服务:凭据只在进程内(BROWSER_ACCESS_KEY),不入库
return obj(row.config).managedBy === ENV_MANAGED ? (this.ctx.config?.BROWSER_ACCESS_KEY ?? null) : null;
}
let q = db
.selectFrom("site_account")
.selectAll()
.where("site_id", "=", req.siteId)
.where("enabled", "=", true)
.where("auth_kind", "=", "password");
if (req.accountId) q = q.where("id", "=", req.accountId);
const acc = await q.orderBy("created_at").executeTakeFirst();
if (!acc) return null;
if (req.field === "username")
return acc.login_name ?? (obj(acc.user_info).username as string | undefined) ?? null;
if (req.field === "password") return acc.secret_ref ? secrets.resolve(acc.secret_ref) : null;
return acc.totp_secret_ref ? totpCode(await secrets.resolve(acc.totp_secret_ref), this.now()) : null;
},
};
}
providerFor(row: BrowserProviderRow): BrowserProvider {
const sig = JSON.stringify([row.base_url, row.secret_ref, row.config, row.kind]);
const hit = this.providers.get(row.id);
if (hit && hit.sig === sig) return hit.provider;
const deps: ProviderDeps = {
secrets: this.secretsFor(row),
sink: this.sink(),
...(this.opts.fetch ? { fetch: this.opts.fetch } : {}),
...(this.opts.downloadPollMs ? { downloadPollMs: this.opts.downloadPollMs } : {}),
};
const cfg = obj(row.config);
const provider =
this.opts.providerFactory?.(row, deps) ??
createCamofoxBrowserProvider({
providerId: row.id,
baseUrl: row.base_url ?? "",
secrets: deps.secrets,
downloadSink: deps.sink,
verified: obj(cfg.verified) as Record<string, boolean>,
...(typeof cfg.requestTimeoutMs === "number" ? { timeoutMs: cfg.requestTimeoutMs } : {}),
...(deps.fetch ? { fetch: deps.fetch } : {}),
...(deps.downloadPollMs ? { downloadPollMs: deps.downloadPollMs } : {}),
now: this.now,
logger: (e) => this.ctx.logger[e.level === "warn" ? "warn" : "debug"]({ component: "browser", ...e }, e.msg),
});
this.providers.set(row.id, { sig, provider });
return provider;
}
/**
* BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED → 名为 camofox-env 的浏览器服务(每个进程同步一次)。
* 地址与启用由环境变量决定;config 里的其它键(verified、超时等)保留。没设地址时已有的 camofox-env 置为停用。
*/
syncEnvProvider(): Promise<void> {
this.envSync ??= this.doSyncEnvProvider().then(
(probe) => {
// 首次登记后在后台探测一次,写回能力与上游版本(不阻塞当前请求;camofox 未就绪时下次"测试连通"再写)
if (probe) void this.listProviders(true).catch(() => {});
},
(e) => {
this.envSync = undefined;
this.ctx.logger.warn({ component: "browser", err: String(e) }, "登记环境变量浏览器服务失败");
},
);
return this.envSync;
}
/** 返回是否需要后台探测(已启用且还没有探测过能力) */
private async doSyncEnvProvider(): Promise<boolean> {
const c = this.ctx.config;
const url = c?.BROWSER_PROVIDER_URL;
const enabled = !!url && c.BROWSER_ENABLED !== false;
const db = this.ctx.db;
const row = await db
.selectFrom("browser_provider")
.selectAll()
.where("name", "=", ENV_PROVIDER_NAME)
.executeTakeFirst();
if (!url) {
if (row?.enabled)
await db.updateTable("browser_provider").set({ enabled: false }).where("id", "=", row.id).execute();
return false;
}
const config = JSON.stringify({ ...obj(row?.config), managedBy: ENV_MANAGED });
if (row)
await db
.updateTable("browser_provider")
.set({ base_url: url, enabled, kind: "camofox", secret_ref: null, config })
.where("id", "=", row.id)
.execute();
else
await db
.insertInto("browser_provider")
.values({ name: ENV_PROVIDER_NAME, kind: "camofox", base_url: url, enabled, config })
.onConflict((oc) => oc.column("name").doNothing())
.execute();
return enabled && !Object.keys(obj(row?.capabilities)).length;
}
/** 设置页登记浏览器服务:访问凭据加密存 secret;名称 camofox-env 保留给环境变量 */
async createProvider(body: {
name: string;
baseUrl: string;
accessKey?: string | undefined;
enabled?: boolean | undefined;
}): Promise<BrowserProviderView> {
if (body.name === ENV_PROVIDER_NAME)
throw new BrowserServiceError("bad_request", `名称 ${ENV_PROVIDER_NAME} 保留给环境变量`);
const exists = await this.ctx.db
.selectFrom("browser_provider")
.select("id")
.where("name", "=", body.name)
.executeTakeFirst();
if (exists) throw new BrowserServiceError("conflict", `浏览器服务「${body.name}」已存在`);
const accessKey = secretInput(body.accessKey);
const secretRef = accessKey
? await this.ctx.secrets.create({ purpose: "other", plaintext: accessKey, label: `browser:${body.name}` })
: null;
const row = await this.ctx.db
.insertInto("browser_provider")
.values({
name: body.name,
kind: "camofox",
base_url: body.baseUrl,
secret_ref: secretRef,
enabled: body.enabled ?? true,
})
.returning("id")
.executeTakeFirstOrThrow();
return this.providerView(row.id);
}
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据。camofox-env 的地址与凭据归环境变量管,只能改启用 */
async updateProvider(
id: string,
input: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
): Promise<BrowserProviderView> {
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const body = { ...input, accessKey: secretInput(input.accessKey) };
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
const changesAddr = body.baseUrl !== undefined && body.baseUrl !== row.base_url;
if (obj(row.config).managedBy === ENV_MANAGED && (changesAddr || body.accessKey !== undefined))
throw new BrowserServiceError(
"conflict",
"该浏览器服务由环境变量 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 管理,改部署配置后重启",
);
let secretRef = row.secret_ref;
if (body.accessKey === null) secretRef = null;
else if (body.accessKey) {
if (secretRef) await this.ctx.secrets.replace(secretRef, body.accessKey);
else
secretRef = await this.ctx.secrets.create({
purpose: "other",
plaintext: body.accessKey,
label: `browser:${row.name}`,
});
}
await this.ctx.db
.updateTable("browser_provider")
.set({
...(body.baseUrl !== undefined ? { base_url: body.baseUrl } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
secret_ref: secretRef,
})
.where("id", "=", id)
.execute();
if (row.secret_ref && secretRef === null) await this.ctx.secrets.remove(row.secret_ref).catch(() => {});
// 凭据换了但 secret_ref 不变:签名不变,显式丢弃缓存的适配器
this.providers.delete(id);
return this.providerView(id);
}
/** 删除设置页登记的服务:有未关闭会话时拒绝(先关会话);环境变量登记的只能停用 */
async deleteProvider(id: string): Promise<{ id: string }> {
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
if (obj(row.config).managedBy === ENV_MANAGED)
throw new BrowserServiceError("conflict", "该浏览器服务由环境变量登记,只能停用(BROWSER_ENABLED=false)");
const open = await this.ctx.db
.selectFrom("browser_session")
.select("id")
.where("provider_id", "=", id)
.where("controller", "<>", "closed")
.executeTakeFirst();
if (open) throw new BrowserServiceError("conflict", "该浏览器服务还有未关闭的会话,先关闭会话");
await this.ctx.db.deleteFrom("browser_provider").where("id", "=", id).execute();
if (row.secret_ref) await this.ctx.secrets.remove(row.secret_ref).catch(() => {});
this.providers.delete(id);
return { id };
}
private async providerView(id: string): Promise<BrowserProviderView> {
const v = (await this.listProviders(false, id))[0];
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
return v;
}
async providerRow(id?: string): Promise<BrowserProviderRow> {
await this.syncEnvProvider();
let q = this.ctx.db
.selectFrom("browser_provider")
.selectAll()
.where("enabled", "=", true)
.where("kind", "<>", "none");
if (id) q = q.where("id", "=", id);
const row = await q.orderBy("created_at").executeTakeFirst();
if (!row)
throw new BrowserServiceError("precondition", "没有启用的浏览器服务(camofox),见 docs/runbook/browser.md");
return row;
}
async listProviders(probe: boolean, onlyId?: string): Promise<BrowserProviderView[]> {
await this.syncEnvProvider();
let rows = await this.ctx.db.selectFrom("browser_provider").selectAll().orderBy("created_at").execute();
if (onlyId) rows = rows.filter((r) => r.id === onlyId);
const out: BrowserProviderView[] = [];
for (let row of rows) {
let health: BrowserHealth | null = null;
if (probe && (row.enabled || onlyId) && row.kind !== "none") {
const p = this.providerFor(row);
health = await p.health();
let caps: BrowserCapabilities | null = null;
try {
caps = await p.capabilities({ probe: health.status !== "down" });
} catch (e) {
caps = await p.capabilities();
// /health 免认证,只看它会把"凭据错了"显示成健康:探测被 401/403 拒绝时标为离线并注明
if (e instanceof BrowserError && e.kind === "auth")
health = { ...health, status: "down", detail: { ...health.detail, error: "auth" } };
}
row = await this.ctx.db
.updateTable("browser_provider")
.set({
capabilities: JSON.stringify(caps),
// 上游版本有真机核验记录时,verified_at 取核验日期(不是探测时间)
...(caps.upstreamVersion && CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]
? { verified_at: new Date(`${CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]!.verifiedAt}T00:00:00Z`) }
: {}),
health_status: HEALTH[health.status],
last_health_at: new Date(health.checkedAt),
})
.where("id", "=", row.id)
.returningAll()
.executeTakeFirstOrThrow();
// 探测时顺便读一次浏览器身份(站点请求 UA 跟随它,BR-4)
if (health.status !== "down" && row.enabled) await this.refreshIdentity(row).catch(() => null);
}
const caps = obj(row.capabilities);
out.push({
id: row.id,
name: row.name,
kind: row.kind as "camofox" | "none",
baseUrl: row.base_url,
secretRef: row.secret_ref,
managedBy: obj(row.config).managedBy === ENV_MANAGED ? "env" : null,
hasAccessKey:
!!row.secret_ref || (obj(row.config).managedBy === ENV_MANAGED && !!this.ctx.config?.BROWSER_ACCESS_KEY),
enabled: row.enabled,
capabilities: Object.keys(caps).length ? (caps as unknown as BrowserCapabilities) : null,
verifiedAt: iso(row.verified_at),
healthStatus: row.health_status as BrowserProviderView["healthStatus"],
lastHealthAt: iso(row.last_health_at),
health,
});
}
return out;
}
// ---------- 站点与预算 ----------
async liveSite(siteId: string): Promise<SiteRow> {
const s = await this.ctx.db
.selectFrom("site")
.selectAll()
.where("id", "=", siteId)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (!s) throw new BrowserServiceError("not_found", "站点不存在");
return s;
}
/** 导航预算:站点域名(+ config.browserDomains)、超时、重试、字节上限(浏览器服务 config 可覆盖) */
budgetFor(site: SiteRow, provider: BrowserProviderRow): NavigationBudget {
const cfg = obj(provider.config);
const extra = obj(site.config).browserDomains;
const hosts = [new URL(site.base_url).hostname, ...(Array.isArray(extra) ? extra.map(String) : [])];
return {
domainAllowlist: hosts,
timeoutMs: typeof cfg.navigationTimeoutMs === "number" ? cfg.navigationTimeoutMs : 30_000,
maxRetries: typeof cfg.maxRetries === "number" ? cfg.maxRetries : 2,
maxBytes: typeof cfg.snapshotMaxBytes === "number" ? cfg.snapshotMaxBytes : 512 * 1024,
};
}
/** 站点限流对快照、实时搜索和浏览器统一生效(REQ §4.6):导航前取同一个限流令牌 */
private async rateLimit(site: SiteRow): Promise<void> {
const rt = this.opts.runtime?.(this.ctx);
if (!rt) return;
try {
rt.siteAdapter(site);
} catch {
return; // 没有随产品发布的适配器定义:无站点限流器
}
await rt.limiterOf(site.id)?.acquire();
}
// ---------- 会话 ----------
private async sessionRow(id: string): Promise<BrowserSessionRow> {
const row = await this.ctx.db.selectFrom("browser_session").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new BrowserServiceError("not_found", "浏览器会话不存在");
return row;
}
private async activeLease(sessionId: string): Promise<BrowserControlLeaseRow | undefined> {
return this.ctx.db
.selectFrom("browser_control_lease")
.selectAll()
.where("session_id", "=", sessionId)
.where("status", "=", "active")
.executeTakeFirst();
}
/** 会话加载进适配器(服务重启后按数据库恢复);返回 provider 与最新行 */
private async loaded(row: BrowserSessionRow): Promise<{ provider: BrowserProvider; row: BrowserSessionRow }> {
const prow = await this.ctx.db
.selectFrom("browser_provider")
.selectAll()
.where("id", "=", row.provider_id)
.executeTakeFirst();
if (!prow?.enabled) {
await this.closeRow(row, "provider_disabled");
throw new BrowserServiceError("precondition", "浏览器服务已停用,会话已关闭");
}
const site = await this.liveSite(row.site_id).catch(() => null);
if (!site?.enabled) {
// 站点关闭即停止其网络访问(REQ §4.6、§4.7.3)
await this.closeRow(row, "site_disabled", prow);
throw new BrowserServiceError("precondition", "站点已停用,浏览器会话已关闭");
}
const provider = this.providerFor(prow);
// api / worker 各有一份适配器内存状态:另一个进程换了当前标签页(回收后重开)时,按数据库重新载入
const info = provider.sessionInfo(row.id);
const dbTab = (obj(row.recovery_state) as RecoveryState).currentTabId ?? null;
if (!info || (dbTab !== null && info.currentTabId !== dbTab)) {
const tabs = await this.tabRows(row.id);
provider.restoreSession({
session: toSession(row),
tabs: tabs.map(toTab),
currentTabId: (obj(row.recovery_state) as RecoveryState).currentTabId ?? null,
budget: this.budgetFor(site, prow),
});
// 重启后旧引用失效:Agent 必须重新读取页面
row = await this.patchRecovery(row, { needsSnapshot: true });
}
return { provider, row };
}
private tabRows(sessionId: string): Promise<BrowserTabRow[]> {
return this.ctx.db
.selectFrom("browser_tab")
.selectAll()
.where("session_id", "=", sessionId)
.orderBy("created_at")
.execute();
}
private async patchRecovery(row: BrowserSessionRow, patch: RecoveryState): Promise<BrowserSessionRow> {
return this.ctx.db
.updateTable("browser_session")
.set({ recovery_state: JSON.stringify({ ...obj(row.recovery_state), ...patch }), updated_at: this.now() })
.where("id", "=", row.id)
.returningAll()
.executeTakeFirstOrThrow();
}
/** 适配器内存状态 → 数据库(会话地址/活动时间/控制者、标签页) */
private async persist(sessionId: string, provider: BrowserProvider, patch: RecoveryState = {}): Promise<void> {
const info = provider.sessionInfo(sessionId);
if (!info) return;
const row = await this.sessionRow(sessionId);
await this.ctx.db.transaction().execute(async (trx) => {
await trx
.updateTable("browser_session")
.set({
current_url: info.session.currentUrl,
last_activity_at: new Date(info.session.lastActivityAt),
recovery_state: JSON.stringify({
...obj(row.recovery_state),
currentTabId: info.currentTabId,
...patch,
}),
updated_at: this.now(),
})
.where("id", "=", sessionId)
.execute();
for (const t of info.tabs)
await trx
.insertInto("browser_tab")
.values({
id: t.id,
session_id: sessionId,
upstream_tab_id: t.upstreamTabId,
url: t.url,
title: t.title,
status: t.status,
last_snapshot_at: t.lastSnapshotAt ? new Date(t.lastSnapshotAt) : null,
closed_at: t.closedAt ? new Date(t.closedAt) : null,
})
.onConflict((oc) =>
oc.columns(["session_id", "upstream_tab_id"]).doUpdateSet({
url: t.url,
status: t.status,
last_snapshot_at: t.lastSnapshotAt ? new Date(t.lastSnapshotAt) : null,
closed_at: t.closedAt ? new Date(t.closedAt) : null,
updated_at: this.now(),
}),
)
.execute();
});
}
async view(id: string): Promise<BrowserSessionView> {
let row = await this.sessionRow(id);
row = await this.expireLeases(row);
// 原站视图轮询会话详情:顺带发现浏览器服务重启(节流,失败不影响详情)
if (row.controller !== "closed" && (await this.checkInstance(row.provider_id).catch(() => false)))
row = await this.sessionRow(id);
const [tabs, lease] = await Promise.all([this.tabRows(id), this.activeLease(id)]);
const rs = obj(row.recovery_state) as RecoveryState;
return {
...toSession(row),
tabs: tabs.map(toTab),
currentTabId: rs.currentTabId ?? tabs.find((t) => t.status === "open")?.upstream_tab_id ?? null,
lease: lease ? toLease(lease) : null,
needsSnapshot: rs.needsSnapshot === true,
waitingReason: row.controller === "waiting" || row.controller === "human" ? (rs.waitingReason ?? null) : null,
credential: rs.credential ?? null,
needsReconnect: rs.needsReconnect === true,
reconnect: rs.reconnect ?? null,
lastAutomationAt: rs.lastAutomationAt ?? null,
};
}
async list(q: { siteId?: string; includeClosed?: boolean }): Promise<BrowserSessionView[]> {
let sel = this.ctx.db.selectFrom("browser_session").select("id").orderBy("last_activity_at", "desc").limit(100);
if (q.siteId) sel = sel.where("site_id", "=", q.siteId);
if (!q.includeClosed) sel = sel.where("controller", "<>", "closed");
const rows = await sel.execute();
return Promise.all(rows.map((r) => this.view(r.id)));
}
/** 站点(账号)对应的未关闭会话 */
async liveSessionFor(siteId: string, accountId?: string | null): Promise<BrowserSessionRow | undefined> {
let q = this.ctx.db
.selectFrom("browser_session")
.selectAll()
.where("site_id", "=", siteId)
.where("controller", "<>", "closed");
if (accountId !== undefined)
q = accountId === null ? q.where("account_id", "is", null) : q.where("account_id", "=", accountId);
return q.orderBy("last_activity_at", "desc").executeTakeFirst();
}
/** 站点缺省账号:第一个启用的非 passkey 账号 */
private async defaultAccount(siteId: string): Promise<string | null> {
const a = await this.ctx.db
.selectFrom("site_account")
.select("id")
.where("site_id", "=", siteId)
.where("enabled", "=", true)
.where("auth_kind", "<>", "passkey")
.orderBy("created_at")
.executeTakeFirst();
return a?.id ?? null;
}
async open(req: { siteId: string; accountId?: string | null; providerId?: string; url?: string }): Promise<string> {
const site = await this.liveSite(req.siteId);
if (!site.enabled) throw new BrowserServiceError("precondition", "站点已停用");
const prow = await this.providerRow(req.providerId);
const accountId = req.accountId === undefined ? await this.defaultAccount(site.id) : req.accountId;
const profileKey = profileKeyFor(site.id, accountId);
const existing = await this.ctx.db
.selectFrom("browser_session")
.selectAll()
.where("provider_id", "=", prow.id)
.where("profile_key", "=", profileKey)
.where("controller", "<>", "closed")
.executeTakeFirst();
if (existing) {
await this.loaded(existing);
return existing.id;
}
const budget = this.budgetFor(site, prow);
const provider = this.providerFor(prow);
// 数据库里已没有未关闭的会话:本进程内存里同 profile 还开着的是别的进程关掉的,标关闭,否则适配器拒绝新开
for (const m of await provider.listSessions())
if (m.profileKey === profileKey && m.controller !== "closed") provider.setController(m.id, "closed");
const prep = await this.prepareCookies(site, accountId);
await this.applyEgress(provider, site, profileKey);
await this.refreshIdentity(prow, { maxAgeMs: 10 * 60_000 }).catch(() => null);
await this.rateLimit(site);
let res: Awaited<ReturnType<BrowserProvider["openSession"]>>;
try {
res = await provider.openSession({
providerId: prow.id,
siteId: site.id,
accountId,
profileKey,
initialUrl: req.url ?? site.base_url,
budget,
...(prep.cookies.length ? { cookies: prep.cookies } : {}),
});
} catch (e) {
throw fromBrowserError(e);
}
const s = res.session;
const instanceId = await this.currentInstance(prow, true).catch(() => null);
const credential: BrowserCredentialState = { ...prep.state };
if (res.cookies) {
credential.status = res.cookies.status;
credential.count = res.cookies.count;
if (res.cookies.status === "unsupported")
credential.message = "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件),没有注入账号登录态";
else if (res.cookies.status === "failed") credential.message = "注入账号 Cookie 失败";
}
// 注入了 Cookie 仍落在登录页:Cookie 过期。Cookie 账号(没有密码可自动登录)转"需要人工登录":在画面里登录一次,
// 交还 / 关闭时回写,全站生效;账密账号留给 Agent 用凭证注入登录(browser.interact credential),只记状态
const loginPage = provider.sessionInfo(s.id)?.loginPage === true;
const needHuman = credential.status === "injected" && loginPage && prep.authKind === "cookie";
if (credential.status === "injected" && loginPage) {
credential.status = "expired";
credential.message = "账号登录态已失效:接管后在画面里登录一次,交还后 HTTP 抓取、RSS、快照都用新的登录态";
}
await this.ctx.db.transaction().execute(async (trx) => {
await trx
.insertInto("browser_session")
.values({
id: s.id,
provider_id: prow.id,
site_id: site.id,
account_id: accountId,
profile_key: profileKey,
controller: "automation",
current_url: s.currentUrl,
last_activity_at: new Date(s.lastActivityAt),
upstream_ref: JSON.stringify(s.upstreamRef),
recovery_state: JSON.stringify({
currentTabId: res.tab.upstreamTabId,
needsSnapshot: true,
credential,
instanceId,
}),
})
.execute();
await trx
.insertInto("browser_tab")
.values({
id: res.tab.id,
session_id: s.id,
upstream_tab_id: res.tab.upstreamTabId,
url: res.tab.url,
status: "open",
})
.execute();
});
if (needHuman) await this.enterWaiting(await this.sessionRow(s.id), "login_required");
return s.id;
}
// ---------- 凭据统一(BR-2) ----------
/**
* 打开会话前准备要注入的 Cookie:匿名会话与 API Key 类站点(馒头:网页登录态与 API Key 无关)不注入;
* 账密账号还没有会话 Cookie 时先走一次 refresh-login(与站点中心"刷新登录"同一流程)再取。
*/
private async prepareCookies(
site: SiteRow,
accountId: string | null,
): Promise<{ cookies: BrowserCookie[]; state: BrowserCredentialState; authKind: string | null }> {
const state = (
status: BrowserCredentialState["status"],
message: string | null,
extra: Partial<BrowserCredentialState> = {},
): BrowserCredentialState => ({
status,
source: null,
sourceAt: null,
count: 0,
message,
syncedAt: null,
...extra,
});
if (!accountId)
return { cookies: [], authKind: null, state: state("not_applicable", "匿名会话,不注入账号登录态") };
const kind = (getSiteDefinition(site.key)?.umt as { kind?: string } | undefined)?.kind;
let acc = await readAccountCookie(this.ctx, accountId);
if (!acc?.applicable || kind === "mteam")
return {
cookies: [],
authKind: acc?.authKind ?? null,
state: state("not_applicable", "API Key 类站点 / 账号:网页登录态与 API Key 无关,不注入;需要时在桌面里登录"),
};
const rt = this.opts.runtime?.(this.ctx);
if (!acc.header && acc.authKind === "password" && rt) {
await refreshSiteAccount(rt, accountId, { mode: "login" }).catch(() => null);
acc = (await readAccountCookie(this.ctx, accountId)) ?? acc;
}
if (!acc.header)
return { cookies: [], authKind: acc.authKind, state: state("none", "账号还没有可用的登录态(Cookie)") };
const cookies = cookiesFromHeader(acc.header, site.base_url, Math.floor(this.now().getTime() / 1000));
return {
cookies,
authKind: acc.authKind,
state: state("injected", null, {
source: acc.source,
sourceAt: acc.sourceAt ? acc.sourceAt.toISOString() : null,
count: cookies.length,
}),
};
}
/** 从该站点 / 账号的浏览器 profile 读回站点域名的 Cookie 头串(没有启用的浏览器服务或插件时 null) */
async readProfileCookie(siteId: string, accountId: string): Promise<string | null> {
const prow = await this.providerRow().catch(() => null);
if (!prow) return null;
const provider = this.providerFor(prow);
if (!provider.getCookies) return null;
const site = await this.liveSite(siteId);
const list = await provider.getCookies(profileKeyFor(siteId, accountId), [site.base_url]);
return headerFromCookies(list, site.base_url, Math.floor(this.now().getTime() / 1000));
}
/**
* 回写:从会话的浏览器 profile 读回站点域名的 Cookie,与凭据存储比较,有变化则更新(来源 browser)。
* 当前页面是登录页时不回写(未登录的 Cookie 不能覆盖可能仍有效的登录态)。失败只记在会话状态里,不抛出。
*/
async syncCookies(id: string, why: "release" | "close" | "periodic"): Promise<{ changed: boolean } | null> {
try {
const row = await this.sessionRow(id);
if (!row.account_id) return null;
const provider = this.providers.get(row.provider_id)?.provider;
if (!provider?.getCookies) return null;
const rs = obj(row.recovery_state) as RecoveryState;
if (rs.credential?.status === "not_applicable") return null;
const at = this.now();
const patch = async (c: Partial<BrowserCredentialState>) => {
const cur = obj((await this.sessionRow(id)).recovery_state) as RecoveryState;
const base: BrowserCredentialState = cur.credential ?? {
status: "none",
source: null,
sourceAt: null,
count: 0,
message: null,
syncedAt: null,
};
await this.ctx.db
.updateTable("browser_session")
.set({
recovery_state: JSON.stringify({ ...cur, credential: { ...base, syncedAt: at.toISOString(), ...c } }),
})
.where("id", "=", id)
.execute();
};
// 先按上游同步当前地址(桌面里的导航不经 API),再判断是否停在登录页
if (provider.sessionInfo(id)) {
await provider.reconcileTabs(id).catch(() => null);
await this.persist(id, provider).catch(() => {});
}
if (provider.sessionInfo(id)?.loginPage === true) {
await patch({});
return { changed: false };
}
const site = await this.liveSite(row.site_id);
let header: string | null;
try {
header = headerFromCookies(
await provider.getCookies(row.profile_key, [site.base_url]),
site.base_url,
Math.floor(at.getTime() / 1000),
);
} catch (e) {
if (e instanceof BrowserError && e.kind === "not_implemented")
await patch({ message: "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件),浏览器里的登录不会回写" });
return null;
}
if (!header) {
await patch({});
return { changed: false };
}
const r = await writeBrowserCookie(this.ctx, row.account_id, header, at);
await patch(
r.changed
? {
source: "browser",
sourceAt: at.toISOString(),
...(rs.credential?.status === "expired" ? { status: "injected" as const, message: null } : {}),
}
: {},
);
if (r.changed)
this.ctx.logger.info(
{ component: "browser", sessionId: id, why, count: r.count },
"浏览器登录态已回写站点账号",
);
return { changed: r.changed };
} catch (e) {
this.ctx.logger.warn(
{ component: "browser", sessionId: id, why, err: String((e as Error)?.message ?? e) },
"浏览器 Cookie 回写失败",
);
return null;
}
}
private async closeRow(row: BrowserSessionRow, reason: string, prow?: BrowserProviderRow): Promise<void> {
const provider = prow ? this.providerFor(prow) : this.providers.get(row.provider_id)?.provider;
// 关闭前趁上下文还在读回一次登录态(站点 / 浏览器服务停用时不再访问)
if (provider?.sessionInfo(row.id) && reason !== "site_disabled" && reason !== "provider_disabled")
await this.syncCookies(row.id, "close");
if (provider?.sessionInfo(row.id)) await provider.closeSession(row.id, reason).catch(() => {});
const at = this.now();
await this.ctx.db.transaction().execute(async (trx) => {
await trx
.updateTable("browser_session")
.set({ controller: "closed", closed_at: at, close_reason: reason, updated_at: at })
.where("id", "=", row.id)
.where("controller", "<>", "closed")
.execute();
await trx
.updateTable("browser_tab")
.set({ status: "closed", closed_at: at, updated_at: at })
.where("session_id", "=", row.id)
.where("status", "=", "open")
.execute();
await trx
.updateTable("browser_control_lease")
.set({ status: "revoked", released_at: at, release_reason: "session_closed", updated_at: at })
.where("session_id", "=", row.id)
.where("status", "=", "active")
.execute();
});
await this.resolveWaiting(row, "session_closed");
}
async close(id: string, reason = "closed"): Promise<void> {
const row = await this.sessionRow(id);
if (row.controller === "closed") return;
const prow = await this.ctx.db
.selectFrom("browser_provider")
.selectAll()
.where("id", "=", row.provider_id)
.executeTakeFirst();
if (prow?.enabled) {
const provider = this.providerFor(prow);
if (!provider.sessionInfo(row.id)) {
const site = await this.liveSite(row.site_id).catch(() => null);
if (site)
provider.restoreSession({
session: toSession(row),
tabs: (await this.tabRows(row.id)).map(toTab),
currentTabId: null,
budget: this.budgetFor(site, prow),
});
}
}
await this.closeRow(row, reason, prow);
}
// ---------- 控制权 ----------
/** 过期的人工租约 → expired,会话转等待处理(不自动从未知页面继续) */
async expireLeases(row: BrowserSessionRow): Promise<BrowserSessionRow> {
if (row.controller === "closed") return row;
const lease = await this.activeLease(row.id);
if (!lease || new Date(lease.expires_at).getTime() > this.now().getTime()) return row;
const at = this.now();
await this.ctx.db
.updateTable("browser_control_lease")
.set({ status: "expired", released_at: at, release_reason: "ttl", updated_at: at })
.where("id", "=", lease.id)
.where("status", "=", "active")
.execute();
if (lease.holder_kind !== "human") return row;
return this.enterWaiting(row, "lease_expired");
}
/** 转等待处理:自动化动作被拒,生成待处理事项,等人在原站视图接管 */
async enterWaiting(row: BrowserSessionRow, reason: string): Promise<BrowserSessionRow> {
const provider = this.providers.get(row.provider_id)?.provider;
if (provider?.sessionInfo(row.id)) provider.setController(row.id, "waiting");
const site = await this.liveSite(row.site_id).catch(() => null);
const rs = obj(row.recovery_state) as RecoveryState;
let decisionId = rs.waitingDecisionId ?? null;
if (!decisionId) {
const d = await this.ctx.db
.insertInto("decision")
.values({
kind: "other",
status: "awaiting_user",
severity: "warning",
title: `${site?.name ?? "站点"}:原站需要人工处理`,
summary: `${REASON_TEXT[reason] ?? reason}。在站点详情的原站视图接管会话,完成后交还。`,
subject_type: "browser_session",
subject_id: row.id,
dedupe_key: `browser.waiting:${row.id}`,
evidence: JSON.stringify({ siteId: row.site_id, reason, url: row.current_url }),
})
.onConflict((oc) =>
oc.column("dedupe_key").where("status", "in", ["open", "investigating", "awaiting_user"]).doNothing(),
)
.returning("id")
.executeTakeFirst();
decisionId = d?.id ?? null;
}
return this.ctx.db
.updateTable("browser_session")
.set({
controller: "waiting",
recovery_state: JSON.stringify({ ...rs, waitingReason: reason, waitingDecisionId: decisionId }),
updated_at: this.now(),
})
.where("id", "=", row.id)
.where("controller", "<>", "closed")
.returningAll()
.executeTakeFirstOrThrow();
}
private async resolveWaiting(row: BrowserSessionRow, by: string): Promise<void> {
const id = (obj(row.recovery_state) as RecoveryState).waitingDecisionId;
if (!id) return;
await this.ctx.db
.updateTable("decision")
.set({
status: "resolved",
resolved_at: this.now(),
resolved_by: by,
resolution: JSON.stringify({ via: "browser" }),
})
.where("id", "=", id)
.where("status", "in", ["open", "investigating", "awaiting_user"])
.execute();
}
/**
* 人工接管(REQ §4.7.3):先把适配器控制者切到 human(新的自动化输入立即被拒),
* 等该 profile 上进行中的操作收尾,再写租约;其他会话不受影响。
*/
async acquire(id: string, holder: string, reason: string, ttlMs: number): Promise<BrowserControlLease> {
let row = await this.expireLeases(await this.sessionRow(id));
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
const current = await this.activeLease(id);
if (current?.holder_kind === "human") {
if (current.holder === holder) return toLease(current);
throw new BrowserServiceError("conflict", `会话已由 ${holderName(current.holder)} 接管`);
}
const { provider } = await this.loaded(row);
provider.setController(id, "human");
try {
await provider.drain(id);
} catch (e) {
throw fromBrowserError(e);
}
const at = this.now();
row = await this.sessionRow(id);
const lease = await this.ctx.db.transaction().execute(async (trx) => {
if (current)
await trx
.updateTable("browser_control_lease")
.set({ status: "revoked", released_at: at, release_reason: "human_takeover", updated_at: at })
.where("id", "=", current.id)
.execute();
const l = await trx
.insertInto("browser_control_lease")
.values({
session_id: id,
holder_kind: "human",
holder,
reason,
status: "active",
acquired_at: at,
expires_at: new Date(at.getTime() + ttlMs),
})
.returningAll()
.executeTakeFirstOrThrow();
await trx
.updateTable("browser_session")
.set({
controller: "human",
recovery_state: JSON.stringify({
...obj(row.recovery_state),
takeoverAt: at.toISOString(),
previousController: row.controller,
}),
updated_at: at,
})
.where("id", "=", id)
.execute();
return l;
});
return toLease(lease);
}
/** 交还:回到自动化(或留在等待处理);Agent 恢复前必须重新读取页面,不复用旧引用、不重放旧点击 */
async release(
id: string,
opts: { leaseId?: string; reason: string; to: "automation" | "waiting" },
): Promise<BrowserControlLease> {
const row = await this.sessionRow(id);
const lease = await this.activeLease(id);
if (lease?.holder_kind !== "human" || (opts.leaseId && lease.id !== opts.leaseId))
throw new BrowserServiceError("conflict", "没有可交还的人工接管");
const at = this.now();
const { provider } = await this.loaded(row);
provider.setController(id, opts.to);
const rs = obj(row.recovery_state) as RecoveryState;
const released = await this.ctx.db.transaction().execute(async (trx) => {
const l = await trx
.updateTable("browser_control_lease")
.set({ status: "released", released_at: at, release_reason: opts.reason, updated_at: at })
.where("id", "=", lease.id)
.returningAll()
.executeTakeFirstOrThrow();
await trx
.updateTable("browser_session")
.set({
controller: opts.to,
recovery_state: JSON.stringify({
...rs,
needsSnapshot: true,
releasedAt: at.toISOString(),
waitingReason: opts.to === "waiting" ? (rs.waitingReason ?? "human_released_waiting") : null,
...(opts.to === "automation" ? { waitingDecisionId: null } : {}),
}),
updated_at: at,
})
.where("id", "=", id)
.execute();
return l;
});
if (opts.to === "automation") await this.resolveWaiting(row, lease.holder);
await this.persist(id, provider);
// 人工在桌面 / 截图模式里登录过:交还时读回登录态写进站点账号,全站(HTTP 抓取 / RSS / 快照)生效
await this.syncCookies(id, "release");
return toLease(released);
}
// ---------- 动作 ----------
/**
* 执行一次会话动作:会话存活 → 租约过期检查 → 发起方与控制者一致(人工须持有自己的租约)→ 适配器 → 回写数据库。
* 上游标签页已被回收(404)时对账标 lost 并报冲突,不盲目重放。
*/
async run<T>(
id: string,
actor: Actor,
fn: (
p: BrowserProvider,
ref: TabRef,
ctx: { row: BrowserSessionRow; site: SiteRow; budget: NavigationBudget },
) => Promise<T>,
opts: { tabId?: string; write?: boolean; persist?: boolean; patch?: (out: T) => RecoveryState } = {},
): Promise<T> {
let row = await this.expireLeases(await this.sessionRow(id));
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
if (opts.write !== false) {
if (actor.kind === "human") {
const lease = await this.activeLease(id);
if (row.controller !== "human" || !lease || lease.holder !== actor.holder)
throw new BrowserServiceError("conflict", "先接管会话再操作");
} else if (row.controller !== "automation") {
const rs = obj(row.recovery_state) as RecoveryState;
throw new BrowserServiceError(
"conflict",
row.controller === "human"
? "会话正由人工接管,自动化动作已停止"
: `会话在等待人工处理(${REASON_TEXT[rs.waitingReason ?? ""] ?? rs.waitingReason ?? "未知原因"}),自动化动作已停止`,
);
}
}
// BR-3:浏览器服务重启过(实例标识变了)→ 先重建上游上下文再操作
if (await this.checkInstance(row.provider_id).catch(() => false)) row = await this.sessionRow(id);
const pending = obj(row.recovery_state) as RecoveryState;
if (pending.needsReconnect) {
// 刚失败过:短时间内不反复重连(原站视图 1.5 秒一次截图),直接报上次的原因;显式 POST .../reconnect 不受限
const last = pending.reconnect;
if (last?.status === "failed" && this.now().getTime() - new Date(last.at).getTime() < RECONNECT_BACKOFF_MS)
throw new BrowserServiceError("reconnect_failed", `浏览器已重启,重新连接失败:${last.reason ?? "未知原因"}`);
await this.reconnect(id, pending.reconnectTrigger ?? "provider_restarted");
row = await this.sessionRow(id);
}
let loaded = await this.loaded(row);
row = loaded.row;
const prow = await this.providerRow(row.provider_id).catch(() => null);
const site = await this.liveSite(row.site_id);
const budget = prow
? this.budgetFor(site, prow)
: { domainAllowlist: [], timeoutMs: 30_000, maxRetries: 0, maxBytes: 1 };
const ref: TabRef = { sessionId: id, as: actor.kind, ...(opts.tabId ? { tabId: opts.tabId } : {}) };
let out: T;
try {
out = await fn(loaded.provider, ref, { row, site, budget });
} catch (e) {
if (!isGone(e)) {
await this.persist(id, loaded.provider).catch(() => {});
throw fromBrowserError(e);
}
// 上游说标签页 / 会话不在了:先对账。当前标签页还活着 → 指定的旧标签页没了照旧报错,否则在当前标签页重试一次;
// 全没了(浏览器服务重启 / 回收)→ 注入 Cookie、新开标签页回到原地址,再重试一次(失败 503 browser_reconnect_failed)
const r = await loaded.provider.reconcileTabs(id).catch(() => null);
await this.persist(id, loaded.provider, { needsSnapshot: true }).catch(() => {});
const alive = r ? (loaded.provider.sessionInfo(id)?.currentTabId ?? null) : null;
if (alive && opts.tabId && opts.tabId !== alive) {
if (r?.lost.includes(opts.tabId))
throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面");
throw fromBrowserError(e);
}
if (!alive) {
await this.markReconnect(id, "tab_gone");
await this.reconnect(id, "tab_gone");
loaded = await this.loaded(await this.sessionRow(id));
row = loaded.row;
}
const retry: TabRef = { sessionId: id, as: actor.kind };
try {
out = await fn(loaded.provider, retry, { row, site, budget });
} catch (e2) {
await this.persist(id, loaded.provider).catch(() => {});
throw fromBrowserError(e2);
}
}
const automation: RecoveryState = actor.kind === "automation" ? { lastAutomationAt: this.now().toISOString() } : {};
if (opts.persist !== false)
await this.persist(id, loaded.provider, { ...(opts.patch?.(out) ?? {}), ...automation });
else if (automation.lastAutomationAt) await this.patchRecovery(await this.sessionRow(id), automation);
this.maybeSyncCookies(row);
return out;
}
// ---------- BR-3 浏览器服务重启后自愈 ----------
/** 各浏览器服务最近一次读到的实例标识(节流缓存;只在进程内) */
private readonly instances = new Map<string, { at: number; id: string | null; pending?: Promise<string | null> }>();
/** 各浏览器服务已按哪个实例标识对过会话(同一标识不重复写库) */
private readonly applied = new Map<string, string>();
/** 进行中的重连:同一会话并发的操作共用一次 */
private readonly reconnecting = new Map<string, Promise<void>>();
/** 浏览器服务当前实例标识:INSTANCE_CHECK_MS 内复用上次的结果;取不到(旧镜像)为 null */
private async currentInstance(prow: BrowserProviderRow, force = false): Promise<string | null> {
const provider = this.providerFor(prow);
if (!provider.instanceId) return null;
const hit = this.instances.get(prow.id);
if (hit?.pending) return hit.pending;
if (hit && !force && Date.now() - hit.at < INSTANCE_CHECK_MS) return hit.id;
const pending = provider.instanceId().then(
(id) => {
this.instances.set(prow.id, { at: Date.now(), id });
return id;
},
(e) => {
// 连不上:沿用旧值,不当成重启(真重启时标签页 404/410 会被动发现)
this.instances.set(prow.id, { at: Date.now(), id: hit?.id ?? null });
throw e;
},
);
this.instances.set(prow.id, { at: hit?.at ?? 0, id: hit?.id ?? null, pending });
return pending;
}
/**
* 重启检测:浏览器服务实例标识变了 → 该服务下全部未关闭会话标 needsReconnect(下一次操作 / 原站视图触发重连)。
* 没记录实例标识的旧会话补记当前值。返回本次是否有会话被标记。
*/
async checkInstance(providerId: string, force = false): Promise<boolean> {
const prow = await this.ctx.db
.selectFrom("browser_provider")
.selectAll()
.where("id", "=", providerId)
.executeTakeFirst();
if (!prow?.enabled || prow.kind === "none") return false;
const id = await this.currentInstance(prow, force);
if (!id || (!force && this.applied.get(providerId) === id)) return false;
const db = this.ctx.db;
await db
.updateTable("browser_session")
.set({ recovery_state: sql`recovery_state || ${JSON.stringify({ instanceId: id })}::jsonb` })
.where("provider_id", "=", providerId)
.where("controller", "<>", "closed")
.where(sql<boolean>`recovery_state->>'instanceId' is null`)
.execute();
const r = await db
.updateTable("browser_session")
.set({
recovery_state: sql`recovery_state || ${JSON.stringify({ needsReconnect: true, reconnectTrigger: "provider_restarted" })}::jsonb`,
updated_at: this.now(),
})
.where("provider_id", "=", providerId)
.where("controller", "<>", "closed")
.where(sql<boolean>`recovery_state->>'instanceId' <> ${id}`)
.where(sql<boolean>`coalesce(recovery_state->>'needsReconnect', 'false') <> 'true'`)
.executeTakeFirst();
this.applied.set(providerId, id);
const n = Number(r.numUpdatedRows ?? 0);
if (n > 0)
this.ctx.logger.info(
{ component: "browser", providerId, sessions: n },
"浏览器服务重启过,未关闭的会话标为需要重新连接",
);
return n > 0;
}
private async markReconnect(id: string, trigger: BrowserReconnectState["trigger"]): Promise<void> {
await this.patchRecovery(await this.sessionRow(id), { needsReconnect: true, reconnectTrigger: trigger });
}
/**
* POST /browser/sessions/{id}/reconnect(幂等):先查实例标识;没标需要重连时按上游对账,当前标签页还活着就什么都不做;
* 否则重建。失败抛 reconnect_failed(503)。
*/
async reconnectIfNeeded(id: string): Promise<void> {
let row = await this.sessionRow(id);
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
if (await this.checkInstance(row.provider_id, true).catch(() => false)) row = await this.sessionRow(id);
const rs = obj(row.recovery_state) as RecoveryState;
if (!rs.needsReconnect) {
const { provider } = await this.loaded(row);
try {
await provider.reconcileTabs(id);
} catch (e) {
throw new BrowserServiceError(
"reconnect_failed",
`浏览器服务不可用:${e instanceof Error ? e.message : String(e)}`,
);
}
await this.persist(id, provider).catch(() => {});
if (provider.sessionInfo(id)?.currentTabId) return;
}
await this.reconnect(id, rs.needsReconnect ? (rs.reconnectTrigger ?? "provider_restarted") : "manual");
}
/** 重建会话的上游上下文(同一会话并发调用只做一次) */
reconnect(id: string, trigger: BrowserReconnectState["trigger"]): Promise<void> {
const running = this.reconnecting.get(id);
if (running) return running;
const p = this.doReconnect(id, trigger).finally(() => this.reconnecting.delete(id));
this.reconnecting.set(id, p);
return p;
}
/**
* 按会话的站点 / 账号:注入账号 Cookie(BR-2 同一套准备)→ 同一 userId 开新标签页 → 导航到会话记录的地址(没有或不在站点域名内
* 则站点首页)→ 旧标签页标 lost、换当前标签页。控制者与租约不变;人工接管中重连记 humanNotice(页面已重新加载,请核对)。
*/
private async doReconnect(id: string, trigger: BrowserReconnectState["trigger"]): Promise<void> {
let row = await this.sessionRow(id);
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
const loaded = await this.loaded(row);
row = loaded.row;
const provider = loaded.provider;
const prow = await this.providerRow(row.provider_id);
const site = await this.liveSite(row.site_id);
const budget = this.budgetFor(site, prow);
const at = this.now().toISOString();
const fail = async (reason: string) => {
const cur = await this.sessionRow(id);
await this.patchRecovery(cur, {
needsReconnect: true,
reconnectTrigger: trigger,
reconnect: { status: "failed", trigger, at, reason, humanNotice: false },
});
this.ctx.logger.warn({ component: "browser", sessionId: id, trigger, reason }, "浏览器会话重新连接失败");
return new BrowserServiceError("reconnect_failed", `浏览器已重启,重新连接失败:${reason}`);
};
if (!provider.reopenSession) throw await fail("浏览器服务不支持重新连接");
const url = restoreUrl(row.current_url, budget) ?? site.base_url;
const prep = await this.prepareCookies(site, row.account_id).catch(() => null);
await this.applyEgress(provider, site, row.profile_key);
// 浏览器重启过(可能换了镜像版本):身份重新读
await this.refreshIdentity(prow).catch(() => null);
let res: Awaited<ReturnType<NonNullable<BrowserProvider["reopenSession"]>>>;
try {
await this.rateLimit(site);
res = await provider.reopenSession(id, {
url,
budget,
...(prep?.cookies.length ? { cookies: prep.cookies } : {}),
});
} catch (e) {
throw await fail(e instanceof Error ? e.message : String(e));
}
const instanceId = await this.currentInstance(prow, true).catch(() => null);
const rs = obj((await this.sessionRow(id)).recovery_state) as RecoveryState;
await this.persist(id, provider, {
needsReconnect: false,
needsSnapshot: true,
instanceId: instanceId ?? rs.instanceId ?? null,
reconnect: { status: "ok", trigger, at, reason: null, humanNotice: row.controller === "human" },
});
this.coordSpaces.delete(id);
this.ctx.logger.info(
{
component: "browser",
sessionId: id,
trigger,
lostTabs: res.lost.length,
cookies: res.cookies?.status ?? "none",
cookieCount: res.cookies?.count ?? 0,
controller: row.controller,
},
"浏览器会话已重新连接",
);
}
/** 有活动时每 COOKIE_SYNC_INTERVAL_MS 读回一次登录态(后台,不拖慢动作;同一会话同时只跑一个) */
private readonly syncing = new Set<string>();
private maybeSyncCookies(row: BrowserSessionRow): void {
if (!row.account_id || this.syncing.has(row.id)) return;
const c = (obj(row.recovery_state) as RecoveryState).credential;
if (c?.status === "not_applicable") return;
const last = c?.syncedAt ? new Date(c.syncedAt).getTime() : new Date(row.created_at).getTime();
if (this.now().getTime() - last < COOKIE_SYNC_INTERVAL_MS) return;
this.syncing.add(row.id);
void this.syncCookies(row.id, "periodic").finally(() => this.syncing.delete(row.id));
}
/** 导航前按站点限流取令牌 */
async limited(site: SiteRow): Promise<void> {
await this.rateLimit(site);
}
/**
* 快照后判断是否需要人工:只在自动化控制下。二次验证 / 短信 / 本人设备确认一律转等待处理;
* 验证码 / 挑战页在视觉子流程(browser.solve_challenge)进行中不转,由子流程处理,失败再转。
*/
async afterAutomationSnapshot(id: string, text: string, loginPage: boolean): Promise<void> {
const row = await this.sessionRow(id);
if (row.controller !== "automation") return;
if (TWO_FACTOR.test(text)) await this.enterWaiting(row, "two_factor");
else if (HUMAN_CHALLENGE.test(text) && !this.challenges.has(id))
await this.enterWaiting(row, loginPage ? "captcha" : "challenge");
}
async requestHuman(id: string, reason: string): Promise<void> {
const row = await this.sessionRow(id);
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
if (row.controller !== "automation") return;
await this.enterWaiting(row, reason || "agent_request");
}
// ---------- VIS-1 视觉自动化 ----------
/** 每个会话最近一次截图的坐标系(截图像素 → 页面 CSS 像素);只在进程内 */
private readonly coordSpaces = new Map<string, BrowserCoordinateSpace>();
/** 进行中的视觉子流程:会话 → 已写的证据 */
private readonly challenges = new Map<string, { evidence: BrowserEvidenceRef[] }>();
private evidenceDir(): string {
if (this.opts.evidenceDir) return this.opts.evidenceDir;
const dataDir = (this.ctx.config as { DATA_DIR?: string }).DATA_DIR ?? "./data";
return join(resolve(dataDir), "tmp", "evidence");
}
/** 证据缩略图(≤480 宽 JPEG)落 DATA_DIR/tmp/evidence(0600),超过 7 天的顺手清掉;返回引用 */
async writeEvidence(sessionId: string, shot: Uint8Array): Promise<BrowserEvidenceRef | null> {
try {
const thumb = prepareImage(shot, { maxWidth: 480, format: "jpeg", quality: 60 });
const dir = this.evidenceDir();
await mkdir(dir, { recursive: true, mode: 0o700 });
if (Math.random() < 0.05) await this.cleanupDir(dir, 7 * 24 * 3_600_000);
const id = crypto.randomUUID();
await writeFile(join(dir, `${id}.jpg`), thumb.data, { mode: 0o600 });
const ref: BrowserEvidenceRef = { id, ref: `evidence/${id}.jpg`, capturedAt: this.now().toISOString() };
this.challenges.get(sessionId)?.evidence.push(ref);
return ref;
} catch (err) {
this.ctx.logger.warn({ sessionId, err: { message: (err as Error).message } }, "截图证据写入失败");
return null;
}
}
evidencePath(ref: string): string {
return join(this.evidenceDir(), ref.replace(/^evidence\//, "").replace(/[/\\]/g, "_"));
}
private async cleanupDir(dir: string, maxAgeMs: number): Promise<void> {
const cutoff = Date.now() - maxAgeMs;
for (const f of await readdir(dir).catch(() => [] as string[])) {
const p = join(dir, f);
const st = await stat(p).catch(() => null);
if (st && st.mtimeMs < cutoff) await rm(p, { force: true }).catch(() => {});
}
}
/** 交给模型的截图参数:browser_provider.config 的 screenshotMaxWidth(≤1280)/ screenshotFormat / screenshotQuality */
private imageOptions(prow: BrowserProviderRow | null): {
maxWidth: number;
format: "png" | "jpeg";
quality: number;
} {
const cfg = obj(prow?.config);
const w = typeof cfg.screenshotMaxWidth === "number" ? cfg.screenshotMaxWidth : 1280;
const q = typeof cfg.screenshotQuality === "number" ? cfg.screenshotQuality : 70;
return {
maxWidth: Math.max(320, Math.min(1280, Math.round(w))),
format: cfg.screenshotFormat === "png" ? "png" : "jpeg",
quality: Math.max(20, Math.min(95, Math.round(q))),
};
}
/**
* 截图 + 页面度量 + 证据:缩放后的截图作为模型坐标系登记在会话上;判定敏感(登录页 / 密码框 / 地址带凭据);
* 页面文字出现二次验证 / 短信 / 本人设备确认时,自动化控制下的会话立即转等待处理。
* wantImage=false(上游不支持图片)或敏感且未授权时不给图片,改给已脱敏的文字快照。
*/
async capture(
id: string,
actor: Actor,
opts: { tabId?: string; wantImage: boolean; sensitiveAllowed: boolean },
): Promise<VisionShot> {
const got = await this.run(
id,
actor,
async (p, ref, x) => {
const shot = await p.screenshot(ref, { maxBytes: 8 * 1024 * 1024 });
const metrics = await p.pageMetrics(ref).catch(() => null);
const prow = await this.providerRow(x.row.provider_id).catch(() => null);
return { shot, metrics, prow };
},
{ write: false, persist: false, ...(opts.tabId ? { tabId: opts.tabId } : {}) },
);
const { shot, metrics } = got;
const src = imageDimensions(shot.data);
const img = prepareImage(shot.data, this.imageOptions(got.prow));
const space = coordinateSpace(src ?? { width: img.width, height: img.height }, img, metrics);
this.coordSpaces.set(id, space);
const sensitive = !shot.modelSafe || metrics?.passwordField === true;
let row = await this.sessionRow(id);
if (row.controller === "automation" && metrics && TWO_FACTOR.test(metrics.textSample))
row = await this.enterWaiting(row, "two_factor");
const waitingReason =
row.controller === "waiting" ? ((obj(row.recovery_state) as RecoveryState).waitingReason ?? null) : null;
const evidence = await this.writeEvidence(id, shot.data);
const blocked = waitingReason === "two_factor";
const give = opts.wantImage && !blocked && (!sensitive || opts.sensitiveAllowed);
let note: string | null = null;
if (blocked) note = "页面要求二次验证 / 短信 / 本人设备确认:不自动处理,会话已转等待处理,由人在原站视图接管";
else if (!opts.wantImage) note = "当前模型不支持图片输入,截图没有交给模型;下面是页面的文字快照";
else if (sensitive && !opts.sensitiveAllowed)
note =
"登录页 / 含密码框的截图属于敏感截图,默认不交给模型(需要 browser.sensitive_screenshot 持续授权并传 allowSensitive);下面是已脱敏的文字快照";
let textSnapshot: string | null = null;
if (!give && !blocked) {
const snap = await this.run(id, actor, (p, ref) => p.snapshot(ref), {
write: false,
persist: false,
...(opts.tabId ? { tabId: opts.tabId } : {}),
}).catch(() => null);
textSnapshot = snap ? snap.text.slice(0, 6000) : null;
}
return {
evidence,
url: shot.url,
controller: row.controller,
sensitive,
imageForModel: give,
note,
textSnapshot,
coordinates: space,
waitingReason,
modelImage: give
? { mimeType: img.mimeType, data: toBase64(img.data), width: img.width, height: img.height }
: null,
};
}
/** 截图像素 → 页面 CSS 像素;没有截图过(或坐标越界)报错 */
toPage(id: string, x: number, y: number): { pageX: number; pageY: number } {
const sp = this.coordSpaces.get(id);
if (!sp) throw new BrowserServiceError("precondition", "先用 browser.screenshot 截图,坐标以截图像素为准");
if (x < 0 || y < 0 || x >= sp.imageWidth || y >= sp.imageHeight)
throw new BrowserServiceError("bad_request", `坐标 (${x}, ${y}) 超出截图范围 ${sp.imageWidth}×${sp.imageHeight}`);
return screenToPage(sp, x, y);
}
/**
* 坐标类动作:自动化控制权 + 当前页面在站点域名内;动作之后旧坐标系作废(页面可能已变化),
* 等 settleMs 再截一张作为新坐标系与证据。
*/
async visionAct(
id: string,
actor: Actor,
act: (p: BrowserProvider, ref: TabRef) => Promise<void>,
opts: { wantImage: boolean; sensitiveAllowed: boolean },
): Promise<VisionShot> {
await this.run(id, actor, async (p, ref, x) => {
const url = p.sessionInfo(id)?.session.currentUrl ?? null;
const host = url ? safeUrl(url)?.hostname : undefined;
if (!host || !hostAllowed(host, x.budget.domainAllowlist))
throw new BrowserServiceError("precondition", "当前页面不在站点域名内,先用 browser.navigate 回到站点页面");
await act(p, ref);
});
this.coordSpaces.delete(id);
await new Promise((r) => setTimeout(r, this.opts.settleMs ?? 400));
return this.capture(id, actor, opts);
}
/**
* 开始视觉子流程:会话须在自动化控制下,或因验证码 / 页面挑战在等待处理(此时收回自动化控制,待处理事项保留,
* 成功后关闭);人工接管中、二次验证、租约过期等其它等待原因一律拒绝。
*/
async beginChallenge(id: string): Promise<{ row: BrowserSessionRow; url: string | null }> {
if (this.challenges.has(id)) throw new BrowserServiceError("conflict", "这个会话已有进行中的挑战处理");
let row = await this.expireLeases(await this.sessionRow(id));
const rs = obj(row.recovery_state) as RecoveryState;
if (row.controller === "waiting" && SOLVABLE.includes(rs.waitingReason ?? "")) {
const { provider } = await this.loaded(row);
provider.setController(id, "automation");
row = await this.ctx.db
.updateTable("browser_session")
.set({
controller: "automation",
recovery_state: JSON.stringify({ ...rs, waitingReason: null, needsSnapshot: true }),
updated_at: this.now(),
})
.where("id", "=", id)
.where("controller", "=", "waiting")
.returningAll()
.executeTakeFirstOrThrow();
} else if (row.controller !== "automation") {
throw new BrowserServiceError(
"conflict",
row.controller === "human"
? "会话正由人工接管,不能自动处理"
: row.controller === "closed"
? "会话已关闭"
: `会话在等待人工处理(${REASON_TEXT[rs.waitingReason ?? ""] ?? rs.waitingReason ?? "未知原因"}),不能自动处理`,
);
}
this.challenges.set(id, { evidence: [] });
return { row, url: row.current_url };
}
challengeEvidence(id: string): BrowserEvidenceRef[] {
return [...(this.challenges.get(id)?.evidence ?? [])];
}
/**
* 结束视觉子流程:成功 → 关闭之前的等待处理事项;否则会话转等待处理(原因 two_factor / challenge_unsolved),
* 待处理事项附上每步证据与子流程运行,供人在原站视图接管。
*/
async endChallenge(
id: string,
out: { solved: boolean; reason: string; summary: string; childRunId: string | null },
): Promise<{ decisionId: string | null; controller: BrowserController }> {
const evidence = this.challengeEvidence(id);
this.challenges.delete(id);
let row = await this.sessionRow(id);
if (out.solved) {
if (row.controller === "automation") {
await this.resolveWaiting(row, "system:browser.solve_challenge");
await this.patchRecovery(row, { waitingDecisionId: null });
}
return { decisionId: null, controller: row.controller };
}
if (row.controller === "automation") row = await this.enterWaiting(row, out.reason);
const decisionId = (obj(row.recovery_state) as RecoveryState).waitingDecisionId ?? null;
if (decisionId)
await this.ctx.db
.updateTable("decision")
.set({
evidence: sql`evidence || ${JSON.stringify({
challenge: {
reason: out.reason,
summary: out.summary.slice(0, 1000),
childRunId: out.childRunId,
screenshots: evidence.slice(-12),
},
})}::jsonb`,
})
.where("id", "=", decisionId)
.execute();
return { decisionId, controller: row.controller };
}
// ---------- 下载 ----------
/** 暂存产物核验:解析种子、按 infohash / 站点资源 ID 匹配本地资源、会话内去重 */
async verifyDownload(
id: string,
artifact: DownloadArtifact,
): Promise<{
verification: BrowserDownloadVerification;
sourceReleaseId: string | null;
duplicate: { planId: string | null } | null;
}> {
const row = await this.sessionRow(id);
const { readFile } = await import("node:fs/promises");
let verification: BrowserDownloadVerification = {
valid: false,
infohashV1: null,
infohashV2: null,
name: null,
totalSize: null,
private: null,
error: null,
};
try {
const bytes = new Uint8Array(await readFile(this.stagedPath(artifact.bytes.ref)));
const meta = parseTorrent(bytes);
verification = {
valid: true,
infohashV1: meta.infohashV1,
infohashV2: meta.infohashV2,
name: meta.name,
totalSize: meta.totalSize,
private: meta.private,
error: null,
};
} catch (e) {
verification.error = e instanceof Error ? e.message.slice(0, 200) : "无法解析";
}
let sourceReleaseId: string | null = null;
if (verification.valid) {
const hashes = [verification.infohashV1, verification.infohashV2].filter((h): h is string => !!h);
const hit = await this.ctx.db
.selectFrom("source_release")
.select("id")
.where("site_id", "=", row.site_id)
.where((eb) => eb.or([eb("infohash_v1", "in", hashes), eb("infohash_v2", "in", hashes)]))
.executeTakeFirst();
sourceReleaseId = hit?.id ?? null;
if (!sourceReleaseId && artifact.sourceUrl) {
const rid = safeUrl(artifact.sourceUrl)?.searchParams.get("id");
if (rid) {
const byId = await this.ctx.db
.selectFrom("source_release")
.select("id")
.where("site_id", "=", row.site_id)
.where("site_release_id", "=", rid)
.executeTakeFirst();
sourceReleaseId = byId?.id ?? null;
}
}
}
const rs = obj(row.recovery_state) as RecoveryState;
const prev = (rs.downloads ?? []).find((d) => d.sha256 === artifact.sha256);
return { verification, sourceReleaseId, duplicate: prev ? { planId: prev.planId } : null };
}
async recordDownload(
id: string,
entry: { sha256: string; artifactId: string; sourceReleaseId: string | null; planId: string | null },
): Promise<void> {
const row = await this.sessionRow(id);
const rs = obj(row.recovery_state) as RecoveryState;
const list = (rs.downloads ?? []).filter((d) => d.sha256 !== entry.sha256);
list.push(entry);
await this.patchRecovery(row, { downloads: list.slice(-50) });
}
}
const REASON_TEXT: Record<string, string> = {
captcha: "登录页需要验证码",
challenge: "页面需要人工验证",
two_factor: "需要二次验证或本人设备确认",
challenge_unsolved: "自动处理页面挑战未成功",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "人工接管超时未交还",
agent_request: "智能体请求人工处理",
human_released_waiting: "人工交还时选择继续等待",
};
/** 重连回到的地址:会话记录的地址是 http(s)、在站点域名内、没有被脱敏过才用;否则 null(调用方退回站点首页) */
function restoreUrl(url: string | null, budget: NavigationBudget): string | null {
const u = url ? safeUrl(url) : null;
if (!u || !/^https?:$/.test(u.protocol) || !hostAllowed(u.hostname, budget.domainAllowlist)) return null;
if (url!.includes("[REDACTED")) return null;
return url;
}
function safeUrl(s: string): URL | null {
try {
return new URL(s);
} catch {
return null;
}
}
function holderName(holder: string): string {
return holder.includes(":") ? holder.slice(holder.indexOf(":") + 1) : holder;
}
export function toSession(r: BrowserSessionRow): BrowserSession {
return {
id: r.id,
providerId: r.provider_id,
siteId: r.site_id,
accountId: r.account_id,
profileKey: r.profile_key,
controller: r.controller as BrowserController,
currentUrl: r.current_url,
lastActivityAt: iso(r.last_activity_at)!,
upstreamRef: obj(r.upstream_ref),
recoveryState: obj(r.recovery_state),
closedAt: iso(r.closed_at),
closeReason: r.close_reason,
createdAt: iso(r.created_at)!,
updatedAt: iso(r.updated_at)!,
};
}
export function toTab(r: BrowserTabRow): BrowserTab {
return {
id: r.id,
sessionId: r.session_id,
upstreamTabId: r.upstream_tab_id,
url: r.url,
title: r.title,
status: r.status as BrowserTab["status"],
lastSnapshotAt: iso(r.last_snapshot_at),
closedAt: iso(r.closed_at),
createdAt: iso(r.created_at)!,
updatedAt: iso(r.updated_at)!,
};
}
export function toLease(r: BrowserControlLeaseRow): BrowserControlLease {
return {
id: r.id,
sessionId: r.session_id,
holderKind: r.holder_kind as BrowserControlLease["holderKind"],
holder: r.holder,
reason: r.reason,
status: r.status as BrowserControlLease["status"],
acquiredAt: iso(r.acquired_at)!,
expiresAt: iso(r.expires_at)!,
releasedAt: iso(r.released_at),
releaseReason: r.release_reason,
createdAt: iso(r.created_at)!,
updatedAt: iso(r.updated_at)!,
};
}
/** 视觉子流程可以接手的等待原因(验证码 / 页面挑战 / 上次没处理成功) */
const SOLVABLE = ["captcha", "challenge", "challenge_unsolved"];
/** capture / visionAct 的结果(能力输出的公共部分) */
export interface VisionShot {
evidence: BrowserEvidenceRef | null;
url: string | null;
controller: BrowserController;
sensitive: boolean;
imageForModel: boolean;
note: string | null;
textSnapshot: string | null;
coordinates: BrowserCoordinateSpace | null;
waitingReason: string | null;
modelImage: { mimeType: "image/png" | "image/jpeg"; data: string; width: number; height: number } | null;
}
/** 模型坐标系:缩放后的截图 ↔ 页面视口;取不到页面度量时按原始截图尺寸、DPR 1 推定 */
export function coordinateSpace(
src: { width: number; height: number },
img: { width: number; height: number },
m: PageMetrics | null,
): BrowserCoordinateSpace {
return {
imageWidth: img.width,
imageHeight: img.height,
sourceWidth: src.width,
sourceHeight: src.height,
viewportWidth: m?.viewportWidth ?? src.width,
viewportHeight: m?.viewportHeight ?? src.height,
devicePixelRatio: m?.devicePixelRatio ?? 1,
assumed: !m,
};
}
/** 截图像素(交给模型的缩放图)→ 页面 CSS 像素:按像素中心换算,四舍五入 */
export function screenToPage(sp: BrowserCoordinateSpace, x: number, y: number): { pageX: number; pageY: number } {
return {
pageX: Math.round(((x + 0.5) * sp.viewportWidth) / sp.imageWidth - 0.5),
pageY: Math.round(((y + 0.5) * sp.viewportHeight) / sp.imageHeight - 0.5),
};
}
/** 截图像素尺寸:PNG IHDR / JPEG SOF */
export function imageSize(data: Uint8Array): { width: number | null; height: number | null } {
if (data.length > 24 && data[0] === 0x89 && data[1] === 0x50) {
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
return { width: v.getUint32(16), height: v.getUint32(20) };
}
if (data[0] === 0xff && data[1] === 0xd8) {
let i = 2;
while (i + 9 < data.length) {
if (data[i] !== 0xff) break;
const marker = data[i + 1]!;
const len = (data[i + 2]! << 8) | data[i + 3]!;
if (marker >= 0xc0 && marker <= 0xc3)
return { width: (data[i + 7]! << 8) | data[i + 8]!, height: (data[i + 5]! << 8) | data[i + 6]! };
i += 2 + len;
}
}
return { width: null, height: null };
}
export type { ElementTarget };
/** 代理地址 → 浏览器上下文代理(账号口令拆出来;Playwright 不认 URL 里的口令) */
export function egressProxy(url: string): BrowserEgressProxy | null {
try {
const u = new URL(url);
if (!["http:", "https:", "socks4:", "socks5:"].includes(u.protocol) || !u.hostname) return null;
return {
server: `${u.protocol}//${u.host}`,
...(u.username ? { username: decodeURIComponent(u.username) } : {}),
...(u.password ? { password: decodeURIComponent(u.password) } : {}),
};
} catch {
return null;
}
}