diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 17ffd104..23d1016d 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -87,6 +87,11 @@ export const ConfigSchema = z.object({ BROWSER_PROVIDER_URL: z.preprocess(emptyToUndef, z.string().url().optional()), /** 该服务的访问凭据(camofox CAMOFOX_ACCESS_KEY);只在进程内使用,不入库 */ BROWSER_ACCESS_KEY: z.preprocess(emptyToUndef, z.string().min(1).optional()), + /** + * 浏览器会话保活间隔(毫秒,AG-4):桌面接管 / 人工接管 / 智能体正在用的会话每隔这么久做一次只读调用, + * 避免 camofox 空闲 5 分钟回收标签页;0 = 关闭。要比 camofox 的 TAB_INACTIVITY_MS 短。 + */ + BROWSER_KEEPALIVE_MS: z.preprocess(emptyToUndef, z.coerce.number().int().min(0).default(120_000)), /** false = 停用 camofox-env(容器可照常运行,界面标"浏览器不可用") */ BROWSER_ENABLED: z.preprocess( emptyToUndef, diff --git a/apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts b/apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts new file mode 100644 index 00000000..61c92d4b --- /dev/null +++ b/apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts @@ -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(["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([]); + }); +}); diff --git a/apps/server/src/modules/browser/capabilities.ts b/apps/server/src/modules/browser/capabilities.ts index d2aca8ac..30216b46 100644 --- a/apps/server/src/modules/browser/capabilities.ts +++ b/apps/server/src/modules/browser/capabilities.ts @@ -27,7 +27,12 @@ async function guard(fn: () => Promise): Promise { } } -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), }); diff --git a/apps/server/src/modules/browser/keepalive.ts b/apps/server/src/modules/browser/keepalive.ts new file mode 100644 index 00000000..80368f3c --- /dev/null +++ b/apps/server/src/modules/browser/keepalive.ts @@ -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; +} + +export interface KeepaliveTarget { + /** 对会话做一次保活;会话已关闭返回 false */ + keepAlive(sessionId: string): Promise; +} + +export interface KeepaliveOptions { + /** 0 = 关闭保活 */ + intervalMs: number; + log?: (level: "info" | "warn", msg: string, extra?: Record) => void; +} + +export class BrowserKeepalive { + /** sessionId → (登记键 → 登记) */ + private readonly holds = new Map>(); + private timer: ReturnType | undefined; + private ticking: Promise | 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): () => 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): 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 { + 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 { + 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; + } +} diff --git a/apps/server/src/modules/browser/service.ts b/apps/server/src/modules/browser/service.ts index 8fdda9b3..bc5b6ac4 100644 --- a/apps/server/src/modules/browser/service.ts +++ b/apps/server/src/modules/browser/service.ts @@ -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(); private envSync: Promise | 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 { + 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 { + 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 { + 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, - 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 { 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(); + + /** 智能体会话在用这个浏览器会话:会话还在排队 / 运行期间保活(最后一次使用后最多 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 浏览器服务重启后自愈 ---------- /** 各浏览器服务最近一次读到的实例标识(节流缓存;只在进程内) */ diff --git a/apps/server/src/modules/desktop/desktop.ts b/apps/server/src/modules/desktop/desktop.ts index 621f2b47..40bbdfbc 100644 --- a/apps/server/src/modules/desktop/desktop.ts +++ b/apps/server/src/modules/desktop/desktop.ts @@ -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) => 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): 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 { diff --git a/apps/server/src/modules/desktop/index.ts b/apps/server/src/modules/desktop/index.ts index a05deae4..2dbe9fd1 100644 --- a/apps/server/src/modules/desktop/index.ts +++ b/apps/server/src/modules/desktop/index.ts @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index 6829f3a2..ff3d2063 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -191,6 +191,8 @@ services: BROWSER_PROVIDER_URL: ${BROWSER_PROVIDER_URL:-http://camofox:9377} BROWSER_ACCESS_KEY: ${BROWSER_ACCESS_KEY:-${CAMOFOX_ACCESS_KEY:-}} BROWSER_ENABLED: ${BROWSER_ENABLED:-true} + # 桌面接管 / 人工接管 / 智能体正在用的会话每隔多久保活一次(camofox 标签页空闲 5 分钟回收);0 = 关闭 + BROWSER_KEEPALIVE_MS: ${BROWSER_KEEPALIVE_MS:-120000} # 原站视图桌面模式的上游(桌面镜像的 Selkies);缺省由 BROWSER_PROVIDER_URL 推出(端口换 3000) BROWSER_DESKTOP_URL: ${BROWSER_DESKTOP_URL:-} # 出站代理(HTTP_PROXY/HTTPS_PROXY)在 override 里给(见 docs/runbook/deploy.md「容器经宿主代理」)。 diff --git a/docker/compose.env.example b/docker/compose.env.example index d71061fd..6a71fcb6 100644 --- a/docker/compose.env.example +++ b/docker/compose.env.example @@ -50,6 +50,8 @@ CAMOFOX_ACCESS_KEY= # 完全不用浏览器:CAMOFOX_REPLICAS=0 且 BROWSER_ENABLED=false(界面标"浏览器不可用") # CAMOFOX_REPLICAS=1 # BROWSER_ENABLED=true +# 桌面接管 / 人工接管 / 智能体正在用的浏览器会话的保活间隔(毫秒,0 = 关闭;要比 camofox 标签页空闲回收 5 分钟短) +# BROWSER_KEEPALIVE_MS=120000 # camofox 调优(可选):会话空闲回收(回收时落盘登录状态)、浏览器空闲退出、最多会话数、内存上限 # CAMOFOX_SESSION_TIMEOUT_MS=1800000 # CAMOFOX_BROWSER_IDLE_TIMEOUT_MS=300000 diff --git a/docs/runbook/browser.md b/docs/runbook/browser.md index cedd7e87..67d080b6 100644 --- a/docs/runbook/browser.md +++ b/docs/runbook/browser.md @@ -342,7 +342,8 @@ docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./umt-root.cr - Firefox 工具栏里有 Playwright 的 "JUGGLER" 标识;用户可在画面里打开 `about:addons`、新标签页、输入任意网址——越界防护 (`policies.json`、`context.route` 白名单、交还时核对 URL,方案 §6)未做。 -- 接管期间 camofox 看不到 API 请求,`SESSION_TIMEOUT_MS`(缺省 30 分钟)到期会回收会话窗口;接管心跳(方案 §5.5 插件)未做。 +- 接管期间 camofox 看不到 API 请求:桌面连接期间与人工接管期间由 app 定期保活(§12,AG-4),不再被空闲回收; + 浏览器里的心跳插件(方案 §5.5)不做。 - 一个桌面承载所有站点会话:持有 A 站租约的人在画面里能看到 / 操作切到前台的其它窗口;目前同一时刻一般只有一个会话窗口。 - 真实输入法(Windows 微软拼音、macOS 日文)、Firefox / Safari 客户端、真实 PT 站点未测;容器内 fcitx5 只验证能启动,未验证组字。 - 画面坐标与 camofox 截图坐标不同(桌面含 56 px 工具栏,Camoufox 伪装了 `screenX` / `outerHeight`),Agent 视觉操作继续用 camofox 截图。 @@ -605,3 +606,39 @@ Camoufox 浏览器包的层都走构建缓存,**不会重新下载 Camoufox(约 `migration-browser-identity.test.ts`、`apps/server/src/modules/browser/__tests__/browser-identity.test.ts`(出口下发、身份存库)、 `packages/adapters/src/browser/umt-plugin.test.ts`(插件纯函数、适配器接口);真机:本地临时容器(`--network none`,只开容器内本地 页面)核验了 §11.1 表格与 §11.5 出口行为,未对真实站点发请求。 + +## 12. 会话保活(AG-4) + +camofox 按 **API 调用**判断活动,有三道空闲回收(`docker-compose.yml` 的 camofox 环境变量): + +| 回收 | 缺省 | 判据 | +| --- | --- | --- | +| 标签页 `TAB_INACTIVITY_MS` | 5 分钟 | 每 60 秒看一次标签页的 `toolCalls` 有没有变;没变且超过 5 分钟 → 关标签页,会话空了连会话一起关 | +| 会话 `SESSION_TIMEOUT_MS` | 30 分钟 | 会话 `lastAccess`(大部分 `/tabs/{id}/*` 接口都会刷新) | +| 浏览器 `BROWSER_IDLE_TIMEOUT_MS` | 5 分钟 | 一个会话都没有之后浏览器进程退出 | + +桌面模式里人的键鼠直接进 Selkies,原站视图截图(`GET /tabs/{id}/screenshot`)只刷新 `lastAccess`、不加 `toolCalls`—— +接管 5 分钟后标签页被关掉,桌面画面里的窗口消失。app 在"正在用"的会话上定期保活,代码 `modules/browser/keepalive.ts`、 +`BrowserService.keepAlive`、适配器 `keepAlive`。 + +**保活调用**:camofox 1.17 没有 keepalive / touch 接口;`GET /tabs/{id}/stats` 只刷新 `lastAccess`,挡不住标签页回收。 +用 `GET /tabs/{id}/downloads?userId=…`(不带 `includeData` / `consume`):只列下载元数据,不读页面、不改页面、不清下载记录, +会让 `toolCalls` 加一、刷新 `lastAccess`。只对当前标签页做,不要求控制权,不记 `lastAutomationAt`。 + +**什么时候保活**(进程内登记,各进程保活自己登记的): + +| 使用方 | 开始 | 停止 | +| --- | --- | --- | +| 桌面连接 | 桌面模式 WebSocket 建立(控制或只读都算,多个连接按引用计数) | 全部连接断开 | +| 人工接管 | `control/acquire` | 交还(`control/release`)、租约过期、会话关闭 | +| 智能体会话 | 智能体会话里的浏览器能力调用(`browser.*`,含 `browser.navigate` 新开会话) | 智能体会话不再是排队 / 运行中(结束、等待下载或等用户),或最后一次使用后 30 分钟 | + +每 `BROWSER_KEEPALIVE_MS`(缺省 120000 = 2 分钟,0 = 关闭;要比 `TAB_INACTIVITY_MS` 短)对登记的会话各做一次; +每轮先复查登记是否还有效,没有使用方的会话不再保活,照常由 camofox 空闲回收。会话关闭(`DELETE`)立即撤销全部登记。 + +**保活失败**:保活走与其它动作相同的 `run()` 路径——浏览器重启过(实例标识变了)或标签页已被回收(404 / 410、适配器里没有打开的标签页) +→ 按 §10 自愈重连(注入 Cookie、同一会话新开标签页回到原地址)后在新标签页上再保活一次;重连也失败(浏览器服务不可用)只记 warn 日志 +「浏览器会话保活失败」,会话照常标 `needsReconnect` / `reconnect.status = failed`,下一轮再试。 + +核验:`apps/server/src/modules/browser/__tests__/browser-keepalive.test.ts`(登记 / 引用计数 / 复查;对 mock camofox:接管期间保活、 +不记自动化活动、交还后停;桌面连接期间标签页被回收 → 保活时自愈重连;智能体会话结束后停、会话关闭撤销)。真机未核验。 diff --git a/docs/runbook/deploy.md b/docs/runbook/deploy.md index 9a95dbdf..16161ad2 100644 --- a/docs/runbook/deploy.md +++ b/docs/runbook/deploy.md @@ -134,6 +134,7 @@ docker compose logs app | grep 'setup token' | `BROWSER_PROVIDER_URL` | `http://camofox:9377` | app 登记的浏览器服务地址(`camofox-env`);接自有实例时改 | | `BROWSER_ACCESS_KEY` | `CAMOFOX_ACCESS_KEY` | 该服务的访问凭据,只在进程内使用,不入库 | | `BROWSER_ENABLED` | `true` | `false` = 停用 `camofox-env`,界面标"浏览器不可用" | +| `BROWSER_KEEPALIVE_MS` | `120000` | 桌面接管 / 人工接管 / 智能体正在用的浏览器会话的保活间隔,0 = 关闭;要比 camofox 标签页空闲回收(5 分钟)短(`docs/runbook/browser.md` §12) | | `CAMOFOX_REPLICAS` | `1` | `0` = 不起 camofox 容器 | | `CAMOFOX_SESSION_TIMEOUT_MS` / `CAMOFOX_BROWSER_IDLE_TIMEOUT_MS` | `1800000` / `300000` | 会话空闲回收(落盘登录状态)/ 浏览器空闲退出 | | `CAMOFOX_MAX_SESSIONS` / `CAMOFOX_MEM_LIMIT` | `8` / `2g` | 最多并发会话 / 容器内存上限 | diff --git a/packages/adapters/src/browser/camofox/client.ts b/packages/adapters/src/browser/camofox/client.ts index 5229f147..dcc0e7e4 100644 --- a/packages/adapters/src/browser/camofox/client.ts +++ b/packages/adapters/src/browser/camofox/client.ts @@ -811,6 +811,24 @@ export class CamofoxBrowserProvider implements BrowserProvider { }); } + /** + * AG-4 保活:camofox 1.17 没有 keepalive / touch 接口。`GET /tabs/{id}/stats` 只刷新会话的 lastAccess, + * 不算标签页活动(标签页回收看 toolCalls 有没有变化);最轻的会让 toolCalls 加一的只读调用是 + * `GET /tabs/{id}/downloads`(不带 includeData / consume:只列下载元数据,不读页面、不清下载记录)。 + */ + async keepAlive(ref: TabRef): Promise { + const s = this.state(ref.sessionId); + await this.withProfile(s, async () => { + const tab = this.tabOf(s, ref); + const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/downloads`; + const res = await this.request("GET", path, { + query: { userId: s.session.profileKey }, + timeoutMs: Math.min(this.cfg.timeoutMs ?? 30_000, 10_000), + }); + await res.body?.cancel(); + }); + } + // ---------- 页面 ---------- async navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise { diff --git a/packages/adapters/src/browser/provider.ts b/packages/adapters/src/browser/provider.ts index b40f5b17..4734b743 100644 --- a/packages/adapters/src/browser/provider.ts +++ b/packages/adapters/src/browser/provider.ts @@ -93,6 +93,14 @@ export interface BrowserProvider { */ reopenSession?(sessionId: string, req: ReopenSessionRequest): Promise; + // ── AG-4 保活 ── + /** + * 对标签页做一次最轻的只读调用,让上游把它算作"有活动"(camofox 标签页空闲回收 TAB_INACTIVITY_MS 与会话空闲回收 + * SESSION_TIMEOUT_MS 都按 API 调用计;桌面模式里人的键鼠操作不经 API,不算活动)。不检查控制者、不改页面。 + * 标签页不在了报 not_found(调用方按 BR-3 自愈重连)。 + */ + keepAlive?(ref: TabRef): Promise; + // ── BR-4 浏览器 UA 与指纹统一、出口按站点 ── /** 固定浏览器身份的公开部分(UA、OS、版本);上游没有时返回 null */ identity?(): Promise;