feat(browser): keep sessions alive during desktop, takeover and agent use (AG-4)
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]>
This commit is contained in:
1 parent
919dc02200
commit
1fcfcac885
13 files changed
+523
-12
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { browserServiceFor } from "../index.ts";
|
||||
import { BrowserKeepalive } from "../keepalive.ts";
|
||||
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
|
||||
|
||||
// AG-4 浏览器会话保活(对 testkit mock camofox):桌面连接 / 人工接管 / 智能体会话正在用的会话定期做一次
|
||||
// GET /tabs/{id}/downloads(不带 includeData / consume);用完不再保活;保活时标签页已被回收 → BR-3 自愈重连。
|
||||
|
||||
describe("BrowserKeepalive(登记与复查)", () => {
|
||||
const fake = () => {
|
||||
const calls: string[] = [];
|
||||
const alive = new Set<string>(["s1", "s2"]);
|
||||
return {
|
||||
calls,
|
||||
alive,
|
||||
target: {
|
||||
keepAlive: async (id: string) => {
|
||||
calls.push(id);
|
||||
return alive.has(id);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it("引用计数:同一会话多个桌面连接,全部断开才停;撤销可重复调用", async () => {
|
||||
const f = fake();
|
||||
const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 });
|
||||
const a = k.hold("s1", "desktop", "desktop");
|
||||
const b = k.hold("s1", "desktop", "desktop");
|
||||
await k.tick();
|
||||
expect(f.calls).toEqual(["s1"]);
|
||||
a();
|
||||
a();
|
||||
expect(k.sessions()).toEqual([{ sessionId: "s1", reasons: ["desktop"] }]);
|
||||
b();
|
||||
expect(k.sessions()).toEqual([]);
|
||||
await k.tick();
|
||||
expect(f.calls).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("复查不通过的登记撤销;会话已关闭(keepAlive 返回 false)整条撤销;ensure 不加引用计数", async () => {
|
||||
const f = fake();
|
||||
const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 });
|
||||
let running = true;
|
||||
k.ensure("s1", "agent:r1", "agent", async () => running);
|
||||
k.ensure("s1", "agent:r1", "agent", async () => running);
|
||||
k.hold("s2", "desktop", "desktop");
|
||||
await k.tick();
|
||||
expect(f.calls.sort()).toEqual(["s1", "s2"]);
|
||||
running = false;
|
||||
f.alive.delete("s2");
|
||||
await k.tick();
|
||||
expect(f.calls).toHaveLength(3); // s1 复查不通过不再保活;s2 保活一次后发现已关闭
|
||||
expect(k.sessions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("保活失败只记日志,下一轮继续;intervalMs = 0 时不登记", async () => {
|
||||
const logs: string[] = [];
|
||||
let fail = true;
|
||||
const k = new BrowserKeepalive(
|
||||
{
|
||||
keepAlive: async () => {
|
||||
if (fail) throw new Error("浏览器服务不可用");
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{ intervalMs: 60_000, log: (_l, msg, extra) => logs.push(`${msg} ${String(extra?.err)}`) },
|
||||
);
|
||||
k.hold("s1", "takeover", "takeover");
|
||||
await k.tick();
|
||||
expect(logs).toEqual(["浏览器会话保活失败 浏览器服务不可用"]);
|
||||
fail = false;
|
||||
await k.tick();
|
||||
expect(k.sessions()).toHaveLength(1);
|
||||
k.stop();
|
||||
|
||||
const off = new BrowserKeepalive(fake().target, { intervalMs: 0 });
|
||||
off.hold("s1", "desktop", "desktop");
|
||||
expect(off.sessions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe.skipIf(!up)("AG-4 浏览器会话保活(mock camofox)", () => {
|
||||
let h: BrowserHarness;
|
||||
const svc = () => browserServiceFor(h.ctx)!;
|
||||
const open = async (path: string) =>
|
||||
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: h.accountId, url: `${ORIGIN}${path}` } }))
|
||||
.body.data as Loose;
|
||||
const pings = (tabId: string) =>
|
||||
h.mock.callsTo(`/tabs/${encodeURIComponent(tabId)}/downloads`).filter((c) => c.method === "GET");
|
||||
const view = async (id: string) => (await h.req(`/browser/sessions/${id}`)).body.data as Loose;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("ag4ka");
|
||||
await h.grant("browser.navigate");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
svc()?.keepalive.stop();
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("人工接管期间保活:只读调用、不记自动化活动;交还后停止", async () => {
|
||||
const s = await open("/torrents.php");
|
||||
const before = (await view(s.id)).lastAutomationAt;
|
||||
const acq = await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "核对" } });
|
||||
expect(acq.status).toBe(200);
|
||||
expect(svc().keepalive.sessions()).toEqual([{ sessionId: s.id, reasons: ["takeover"] }]);
|
||||
|
||||
await svc().keepalive.tick();
|
||||
const p = pings(s.currentTabId);
|
||||
expect(p).toHaveLength(1);
|
||||
// 不带 includeData / consume:不读下载内容、不清下载记录
|
||||
expect(p[0]!.query).toEqual({ userId: s.profileKey });
|
||||
const v = await view(s.id);
|
||||
expect(v.controller).toBe("human");
|
||||
expect(v.lastAutomationAt).toBe(before);
|
||||
|
||||
await h.req(`/browser/sessions/${s.id}/control/release`, { body: { to: "automation", reason: "done" } });
|
||||
expect(svc().keepalive.sessions()).toEqual([]);
|
||||
await svc().keepalive.tick();
|
||||
expect(pings(s.currentTabId)).toHaveLength(1);
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
});
|
||||
|
||||
it("桌面连接期间标签页被空闲回收 → 保活时按 BR-3 自愈重连回到原地址;断开后停止", async () => {
|
||||
const s = await open("/details.php?id=42");
|
||||
const release = svc().keepalive.hold(s.id, "desktop", "desktop");
|
||||
// 上游空闲回收了标签页
|
||||
h.mock.tabs.delete(s.currentTabId);
|
||||
await svc().keepalive.tick();
|
||||
const v = await view(s.id);
|
||||
expect(v.currentTabId).not.toBe(s.currentTabId);
|
||||
// 回到会话记录的地址(测试账号没有登录态,mock 站点跳登录页并带 returnto)
|
||||
expect(decodeURIComponent(v.currentUrl)).toContain("/details.php?id=42");
|
||||
expect(v.reconnect).toMatchObject({ status: "ok", trigger: "tab_gone" });
|
||||
// 新标签页上重试了保活
|
||||
expect(pings(v.currentTabId)).toHaveLength(1);
|
||||
|
||||
release();
|
||||
expect(svc().keepalive.sessions()).toEqual([]);
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
});
|
||||
|
||||
it("智能体会话在用:运行中保活;会话结束后停止;浏览器会话关闭也停止", async () => {
|
||||
const run = await h.db
|
||||
.insertInto("agent_run")
|
||||
.values({
|
||||
trigger: "user",
|
||||
goal: "看看种子列表",
|
||||
status: "running",
|
||||
mode: "execute",
|
||||
scope: JSON.stringify({}),
|
||||
budget: JSON.stringify({}),
|
||||
created_by: "user:admin",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const nav = (await h.registry.invoke(
|
||||
"browser.navigate",
|
||||
{ siteId: h.siteId, url: `${ORIGIN}/torrents.php` },
|
||||
{ ...h.agentCtx(), agentRunId: run.id },
|
||||
)) as Loose;
|
||||
const sid = nav.sessionId as string;
|
||||
expect(svc().keepalive.sessions()).toEqual([{ sessionId: sid, reasons: ["agent"] }]);
|
||||
const tab = (await view(sid)).currentTabId as string;
|
||||
await svc().keepalive.tick();
|
||||
expect(pings(tab)).toHaveLength(1);
|
||||
|
||||
await h.db.updateTable("agent_run").set({ status: "succeeded" }).where("id", "=", run.id).execute();
|
||||
await svc().keepalive.tick();
|
||||
expect(pings(tab)).toHaveLength(1);
|
||||
expect(svc().keepalive.sessions()).toEqual([]);
|
||||
|
||||
// 再用一次(新会话在跑)→ 登记;关闭浏览器会话 → 撤销
|
||||
await h.db.updateTable("agent_run").set({ status: "running" }).where("id", "=", run.id).execute();
|
||||
await h.registry.invoke("browser.snapshot", { sessionId: sid }, { ...h.agentCtx(), agentRunId: run.id });
|
||||
expect(svc().keepalive.sessions()).toHaveLength(1);
|
||||
await h.req(`/browser/sessions/${sid}`, { method: "DELETE" });
|
||||
expect(svc().keepalive.sessions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,12 @@ async function guard<T>(fn: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
const agent = (actorId: string): Actor => ({ kind: "automation", holder: actorId });
|
||||
/** 以自动化身份执行;智能体会话里调用时带上会话 ID(会话在跑期间保活这个浏览器会话,AG-4) */
|
||||
const agent = (ctx: HandlerContext): Actor => ({
|
||||
kind: "automation",
|
||||
holder: ctx.actorId,
|
||||
...(ctx.agentRunId ? { agentRunId: ctx.agentRunId } : {}),
|
||||
});
|
||||
|
||||
/** 敏感截图(登录页 / 密码框)交给模型的授权:有效的 browser.sensitive_screenshot(或 *)持续授权 */
|
||||
export const SENSITIVE_SCREENSHOT_GRANT = "browser.sensitive_screenshot";
|
||||
@@ -61,7 +66,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
const id = await resolveSession(input);
|
||||
const snap = await svc().run(
|
||||
id,
|
||||
agent(ctx.actorId),
|
||||
agent(ctx),
|
||||
(p, ref) => p.snapshot(ref, input.offset !== undefined ? { offset: input.offset } : {}),
|
||||
{ write: false, patch: () => ({ needsSnapshot: false }), ...(input.tabId ? { tabId: input.tabId } : {}) },
|
||||
);
|
||||
@@ -91,6 +96,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
url: input.url,
|
||||
}));
|
||||
if (!live) {
|
||||
if (ctx.agentRunId) svc().holdForAgent(id, ctx.agentRunId);
|
||||
const v = await svc().view(id);
|
||||
const info = v.currentUrl ?? "";
|
||||
return {
|
||||
@@ -107,7 +113,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
const sid = id;
|
||||
const nav = await svc().run(
|
||||
sid,
|
||||
agent(ctx.actorId),
|
||||
agent(ctx),
|
||||
async (p, ref, x) => {
|
||||
await svc().limited(x.site);
|
||||
// 标签页全被上游回收(重启 / 空闲超时,已对账为 lost):在同一会话(同一 profile)里开新标签页,不重放旧引用
|
||||
@@ -143,7 +149,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
if (needTarget && !input.target) throw new BrowserServiceError("bad_request", `${input.action} 需要 target`);
|
||||
await s.run(
|
||||
input.sessionId,
|
||||
agent(ctx.actorId),
|
||||
agent(ctx),
|
||||
async (p, ref, x) => {
|
||||
switch (input.action) {
|
||||
case "click":
|
||||
@@ -183,7 +189,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
const s = svc();
|
||||
const artifact = await s.run(
|
||||
input.sessionId,
|
||||
agent(ctx.actorId),
|
||||
agent(ctx),
|
||||
async (p, ref) => {
|
||||
if (input.target) await p.click(ref, input.target);
|
||||
return p.waitForDownload(ref, { timeoutMs: input.timeoutMs, maxBytes: 10 * 1024 * 1024 });
|
||||
@@ -221,7 +227,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
registry.register(capabilitySpec("browser.screenshot"), (input, ctx) =>
|
||||
guard(async () => {
|
||||
const s = svc();
|
||||
return s.capture(input.sessionId, agent(ctx.actorId), {
|
||||
return s.capture(input.sessionId, agent(ctx), {
|
||||
...(input.tabId ? { tabId: input.tabId } : {}),
|
||||
wantImage: wantImage(ctx),
|
||||
sensitiveAllowed: await sensitiveAllowed(s, input.allowSensitive),
|
||||
@@ -238,7 +244,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
const s = svc();
|
||||
const page = point ? s.toPage(input.sessionId, point.x, point.y) : null;
|
||||
const at = page ? { x: page.pageX, y: page.pageY } : null;
|
||||
const shot = await s.visionAct(input.sessionId, agent(ctx.actorId), (p, ref) => fn(p, ref, at), {
|
||||
const shot = await s.visionAct(input.sessionId, agent(ctx), (p, ref) => fn(p, ref, at), {
|
||||
wantImage: wantImage(ctx),
|
||||
sensitiveAllowed: await sensitiveAllowed(s, input.allowSensitive),
|
||||
});
|
||||
@@ -280,7 +286,7 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
guard(async () => {
|
||||
const s = svc();
|
||||
await new Promise((r) => setTimeout(r, input.ms));
|
||||
const shot = await s.capture(input.sessionId, agent(ctx.actorId), {
|
||||
const shot = await s.capture(input.sessionId, agent(ctx), {
|
||||
wantImage: wantImage(ctx),
|
||||
sensitiveAllowed: await sensitiveAllowed(s, input.allowSensitive),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// AG-4 浏览器会话保活:camofox 的标签页空闲 TAB_INACTIVITY_MS(缺省 5 分钟)没有 API 调用就回收,
|
||||
// 会话空闲 SESSION_TIMEOUT_MS 回收,全部会话没了之后 BROWSER_IDLE_TIMEOUT_MS 退出浏览器。
|
||||
// 桌面模式里人的键鼠直接进 Selkies,不经 API;原站视图的截图轮询也不算标签页活动——接管期间页面会被关掉。
|
||||
// 这里在"有人 / 有智能体正在用"的会话上每隔 intervalMs 做一次最轻的只读调用(BrowserProvider.keepAlive),
|
||||
// 用完(桌面断开、交还 / 接管结束、智能体会话结束、会话关闭)就不再保活,照常由上游空闲回收。
|
||||
// 保活调用走 BrowserService.run:浏览器重启过 / 标签页已被回收时按 BR-3 自愈重连。
|
||||
// 保活登记只在进程内:api 进程登记桌面与接管,跑智能体的进程登记智能体会话,各自保活自己的。
|
||||
|
||||
/** 缺省保活间隔:比上游 5 分钟空闲回收短一半还多 */
|
||||
export const BROWSER_KEEPALIVE_MS = 120_000;
|
||||
/** 智能体会话最后一次用这个浏览器会话之后,最多再保活多久(兜底:会话状态读不到时也会停) */
|
||||
export const AGENT_KEEPALIVE_MAX_IDLE_MS = 30 * 60_000;
|
||||
|
||||
export type KeepaliveReason = "desktop" | "takeover" | "agent";
|
||||
|
||||
interface Hold {
|
||||
reason: KeepaliveReason;
|
||||
/** 引用计数(同一桌面会话开了多个连接) */
|
||||
refs: number;
|
||||
/** 每次保活前复查;false = 不再需要 */
|
||||
check?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface KeepaliveTarget {
|
||||
/** 对会话做一次保活;会话已关闭返回 false */
|
||||
keepAlive(sessionId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface KeepaliveOptions {
|
||||
/** 0 = 关闭保活 */
|
||||
intervalMs: number;
|
||||
log?: (level: "info" | "warn", msg: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export class BrowserKeepalive {
|
||||
/** sessionId → (登记键 → 登记) */
|
||||
private readonly holds = new Map<string, Map<string, Hold>>();
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
private ticking: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly target: KeepaliveTarget,
|
||||
private readonly opts: KeepaliveOptions,
|
||||
) {}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.opts.intervalMs > 0;
|
||||
}
|
||||
|
||||
/** 当前在保活的会话(测试 / 状态页用) */
|
||||
sessions(): { sessionId: string; reasons: KeepaliveReason[] }[] {
|
||||
return [...this.holds].map(([sessionId, m]) => ({
|
||||
sessionId,
|
||||
reasons: [...new Set([...m.values()].map((h) => h.reason))],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记一个使用方;返回的函数撤销这一次登记(可重复调用)。同一 key 多次登记按引用计数。
|
||||
* check 在每次保活前复查,返回 false 时整条登记撤销。
|
||||
*/
|
||||
hold(sessionId: string, key: string, reason: KeepaliveReason, check?: () => Promise<boolean>): () => void {
|
||||
if (!this.enabled) return () => {};
|
||||
let m = this.holds.get(sessionId);
|
||||
if (!m) {
|
||||
m = new Map();
|
||||
this.holds.set(sessionId, m);
|
||||
}
|
||||
const cur = m.get(key);
|
||||
if (cur) {
|
||||
cur.refs++;
|
||||
if (check) cur.check = check;
|
||||
} else m.set(key, { reason, refs: 1, ...(check ? { check } : {}) });
|
||||
this.ensureTimer();
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const h = this.holds.get(sessionId)?.get(key);
|
||||
if (!h) return;
|
||||
if (--h.refs <= 0) this.unhold(sessionId, key);
|
||||
};
|
||||
}
|
||||
|
||||
/** 没登记过才登记(不加引用计数);已登记时只换复查函数。用于"最近在用"类登记(智能体会话每次调用都会续) */
|
||||
ensure(sessionId: string, key: string, reason: KeepaliveReason, check?: () => Promise<boolean>): void {
|
||||
const h = this.holds.get(sessionId)?.get(key);
|
||||
if (h) {
|
||||
if (check) h.check = check;
|
||||
return;
|
||||
}
|
||||
this.hold(sessionId, key, reason, check);
|
||||
}
|
||||
|
||||
/** 撤销某个 key 的登记(不管引用计数) */
|
||||
unhold(sessionId: string, key: string): void {
|
||||
const m = this.holds.get(sessionId);
|
||||
if (!m) return;
|
||||
m.delete(key);
|
||||
if (!m.size) this.holds.delete(sessionId);
|
||||
this.maybeStop();
|
||||
}
|
||||
|
||||
/** 会话关闭:撤销全部登记 */
|
||||
drop(sessionId: string): void {
|
||||
if (this.holds.delete(sessionId)) this.maybeStop();
|
||||
}
|
||||
|
||||
/** 一轮保活(定时器调用;测试直接调) */
|
||||
tick(): Promise<void> {
|
||||
if (this.ticking) return this.ticking;
|
||||
this.ticking = this.runTick().finally(() => {
|
||||
this.ticking = null;
|
||||
});
|
||||
return this.ticking;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.holds.clear();
|
||||
this.maybeStop();
|
||||
}
|
||||
|
||||
private async runTick(): Promise<void> {
|
||||
for (const [sessionId, m] of [...this.holds]) {
|
||||
for (const [key, h] of [...m]) {
|
||||
if (!h.check) continue;
|
||||
const ok = await h.check().catch(() => true);
|
||||
if (!ok) m.delete(key);
|
||||
}
|
||||
if (!m.size) {
|
||||
this.holds.delete(sessionId);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const alive = await this.target.keepAlive(sessionId);
|
||||
if (!alive) this.holds.delete(sessionId);
|
||||
} catch (e) {
|
||||
// 自愈重连也失败(浏览器服务不可用等):记日志,下一轮再试;会话本身的状态由 run() 写好(needsReconnect / reconnect.failed)
|
||||
this.opts.log?.("warn", "浏览器会话保活失败", {
|
||||
sessionId,
|
||||
reasons: [...new Set([...m.values()].map((x) => x.reason))],
|
||||
err: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
this.maybeStop();
|
||||
}
|
||||
|
||||
private ensureTimer(): void {
|
||||
if (this.timer !== undefined || !this.enabled) return;
|
||||
this.timer = setInterval(() => void this.tick(), this.opts.intervalMs);
|
||||
// 不拖住进程退出(测试、优雅停机)
|
||||
try {
|
||||
Deno.unrefTimer(this.timer as unknown as number);
|
||||
} catch {
|
||||
/* 非 Deno 运行时 */
|
||||
}
|
||||
}
|
||||
|
||||
private maybeStop(): void {
|
||||
if (this.holds.size || this.timer === undefined) return;
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,18 @@ import {
|
||||
} 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 };
|
||||
export type Actor = {
|
||||
kind: "automation" | "human";
|
||||
holder: string;
|
||||
/** 智能体会话发起的动作(AG-4):会话还在跑期间保活这个浏览器会话 */
|
||||
agentRunId?: string;
|
||||
};
|
||||
|
||||
export interface BrowserModuleOptions {
|
||||
/** camofox 请求用的 fetch(测试指向 mock) */
|
||||
@@ -79,6 +85,8 @@ export interface BrowserModuleOptions {
|
||||
evidenceDir?: string;
|
||||
/** 坐标动作之后等页面稳定再截图的毫秒数;缺省 400 */
|
||||
settleMs?: number;
|
||||
/** 保活间隔(AG-4);缺省 BROWSER_KEEPALIVE_MS 配置(120 秒),0 = 关闭 */
|
||||
keepaliveMs?: number;
|
||||
/** 测试替换 provider 实现 */
|
||||
providerFactory?: (row: BrowserProviderRow, deps: ProviderDeps) => BrowserProvider;
|
||||
}
|
||||
@@ -194,12 +202,21 @@ 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 经钩子取
|
||||
@@ -1081,6 +1098,7 @@ export class BrowserService {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1235,9 +1253,38 @@ export class BrowserService {
|
||||
.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,
|
||||
@@ -1248,6 +1295,7 @@ export class BrowserService {
|
||||
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;
|
||||
@@ -1296,7 +1344,14 @@ export class BrowserService {
|
||||
ref: TabRef,
|
||||
ctx: { row: BrowserSessionRow; site: SiteRow; budget: NavigationBudget },
|
||||
) => Promise<T>,
|
||||
opts: { tabId?: string; write?: boolean; persist?: boolean; patch?: (out: T) => RecoveryState } = {},
|
||||
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", "会话已关闭");
|
||||
@@ -1366,7 +1421,9 @@ export class BrowserService {
|
||||
throw fromBrowserError(e2);
|
||||
}
|
||||
}
|
||||
const automation: RecoveryState = actor.kind === "automation" ? { lastAutomationAt: this.now().toISOString() } : {};
|
||||
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);
|
||||
@@ -1374,6 +1431,23 @@ export class BrowserService {
|
||||
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 浏览器服务重启后自愈 ----------
|
||||
|
||||
/** 各浏览器服务最近一次读到的实例标识(节流缓存;只在进程内) */
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface DesktopDeps {
|
||||
fetch?: typeof fetch;
|
||||
/** 角色复查间隔 */
|
||||
roleCheckMs?: number;
|
||||
/** 桌面连接建立(AG-4):返回连接断开时调用的函数。用于连接期间保活浏览器会话 */
|
||||
onConnect?: (sessionId: string) => () => void;
|
||||
/** 可用性探测缓存 */
|
||||
probeTtlMs?: number;
|
||||
log?: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
@@ -256,6 +258,7 @@ export function registerDesktopRoutes(app: ModuleApp, deps: DesktopDeps): { clos
|
||||
if (!sameOrigin(c.req.raw)) throw new ApiException("forbidden", "桌面连接必须来自本站页面");
|
||||
const s = await requireSession(deps, sessionId, holder);
|
||||
return bridgeWebSocket(c.req.raw, target.replace(/^http/, "ws"), s.role, {
|
||||
...(deps.onConnect ? { onOpen: () => deps.onConnect?.(sessionId) ?? (() => {}) } : {}),
|
||||
auth: (role) => basicAuth(passwords[role]),
|
||||
recheck: async () => {
|
||||
if (!(await deps.consoleSessionValid(sessionRowId))) return null;
|
||||
@@ -312,6 +315,8 @@ interface BridgeOpts {
|
||||
roleCheckMs: number;
|
||||
live: Set<() => void>;
|
||||
log(msg: string, extra?: Record<string, unknown>): void;
|
||||
/** 连接已升级:返回断开时调用的函数(保活登记) */
|
||||
onOpen?: () => () => void;
|
||||
}
|
||||
|
||||
/** 浏览器 ⇄ app ⇄ Selkies 的 WebSocket 双向转发;角色变化时断开(客户端重连后按新角色认证) */
|
||||
@@ -321,6 +326,7 @@ function bridgeWebSocket(req: Request, target: string, role: DesktopRole, o: Bri
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const { socket: client, response } = Deno.upgradeWebSocket(req, protocols[0] ? { protocol: protocols[0] } : {});
|
||||
const onClose = o.onOpen?.();
|
||||
client.binaryType = "arraybuffer";
|
||||
// Deno 的客户端 WebSocket 支持自定义请求头;不带 Origin(Selkies 对无 Origin 的非浏览器客户端放行)
|
||||
// (tsc 按 DOM 类型只认 protocols 参数;Deno 运行时接受 { headers, protocols })
|
||||
@@ -336,6 +342,7 @@ function bridgeWebSocket(req: Request, target: string, role: DesktopRole, o: Bri
|
||||
closed = true;
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
o.live.delete(close);
|
||||
onClose?.();
|
||||
try {
|
||||
if (client.readyState <= WebSocket.OPEN) client.close(code, reason);
|
||||
} catch {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { browserServiceFor } from "../browser/index.ts";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts";
|
||||
|
||||
@@ -18,6 +19,8 @@ export function createDesktopModule(): ServerModule {
|
||||
}),
|
||||
accessKey: ctx.config?.BROWSER_ACCESS_KEY ?? null,
|
||||
...dbSessionChecks(ctx.db),
|
||||
// 桌面连接期间保活浏览器会话(AG-4):人在桌面里的操作不经 camofox API,不保活会被空闲 5 分钟回收
|
||||
onConnect: (sessionId) => browserServiceFor(ctx)?.keepalive.hold(sessionId, "desktop", "desktop") ?? (() => {}),
|
||||
log: (msg, extra) => ctx.logger.warn({ component: "desktop", ...extra }, msg),
|
||||
});
|
||||
closeAll = r.closeAll;
|
||||
|
||||
Reference in new issue
Block a user