camofox reaps a tab after TAB_INACTIVITY_MS (5 min) without API tool calls;
desktop-mode input goes straight to Selkies and screenshots don't count, so
tabs disappeared under the user during takeover.
- BrowserKeepalive (in-process): sessions held by an open desktop WebSocket
(ref-counted), a human takeover lease, or a running/queued agent run that
used browser.* get a keepalive every BROWSER_KEEPALIVE_MS (default 120s,
0 = off). Holds are re-checked each round; release/lease expiry/agent end/
session close stop it and camofox reaps as usual.
- Keepalive call: camofox 1.17 has no keepalive/touch endpoint and /stats does
not bump toolCalls; use GET /tabs/{id}/downloads without includeData/consume
(read-only metadata). Goes through BrowserService.run so restarts or reaped
tabs self-heal via BR-3 reconnect; no lastAutomationAt, no control needed.
- Runbook browser.md §12, deploy env table, compose env; tests against the
testkit mock camofox.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2135 lines
89 KiB
TypeScript
2135 lines
89 KiB
TypeScript
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";
|
||
import { AGENT_KEEPALIVE_MAX_IDLE_MS, BROWSER_KEEPALIVE_MS, BrowserKeepalive } from "./keepalive.ts";
|
||
|
||
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
|
||
// 站点域名白名单 + 站点限流、凭证注入、接管状态机、下载产物暂存核验。
|
||
// 适配器只是内存镜像:服务重启后按数据库恢复,旧元素引用一律失效。
|
||
|
||
export type Actor = {
|
||
kind: "automation" | "human";
|
||
holder: string;
|
||
/** 智能体会话发起的动作(AG-4):会话还在跑期间保活这个浏览器会话 */
|
||
agentRunId?: 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;
|
||
/** 保活间隔(AG-4);缺省 BROWSER_KEEPALIVE_MS 配置(120 秒),0 = 关闭 */
|
||
keepaliveMs?: 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;
|
||
/** AG-4 保活:桌面 / 人工接管 / 智能体正在用的会话定期做一次只读调用 */
|
||
readonly keepalive: BrowserKeepalive;
|
||
|
||
constructor(
|
||
readonly ctx: ServerContext,
|
||
readonly opts: BrowserModuleOptions = {},
|
||
) {
|
||
this.now = opts.now ?? (() => new Date());
|
||
this.keepalive = new BrowserKeepalive(
|
||
{ keepAlive: (id) => this.keepAlive(id) },
|
||
{
|
||
intervalMs: opts.keepaliveMs ?? ctx.config?.BROWSER_KEEPALIVE_MS ?? BROWSER_KEEPALIVE_MS,
|
||
log: (level, msg, extra) => ctx.logger[level]({ component: "browser", ...extra }, msg),
|
||
},
|
||
);
|
||
// 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> {
|
||
this.keepalive.drop(id);
|
||
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;
|
||
});
|
||
// 接管期间人的操作不经上游 API(桌面模式),由 app 保活;交还 / 租约过期 / 会话关闭后停
|
||
this.keepalive.ensure(id, "takeover", "takeover", () => this.humanHolding(id));
|
||
return toLease(lease);
|
||
}
|
||
|
||
/** 会话仍在人工接管中(租约有效) */
|
||
private async humanHolding(id: string): Promise<boolean> {
|
||
const row = await this.sessionRow(id).catch(() => null);
|
||
if (row?.controller !== "human") return false;
|
||
const lease = await this.activeLease(id);
|
||
return lease?.holder_kind === "human" && new Date(lease.expires_at).getTime() > this.now().getTime();
|
||
}
|
||
|
||
/**
|
||
* 保活一次(AG-4):对当前标签页做最轻的只读调用。不要求控制权、不记自动化活动;浏览器重启过 / 标签页已被回收时
|
||
* 按 BR-3 自愈重连(注入 Cookie、回到原地址)。会话已关闭返回 false;上游不支持保活时只做实例检查与对账。
|
||
*/
|
||
async keepAlive(id: string): Promise<boolean> {
|
||
const row = await this.sessionRow(id).catch(() => null);
|
||
if (!row || row.controller === "closed") return false;
|
||
await this.run(
|
||
id,
|
||
{ kind: "automation", holder: "system:keepalive" },
|
||
async (p, ref) => {
|
||
if (p.keepAlive) await p.keepAlive(ref);
|
||
else await p.reconcileTabs(id);
|
||
},
|
||
{ write: false, persist: false, background: true },
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/** 交还:回到自动化(或留在等待处理);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();
|
||
this.keepalive.unhold(id, "takeover");
|
||
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;
|
||
/** 后台维护调用(保活):不记自动化活动 */
|
||
background?: boolean;
|
||
} = {},
|
||
): 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" && !opts.background ? { lastAutomationAt: this.now().toISOString() } : {};
|
||
if (actor.agentRunId) this.holdForAgent(id, actor.agentRunId);
|
||
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;
|
||
}
|
||
|
||
/** 智能体会话最后一次用各浏览器会话的时间(保活复查用) */
|
||
private readonly agentUse = new Map<string, number>();
|
||
|
||
/** 智能体会话在用这个浏览器会话:会话还在排队 / 运行期间保活(最后一次使用后最多 AGENT_KEEPALIVE_MAX_IDLE_MS) */
|
||
holdForAgent(sessionId: string, runId: string): void {
|
||
const key = `agent:${runId}`;
|
||
this.agentUse.set(`${sessionId}|${runId}`, Date.now());
|
||
this.keepalive.ensure(sessionId, key, "agent", async () => {
|
||
const last = this.agentUse.get(`${sessionId}|${runId}`) ?? 0;
|
||
const r = await this.ctx.db.selectFrom("agent_run").select("status").where("id", "=", runId).executeTakeFirst();
|
||
const ok =
|
||
!!r && (r.status === "running" || r.status === "queued") && Date.now() - last < AGENT_KEEPALIVE_MAX_IDLE_MS;
|
||
if (!ok) this.agentUse.delete(`${sessionId}|${runId}`);
|
||
return ok;
|
||
});
|
||
}
|
||
|
||
// ---------- 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;
|
||
}
|
||
}
|