feat(browser): W4-C BrowserProvider contract and camofox-browser adapter skeleton
- contracts/browser.ts: provider kind, per-capability {supported, verified} flags,
session/tab/control-lease rows, NavigationBudget, redacted BrowserSnapshot,
DownloadArtifact with blob/secret refs, BrowserErrorKind
- adapters/browser: BrowserProvider interface, camofox client (health, capabilities
static + /openapi.json probe, openSession/navigate/snapshot/screenshot; rest
not_implemented for M2), none provider, redactSnapshot, profile serial lock,
domain allowlist budget; Bearer access key via BrowserSecretResolver
- testkit/browser: mock camofox server (auth, fixtures with secrets, faults)
- db 0023: browser_provider/session/tab/control_lease with one-active-lease and
one-live-session-per-profile partial unique indexes; types regenerated
- docs/design/browser-provider.md: contract, camofox OpenAPI 1.17.0 mapping,
unverified items and M2 prototype checklist; Playwright is not a product browser
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
269a2269a1
commit
effaa32b70
23 files changed
+2290
No files matched your search
@@ -0,0 +1,26 @@
|
||||
# browser
|
||||
|
||||
`BrowserProvider` 合同(`provider.ts`)及其实现:业务侧访问浏览器服务只走这套接口。产品浏览器能力对接用户已有的 camofox-browser;**Playwright 只用于前端截图测试,不是产品浏览器能力**。设计说明见 `docs/design/browser-provider.md`。
|
||||
|
||||
- `camofox/`:camofox-browser 适配器。按 jo-inc/camofox-browser master `openapi.json`(1.17.0)与 README 实现,`Authorization: Bearer <CAMOFOX_ACCESS_KEY>`,凭据每次请求经 `BrowserSecretResolver` 解出,不缓存、不入日志。camofox `userId` = 本项目 `profileKey`(站点/账号隔离),`sessionKey` = `browser_session.id`。
|
||||
- `none.ts`:默认实现(未配置浏览器),能力全关,动作报 `unavailable`。
|
||||
- `redact.ts`:`redactSnapshot` / `redactBrowserUrl`,快照与地址进入模型前遮蔽密码框值、Cookie 值、含 passkey/token 的 URL。
|
||||
- `profile-lock.ts`:同一 profile 串行锁(进程内内存实现;多进程部署换持久化实现,接口不变)。
|
||||
- `budget.ts`:导航域名白名单(含子域),非 http(s) 拒绝。
|
||||
|
||||
## v0.1 已实现
|
||||
|
||||
`health`(`/health`,免认证,503 → degraded)、`capabilities`(静态声明;`probe: true` 读上游 `/openapi.json` 推导,缺端点即降级)、`openSession`(`POST /tabs`)、`navigate`(`POST /tabs/{id}/navigate`,预算内重试 5xx/超时/网络错误)、`snapshot`(`GET /tabs/{id}/snapshot?format=text`,字节预算截断 + 脱敏)、`screenshot`(`GET /tabs/{id}/screenshot`,JSON base64 或二进制图片;登录页/含凭据地址标 `modelSafe: false`)。
|
||||
|
||||
## M2(当前抛 `BrowserError('not_implemented')`)
|
||||
|
||||
| 方法 | 上游候选端点 | 待办 |
|
||||
| --- | --- | --- |
|
||||
| `closeSession` | `DELETE /sessions/{userId}` | 与 browser_session 持久化、租约释放一起落地 |
|
||||
| `listSessions` | `GET /tabs?userId=` | 上游只列标签页,会话清单以 DB 为准,需对账 |
|
||||
| `click` / `type` | `POST /tabs/{id}/click` / `type` / `select` | 接凭证代理(`TypeInput.kind = 'secret'`);README 示例 `pressEnter` 与 OpenAPI `submit` 字段名不一致,需核验 |
|
||||
| `extract` | `POST /tabs/{id}/extract`(JSON Schema + `x-ref`) | 需先 snapshot 建 ref 表 |
|
||||
| `waitForDownload` | `GET /tabs/{id}/downloads?includeData&consume&maxBytes` | 产物落 blob 暂存 + sha256,交后端核验 |
|
||||
| `acquireControl` / `releaseControl` | 无(上游无接管 API) | 服务层以 `browser_control_lease` 持久化;画面/输入通道待 VNC 原型 |
|
||||
|
||||
仅对 mock 与上游文档验证,未连接真实实例。
|
||||
@@ -0,0 +1,348 @@
|
||||
import type { NavigationBudget } from "@umt/contracts";
|
||||
import {
|
||||
CAMOFOX_FIXTURE_COOKIE,
|
||||
CAMOFOX_FIXTURE_PASSKEY,
|
||||
CAMOFOX_FIXTURE_PASSWORD,
|
||||
createMockCamofox,
|
||||
type MockCamofox,
|
||||
} from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
BrowserError,
|
||||
type BrowserLogger,
|
||||
type CamofoxConfig,
|
||||
camofoxStaticCapabilities,
|
||||
classifyHttpStatus,
|
||||
createCamofoxBrowserProvider,
|
||||
createNoneBrowserProvider,
|
||||
hostAllowed,
|
||||
InMemoryProfileLock,
|
||||
profileKeyFor,
|
||||
redactBrowserUrl,
|
||||
redactSnapshot,
|
||||
staticBrowserSecrets,
|
||||
} from "./index.ts";
|
||||
|
||||
const ACCESS_KEY = "camofox-access-key-fixture";
|
||||
const PROVIDER = "11111111-1111-4111-8111-111111111111";
|
||||
const SITE = "22222222-2222-4222-8222-222222222222";
|
||||
const ACCOUNT = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
const budget = (over: Partial<NavigationBudget> = {}): NavigationBudget => ({
|
||||
domainAllowlist: ["audiences.example"],
|
||||
timeoutMs: 500,
|
||||
maxRetries: 1,
|
||||
maxBytes: 1_000_000,
|
||||
...over,
|
||||
});
|
||||
|
||||
let mocks: MockCamofox[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(mocks.map((m) => m.close()));
|
||||
mocks = [];
|
||||
});
|
||||
|
||||
async function setup(
|
||||
opts: { accessKey?: string; key?: string | null; omitPaths?: string[] } = {},
|
||||
extra: Partial<CamofoxConfig> = {},
|
||||
) {
|
||||
const mock = await createMockCamofox({
|
||||
...(opts.accessKey ? { accessKey: opts.accessKey } : {}),
|
||||
...(opts.omitPaths ? { omitPaths: opts.omitPaths } : {}),
|
||||
});
|
||||
mocks.push(mock);
|
||||
const logs: Parameters<BrowserLogger>[0][] = [];
|
||||
const key = opts.key === undefined ? opts.accessKey : opts.key;
|
||||
const provider = createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: `${mock.url}/`,
|
||||
secrets: staticBrowserSecrets(key ? { [`provider:${PROVIDER}`]: key } : {}),
|
||||
timeoutMs: 500,
|
||||
logger: (e) => logs.push(e),
|
||||
...extra,
|
||||
});
|
||||
return { mock, provider, logs };
|
||||
}
|
||||
|
||||
const open = (p: Awaited<ReturnType<typeof setup>>["provider"], url?: string) =>
|
||||
p.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: ACCOUNT,
|
||||
budget: budget(),
|
||||
...(url ? { initialUrl: url } : {}),
|
||||
});
|
||||
|
||||
async function rejectKind(p: Promise<unknown>): Promise<string> {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(BrowserError);
|
||||
return (e as BrowserError).kind;
|
||||
}
|
||||
throw new Error("预期抛错");
|
||||
}
|
||||
|
||||
describe("能力声明", () => {
|
||||
it("静态声明:已实现能力 supported、M2 能力 false 并注明,默认全部未验证", () => {
|
||||
const c = camofoxStaticCapabilities({ snapshot: true, interact: true });
|
||||
expect(c.navigate).toMatchObject({ supported: true, verified: false, source: "static" });
|
||||
expect(c.snapshot).toMatchObject({ supported: true, verified: true });
|
||||
// 未实现的能力即使配置了 verified 也不会被标为已验证
|
||||
expect(c.interact).toMatchObject({ supported: false, verified: false });
|
||||
expect(c.interact.note).toContain("M2");
|
||||
expect(c.takeover.supported).toBe(false);
|
||||
expect(c.upstreamVersion).toBeNull();
|
||||
});
|
||||
|
||||
it("探测 /openapi.json:读取上游版本,缺端点的能力降级", async () => {
|
||||
const { provider } = await setup({ omitPaths: ["get /tabs/{tabId}/snapshot"] });
|
||||
const c = await provider.capabilities({ probe: true });
|
||||
expect(c.upstreamVersion).toBe("1.17.0");
|
||||
expect(c.probedAt).not.toBeNull();
|
||||
expect(c.navigate).toMatchObject({ supported: true, source: "probed", verified: false });
|
||||
expect(c.snapshot).toMatchObject({ supported: false, source: "probed" });
|
||||
expect(c.snapshot.note).toContain("/tabs/{tabId}/snapshot");
|
||||
});
|
||||
|
||||
it("none 实现:能力全关,动作报 unavailable", async () => {
|
||||
const none = createNoneBrowserProvider();
|
||||
const c = await none.capabilities();
|
||||
expect(Object.values(c).filter((v) => v && typeof v === "object" && "supported" in v && v.supported)).toEqual([]);
|
||||
expect((await none.health()).status).toBe("disabled");
|
||||
expect(await rejectKind(none.navigate({ sessionId: "x" }, "https://a.example", budget()))).toBe("unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("认证与健康", () => {
|
||||
it("每个请求带 Bearer 访问凭据;凭据不进日志与错误信息", async () => {
|
||||
const { mock, provider, logs } = await setup({ accessKey: ACCESS_KEY });
|
||||
await open(provider, "https://audiences.example/torrents.php");
|
||||
const call = mock.callsTo("/tabs")[0]!;
|
||||
expect(call.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
|
||||
expect(JSON.stringify(logs)).not.toContain(ACCESS_KEY);
|
||||
expect(logs.every((l) => !l.path?.includes("?"))).toBe(true);
|
||||
});
|
||||
|
||||
it("凭据缺失/错误 → auth,不重试", async () => {
|
||||
const { mock, provider } = await setup({ accessKey: ACCESS_KEY, key: "wrong-key" });
|
||||
try {
|
||||
await open(provider);
|
||||
throw new Error("预期抛错");
|
||||
} catch (e) {
|
||||
expect(e).toMatchObject({ kind: "auth", httpStatus: 401 });
|
||||
expect(String((e as Error).message)).not.toContain("wrong-key");
|
||||
}
|
||||
expect(mock.callsTo("/tabs")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("health:/health 免认证;503 → degraded;连不上 → down", async () => {
|
||||
const { mock, provider } = await setup({ accessKey: ACCESS_KEY, key: null });
|
||||
const ok = await provider.health();
|
||||
expect(ok.status).toBe("ok");
|
||||
expect(ok.detail).toMatchObject({ engine: "camoufox", browserConnected: true });
|
||||
mock.fault("/health", { status: 503, body: { ok: false, recovering: true } });
|
||||
expect((await provider.health()).status).toBe("degraded");
|
||||
await mock.close();
|
||||
mocks = [];
|
||||
expect((await provider.health()).status).toBe("down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("会话、导航与快照", () => {
|
||||
it("openSession 以 profileKey 作 camofox userId,会话 id 作 sessionKey", async () => {
|
||||
const { mock, provider } = await setup();
|
||||
const r = await open(provider, "https://audiences.example/torrents.php");
|
||||
expect(r.session.profileKey).toBe(profileKeyFor(SITE, ACCOUNT));
|
||||
expect(r.session.controller).toBe("automation");
|
||||
expect(r.tab.upstreamTabId).toBe("tab-1");
|
||||
expect(r.navigation).toMatchObject({ httpStatus: 200, ok: true });
|
||||
expect([...mock.tabs.values()][0]).toMatchObject({ userId: r.session.profileKey, sessionKey: r.session.id });
|
||||
// 同一 profile 不能并存两个未关闭会话
|
||||
expect(await rejectKind(open(provider))).toBe("control_conflict");
|
||||
});
|
||||
|
||||
it("快照进入模型前脱敏:密码值、Cookie 值、passkey URL 均被遮蔽", async () => {
|
||||
const { provider } = await setup();
|
||||
const { session } = await open(provider, "https://audiences.example/torrents.php");
|
||||
await provider.navigate(
|
||||
{ sessionId: session.id },
|
||||
`https://audiences.example/details.php?id=42&passkey=${CAMOFOX_FIXTURE_PASSKEY}`,
|
||||
budget(),
|
||||
);
|
||||
const snap = await provider.snapshot({ sessionId: session.id });
|
||||
const dump = JSON.stringify(snap);
|
||||
for (const secret of [CAMOFOX_FIXTURE_PASSWORD, CAMOFOX_FIXTURE_COOKIE, CAMOFOX_FIXTURE_PASSKEY])
|
||||
expect(dump).not.toContain(secret);
|
||||
expect(snap.text).toContain("奥本海默");
|
||||
expect(snap.text).toContain("[button e6] 登录");
|
||||
expect(snap.url).toBe("https://audiences.example/details.php?id=42&passkey=[REDACTED]");
|
||||
const kinds = Object.fromEntries(snap.redactions.map((r) => [r.kind, r.count]));
|
||||
expect(kinds.password_field).toBeGreaterThanOrEqual(2);
|
||||
expect(kinds.cookie).toBeGreaterThanOrEqual(1);
|
||||
expect(kinds.credential_url).toBeGreaterThanOrEqual(2);
|
||||
expect(snap.refsCount).toBe(7);
|
||||
});
|
||||
|
||||
it("redactSnapshot:HTML 密码框、URL userinfo、路径 passkey、YAML 形式;普通文本不动", () => {
|
||||
const r = redactSnapshot({
|
||||
text: [
|
||||
'<input name="pw" type="password" value="s3cret-html">',
|
||||
"https://user:[email protected]/announce/passkey/abcdef0123456789",
|
||||
'- textbox "Password": yaml-secret',
|
||||
"做种 128 · 大小 58.2 GB",
|
||||
].join("\n"),
|
||||
url: "https://audiences.example/rss.php?rsskey=deadbeefdeadbeef&cat=401",
|
||||
});
|
||||
expect(r.text).not.toMatch(/s3cret-html|pw123|abcdef0123456789|yaml-secret/);
|
||||
expect(r.text).toContain("做种 128 · 大小 58.2 GB");
|
||||
expect(r.url).toBe("https://audiences.example/rss.php?rsskey=[REDACTED]&cat=401");
|
||||
expect(redactBrowserUrl("https://a.example/x?id=1")).toBe("https://a.example/x?id=1");
|
||||
// 幂等:已脱敏的文本再过一遍不变
|
||||
expect(redactSnapshot({ text: r.text, url: r.url }).text).toBe(r.text);
|
||||
});
|
||||
|
||||
it("导航预算:白名单外直接拒绝不发请求;重定向出白名单报 domain_blocked", async () => {
|
||||
const { mock, provider } = await setup();
|
||||
const { session } = await open(provider);
|
||||
expect(await rejectKind(provider.navigate({ sessionId: session.id }, "https://evil.example/", budget()))).toBe(
|
||||
"domain_blocked",
|
||||
);
|
||||
expect(await rejectKind(provider.navigate({ sessionId: session.id }, "file:///etc/passwd", budget()))).toBe(
|
||||
"domain_blocked",
|
||||
);
|
||||
expect(mock.callsTo(/navigate$/)).toHaveLength(0);
|
||||
const redirect = `https://audiences.example/jump?redirect=${encodeURIComponent("https://other.example/login")}`;
|
||||
expect(await rejectKind(provider.navigate({ sessionId: session.id }, redirect, budget()))).toBe("domain_blocked");
|
||||
expect(hostAllowed("cdn.audiences.example", ["audiences.example"])).toBe(true);
|
||||
expect(hostAllowed("audiences.example.evil", ["audiences.example"])).toBe(false);
|
||||
expect(hostAllowed("a.example", [])).toBe(false);
|
||||
});
|
||||
|
||||
it("截图:解码 1x1 PNG;登录页不可进模型;超字节预算报 budget_exceeded", async () => {
|
||||
const { provider } = await setup();
|
||||
const { session } = await open(provider, "https://audiences.example/torrents.php");
|
||||
const shot = await provider.screenshot({ sessionId: session.id });
|
||||
expect(shot.mimeType).toBe("image/png");
|
||||
expect([...shot.data.slice(1, 4)].map((c) => String.fromCharCode(c)).join("")).toBe("PNG");
|
||||
expect(shot.modelSafe).toBe(true);
|
||||
await provider.navigate({ sessionId: session.id }, "https://audiences.example/login.php", budget());
|
||||
expect((await provider.screenshot({ sessionId: session.id })).modelSafe).toBe(false);
|
||||
expect(await rejectKind(provider.screenshot({ sessionId: session.id }, { maxBytes: 10 }))).toBe("budget_exceeded");
|
||||
});
|
||||
|
||||
it("人工接管期间拒绝自动化导航", async () => {
|
||||
const { provider } = await setup();
|
||||
const { session } = await open(provider);
|
||||
provider.setController(session.id, "human");
|
||||
expect(await rejectKind(provider.navigate({ sessionId: session.id }, "https://audiences.example/", budget()))).toBe(
|
||||
"control_conflict",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("串行锁", () => {
|
||||
it("同一 profile 严格串行、不同 profile 并行;等待超时报 lock_timeout", async () => {
|
||||
const lock = new InMemoryProfileLock();
|
||||
const order: string[] = [];
|
||||
const task = (key: string, name: string, ms: number) =>
|
||||
lock.run(key, async () => {
|
||||
order.push(`${name}:start`);
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
order.push(`${name}:end`);
|
||||
});
|
||||
await Promise.all([task("p1", "a", 20), task("p1", "b", 1), task("p2", "c", 1)]);
|
||||
expect(order.indexOf("a:end")).toBeLessThan(order.indexOf("b:start"));
|
||||
expect(order.indexOf("c:start")).toBeLessThan(order.indexOf("a:end"));
|
||||
expect(lock.size()).toBe(0);
|
||||
|
||||
const slow = lock.run("p1", () => new Promise((r) => setTimeout(r, 60)));
|
||||
try {
|
||||
await lock.run("p1", async () => "never", { waitMs: 10 });
|
||||
throw new Error("预期超时");
|
||||
} catch (e) {
|
||||
expect(e).toMatchObject({ kind: "lock_timeout" });
|
||||
}
|
||||
await slow;
|
||||
expect(lock.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("适配器对同一会话的并发请求逐个发往上游", async () => {
|
||||
const { mock, provider } = await setup();
|
||||
const { session } = await open(provider, "https://audiences.example/torrents.php");
|
||||
mock.fault(/snapshot$/, { delayMs: 40 });
|
||||
const started = Date.now();
|
||||
await Promise.all([provider.snapshot({ sessionId: session.id }), provider.screenshot({ sessionId: session.id })]);
|
||||
const shotCall = mock.callsTo(/screenshot$/)[0]!;
|
||||
expect(shotCall.status).toBe(200);
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("M2 未实现分支", () => {
|
||||
it("click/type/extract/waitForDownload/接管/关闭会话均抛 not_implemented", async () => {
|
||||
const { provider } = await setup();
|
||||
const ref = { sessionId: "x" };
|
||||
const calls: Promise<unknown>[] = [
|
||||
provider.click(ref, { ref: "e1" }),
|
||||
provider.type(ref, { ref: "e1" }, { kind: "text", text: "x" }),
|
||||
provider.extract(ref, {}),
|
||||
provider.waitForDownload(ref, { timeoutMs: 10, maxBytes: 10 }),
|
||||
provider.acquireControl({
|
||||
sessionId: "x",
|
||||
holderKind: "human",
|
||||
holder: "admin",
|
||||
reason: "登录验证",
|
||||
ttlMs: 1000,
|
||||
}),
|
||||
provider.releaseControl("lease"),
|
||||
provider.closeSession("x"),
|
||||
provider.listSessions(),
|
||||
];
|
||||
for (const c of calls) expect(await rejectKind(c)).toBe("not_implemented");
|
||||
});
|
||||
});
|
||||
|
||||
describe("错误分类", () => {
|
||||
it("HTTP 状态映射", () => {
|
||||
expect(classifyHttpStatus(401)).toBe("auth");
|
||||
expect(classifyHttpStatus(403)).toBe("auth");
|
||||
expect(classifyHttpStatus(404)).toBe("not_found");
|
||||
expect(classifyHttpStatus(409)).toBe("bad_request");
|
||||
expect(classifyHttpStatus(429)).toBe("rate_limited");
|
||||
expect(classifyHttpStatus(500)).toBe("upstream");
|
||||
expect(classifyHttpStatus(503)).toBe("unavailable");
|
||||
expect(new BrowserError("upstream").retryable).toBe(true);
|
||||
expect(new BrowserError("auth").retryable).toBe(false);
|
||||
});
|
||||
|
||||
it("上游 5xx 在 maxRetries 内重试;超时分类为 timeout;429 不重试", async () => {
|
||||
const { mock, provider } = await setup();
|
||||
const { session } = await open(provider);
|
||||
const ref = { sessionId: session.id };
|
||||
mock.fault(/navigate$/, { status: 502, times: 1 });
|
||||
const r = await provider.navigate(ref, "https://audiences.example/torrents.php", budget({ maxRetries: 1 }));
|
||||
expect(r.attempts).toBe(2);
|
||||
|
||||
mock.fault(/navigate$/, { delayMs: 200, times: 5 });
|
||||
expect(
|
||||
await rejectKind(provider.navigate(ref, "https://audiences.example/", budget({ timeoutMs: 50, maxRetries: 0 }))),
|
||||
).toBe("timeout");
|
||||
mock.clearFaults();
|
||||
|
||||
mock.fault(/navigate$/, { status: 429, times: 5 });
|
||||
const before = mock.callsTo(/navigate$/).length;
|
||||
expect(await rejectKind(provider.navigate(ref, "https://audiences.example/", budget({ maxRetries: 3 })))).toBe(
|
||||
"rate_limited",
|
||||
);
|
||||
expect(mock.callsTo(/navigate$/).length - before).toBe(1);
|
||||
});
|
||||
|
||||
it("未知会话 → not_found;上游返回非 JSON → protocol", async () => {
|
||||
const { mock, provider } = await setup();
|
||||
expect(await rejectKind(provider.snapshot({ sessionId: "nope" }))).toBe("not_found");
|
||||
const { session } = await open(provider);
|
||||
mock.fault(/snapshot$/, { status: 200, body: undefined });
|
||||
expect(await rejectKind(provider.snapshot({ sessionId: session.id }))).toBe("protocol");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NavigationBudget } from "@umt/contracts";
|
||||
import { BrowserError } from "./errors.ts";
|
||||
import { redactBrowserUrl } from "./redact.ts";
|
||||
|
||||
/** 主机是否在白名单内:精确匹配或为白名单项的子域。空白名单 = 全部拒绝。 */
|
||||
export function hostAllowed(host: string, allowlist: readonly string[]): boolean {
|
||||
const h = host.toLowerCase().replace(/\.$/, "");
|
||||
return allowlist.some((raw) => {
|
||||
const d = raw.toLowerCase().replace(/^\*\./, "").replace(/\.$/, "");
|
||||
return d !== "" && (h === d || h.endsWith(`.${d}`));
|
||||
});
|
||||
}
|
||||
|
||||
/** 导航前检查:只允许 http(s) 且主机在白名单内;违规抛 domain_blocked(信息里只含脱敏 URL) */
|
||||
export function assertNavigable(url: string, budget: NavigationBudget): URL {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(url);
|
||||
} catch {
|
||||
throw new BrowserError("bad_request", "非法 URL");
|
||||
}
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:")
|
||||
throw new BrowserError("domain_blocked", `不允许的协议 ${u.protocol}`);
|
||||
if (!hostAllowed(u.hostname, budget.domainAllowlist))
|
||||
throw new BrowserError("domain_blocked", `域名不在白名单:${redactBrowserUrl(u.origin)}`);
|
||||
return u;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { BrowserCapabilities, BrowserCapabilityFlag, BrowserCapabilityKey } from "@umt/contracts";
|
||||
|
||||
/**
|
||||
* camofox-browser 能力声明。依据:jo-inc/camofox-browser master 的 openapi.json(info.version 1.17.0)与 README,
|
||||
* 2026-09-27 取得;用户实际部署可能是其他 fork/版本,运行时以 /openapi.json 探测结果为准(REQ §4.7.4)。
|
||||
*
|
||||
* supported = 本适配器当前能对该上游提供的能力(上游有端点但适配器尚未实现的记 false 并注明 M2);
|
||||
* verified 只能由真实实例原型验证结果置 true(配置注入),探测永远不会把它设为 true。
|
||||
*/
|
||||
|
||||
/** 每项能力依赖的上游端点(OpenAPI paths 键,小写方法) */
|
||||
export const CAMOFOX_ENDPOINTS: Record<BrowserCapabilityKey, readonly string[]> = {
|
||||
sessions: ["post /tabs", "delete /sessions/{userId}"],
|
||||
tabs: ["post /tabs", "get /tabs", "delete /tabs/{tabId}"],
|
||||
navigate: ["post /tabs/{tabId}/navigate"],
|
||||
snapshot: ["get /tabs/{tabId}/snapshot"],
|
||||
screenshot: ["get /tabs/{tabId}/screenshot"],
|
||||
interact: ["post /tabs/{tabId}/click", "post /tabs/{tabId}/type", "post /tabs/{tabId}/select"],
|
||||
extract: ["post /tabs/{tabId}/extract"],
|
||||
downloadCapture: ["get /tabs/{tabId}/downloads"],
|
||||
// 上游 OpenAPI 无接管端点;VNC 插件(ENABLE_VNC,noVNC 独立端口)是整块虚拟显示,需原型验证
|
||||
takeover: [],
|
||||
// 持久化插件默认开启,无需端点;storage_state 端点属插件,不在 OpenAPI 中
|
||||
persistentProfile: [],
|
||||
};
|
||||
|
||||
/** 适配器已实现的能力;其余上游虽有端点,适配器在 M2 实现 */
|
||||
const ADAPTER_IMPLEMENTED: ReadonlySet<BrowserCapabilityKey> = new Set([
|
||||
"sessions",
|
||||
"tabs",
|
||||
"navigate",
|
||||
"snapshot",
|
||||
"screenshot",
|
||||
"persistentProfile",
|
||||
]);
|
||||
|
||||
const STATIC_NOTES: Partial<Record<BrowserCapabilityKey, string>> = {
|
||||
sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;关闭会话适配器 M2",
|
||||
tabs: "仅建首个标签页;列举/关闭适配器 M2",
|
||||
snapshot: "可访问性树文本 + eN 元素引用,支持 offset 分页",
|
||||
interact: "上游有 click/type/select;适配器 M2",
|
||||
extract: "上游 POST /extract 需先 snapshot 建 ref 表(x-ref);适配器 M2",
|
||||
downloadCapture: "上游 GET /downloads 支持 includeData/consume/maxBytes;适配器 M2",
|
||||
takeover: "上游无接管 API;VNC 插件画面/输入与同会话连续操作待原型验证",
|
||||
persistentProfile: "上游持久化插件默认保存 cookies+localStorage(~/.camofox/profiles),重启恢复待验证",
|
||||
};
|
||||
|
||||
export type VerifiedMap = Partial<Record<BrowserCapabilityKey, boolean>>;
|
||||
|
||||
const KEYS = Object.keys(CAMOFOX_ENDPOINTS) as BrowserCapabilityKey[];
|
||||
|
||||
function flag(
|
||||
key: BrowserCapabilityKey,
|
||||
supported: boolean,
|
||||
source: "static" | "probed",
|
||||
verified: VerifiedMap,
|
||||
note?: string,
|
||||
): BrowserCapabilityFlag {
|
||||
const n = note ?? STATIC_NOTES[key];
|
||||
return { supported, verified: supported && verified[key] === true, source, ...(n ? { note: n } : {}) };
|
||||
}
|
||||
|
||||
export function camofoxStaticCapabilities(verified: VerifiedMap = {}): BrowserCapabilities {
|
||||
const flags = Object.fromEntries(
|
||||
KEYS.map((k) => [k, flag(k, ADAPTER_IMPLEMENTED.has(k), "static", verified)]),
|
||||
) as Record<BrowserCapabilityKey, BrowserCapabilityFlag>;
|
||||
return { ...flags, upstreamVersion: null, probedAt: null };
|
||||
}
|
||||
|
||||
/** 从上游 openapi.json 文档推导能力:缺端点的能力降为 false 并注明 */
|
||||
export function camofoxCapabilitiesFromOpenApi(
|
||||
doc: unknown,
|
||||
verified: VerifiedMap = {},
|
||||
now = new Date(),
|
||||
): BrowserCapabilities {
|
||||
const d = (doc ?? {}) as { info?: { version?: unknown }; paths?: Record<string, Record<string, unknown>> };
|
||||
const have = new Set<string>();
|
||||
for (const [p, ops] of Object.entries(d.paths ?? {}))
|
||||
for (const m of Object.keys(ops ?? {})) have.add(`${m.toLowerCase()} ${p}`);
|
||||
// 路径参数名不同的 fork 也视为同一端点
|
||||
const norm = (s: string) => s.replace(/\{[^}]+\}/g, "{}");
|
||||
const haveNorm = new Set([...have].map(norm));
|
||||
const flags = Object.fromEntries(
|
||||
KEYS.map((k) => {
|
||||
const missing = CAMOFOX_ENDPOINTS[k].filter((e) => !haveNorm.has(norm(e)));
|
||||
if (missing.length > 0) return [k, flag(k, false, "probed", verified, `上游缺少端点:${missing.join(", ")}`)];
|
||||
return [k, flag(k, ADAPTER_IMPLEMENTED.has(k), CAMOFOX_ENDPOINTS[k].length > 0 ? "probed" : "static", verified)];
|
||||
}),
|
||||
) as Record<BrowserCapabilityKey, BrowserCapabilityFlag>;
|
||||
return {
|
||||
...flags,
|
||||
upstreamVersion: typeof d.info?.version === "string" ? d.info.version : null,
|
||||
probedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* camofox-browser 适配器(BrowserProvider 首个实现)。
|
||||
* 对照:jo-inc/camofox-browser master openapi.json(info.version 1.17.0)与 README,仅读文档,未连接真实实例。
|
||||
*
|
||||
* 上游模型与本项目合同的映射:
|
||||
* - camofox userId = 一个隔离 BrowserContext(cookies/storage),本项目用 profileKey(站点/账号)充当;
|
||||
* - camofox sessionKey = 标签组,本项目用 browser_session.id;
|
||||
* - 上游没有"建会话"端点,首个 POST /tabs 隐式创建;会话由本适配器在内存登记,持久化由服务层写 browser_session。
|
||||
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
|
||||
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
|
||||
*
|
||||
* v0.1 实现:health、capabilities(静态 + /openapi.json 探测)、openSession、navigate、snapshot、screenshot;
|
||||
* 其余方法抛 BrowserError('not_implemented'),见 README 的 M2 清单。
|
||||
*/
|
||||
import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserHealth,
|
||||
BrowserSession,
|
||||
BrowserSnapshot,
|
||||
BrowserTab,
|
||||
DownloadArtifact,
|
||||
NavigationBudget,
|
||||
} from "@umt/contracts";
|
||||
import { assertNavigable, hostAllowed } from "../budget.ts";
|
||||
import { BrowserError, classifyHttpStatus, notImplemented } from "../errors.ts";
|
||||
import { InMemoryProfileLock, type ProfileLock } from "../profile-lock.ts";
|
||||
import {
|
||||
type AcquireControlRequest,
|
||||
type BrowserLogger,
|
||||
type BrowserProvider,
|
||||
type BrowserScreenshot,
|
||||
type BrowserSecretResolver,
|
||||
type ElementTarget,
|
||||
type NavigationResult,
|
||||
type OpenSessionRequest,
|
||||
type OpenSessionResult,
|
||||
profileKeyFor,
|
||||
type SnapshotOptions,
|
||||
type TabRef,
|
||||
type TypeInput,
|
||||
} from "../provider.ts";
|
||||
import { redactBrowserUrl, redactSnapshot, urlHasCredential } from "../redact.ts";
|
||||
import { camofoxCapabilitiesFromOpenApi, camofoxStaticCapabilities, type VerifiedMap } from "./capabilities.ts";
|
||||
|
||||
export interface CamofoxConfig {
|
||||
/** browser_provider.id */
|
||||
providerId: string;
|
||||
/** 例 http://camofox:9377 */
|
||||
baseUrl: string;
|
||||
secrets: BrowserSecretResolver;
|
||||
/** 单次请求默认超时;导航用 budget.timeoutMs */
|
||||
timeoutMs?: number;
|
||||
/** 同一 profile 串行锁;缺省进程内内存锁 */
|
||||
lock?: ProfileLock;
|
||||
lockWaitMs?: number;
|
||||
/** 已对真实实例验证通过的能力(来自原型验证记录);缺省全部未验证 */
|
||||
verified?: VerifiedMap;
|
||||
/** 截图/快照默认字节上限(未传 budget 时) */
|
||||
defaultMaxBytes?: number;
|
||||
fetch?: typeof fetch;
|
||||
logger?: BrowserLogger;
|
||||
now?: () => Date;
|
||||
newId?: () => string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
session: BrowserSession;
|
||||
tabs: Map<string, BrowserTab>;
|
||||
currentTabId: string;
|
||||
/** 未脱敏地址只留在内存,用于判断截图能否进入模型;不对外返回 */
|
||||
rawUrl: string | null;
|
||||
budget: NavigationBudget;
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
||||
const int = (v: unknown) => (typeof v === "number" && Number.isInteger(v) ? v : undefined);
|
||||
|
||||
const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i;
|
||||
|
||||
export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
readonly kind = "camofox" as const;
|
||||
private readonly base: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly lock: ProfileLock;
|
||||
private readonly sessions = new Map<string, SessionState>();
|
||||
private readonly now: () => Date;
|
||||
private readonly newId: () => string;
|
||||
|
||||
constructor(private readonly cfg: CamofoxConfig) {
|
||||
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
||||
this.fetchImpl = cfg.fetch ?? fetch;
|
||||
this.lock = cfg.lock ?? new InMemoryProfileLock(cfg.lockWaitMs);
|
||||
this.now = cfg.now ?? (() => new Date());
|
||||
this.newId = cfg.newId ?? (() => crypto.randomUUID());
|
||||
}
|
||||
|
||||
// ---------- HTTP ----------
|
||||
|
||||
private async authHeader(): Promise<Record<string, string>> {
|
||||
const key = await this.cfg.secrets.resolve({ kind: "provider_access_key", providerId: this.cfg.providerId });
|
||||
return key ? { authorization: `Bearer ${key}` } : {};
|
||||
}
|
||||
|
||||
private async request(
|
||||
method: "GET" | "POST" | "DELETE",
|
||||
path: string,
|
||||
opts: { query?: Record<string, string | undefined>; body?: Json; timeoutMs?: number; auth?: boolean } = {},
|
||||
): Promise<Response> {
|
||||
const u = new URL(this.base + path);
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) u.searchParams.set(k, v);
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
...(opts.auth === false ? {} : await this.authHeader()),
|
||||
};
|
||||
if (opts.body) headers["content-type"] = "application/json";
|
||||
const timeoutMs = opts.timeoutMs ?? this.cfg.timeoutMs ?? 30_000;
|
||||
const started = Date.now();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchImpl(u, {
|
||||
method,
|
||||
headers,
|
||||
...(opts.body ? { body: JSON.stringify(opts.body) } : {}),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (e) {
|
||||
const name = (e as { name?: string })?.name;
|
||||
const kind = name === "TimeoutError" || name === "AbortError" ? "timeout" : "network";
|
||||
this.cfg.logger?.({ level: "warn", msg: `camofox ${kind}`, method, path, ms: Date.now() - started });
|
||||
throw new BrowserError(
|
||||
kind,
|
||||
`camofox ${method} ${path} ${kind === "timeout" ? `超时(${timeoutMs}ms)` : "网络错误"}`,
|
||||
);
|
||||
}
|
||||
this.cfg.logger?.({ level: "debug", msg: "camofox", method, path, status: res.status, ms: Date.now() - started });
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw new BrowserError(classifyHttpStatus(res.status), `camofox ${method} ${path} → HTTP ${res.status}`, {
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
private async json(res: Response, path: string): Promise<Json> {
|
||||
try {
|
||||
const v = (await res.json()) as unknown;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return v as Json;
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
throw new BrowserError("protocol", `camofox ${path} 返回非 JSON 对象`);
|
||||
}
|
||||
|
||||
// ---------- 状态 ----------
|
||||
|
||||
private state(sessionId: string): SessionState {
|
||||
const s = this.sessions.get(sessionId);
|
||||
if (!s) throw new BrowserError("not_found", "会话不存在或已关闭");
|
||||
return s;
|
||||
}
|
||||
|
||||
private tabOf(s: SessionState, ref: TabRef): BrowserTab {
|
||||
const t = s.tabs.get(ref.tabId ?? s.currentTabId);
|
||||
if (t?.status !== "open") throw new BrowserError("not_found", "标签页不存在或已关闭");
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 接管/等待/关闭中的会话拒绝自动化动作(REQ §4.7.3) */
|
||||
private assertAutomation(s: SessionState): void {
|
||||
if (s.session.controller !== "automation")
|
||||
throw new BrowserError("control_conflict", `会话当前由 ${s.session.controller} 控制,自动化动作已停止`);
|
||||
}
|
||||
|
||||
private touch(s: SessionState, rawUrl?: string | null): void {
|
||||
const at = this.now().toISOString();
|
||||
if (rawUrl !== undefined) {
|
||||
s.rawUrl = rawUrl;
|
||||
s.session.currentUrl = rawUrl === null ? null : redactBrowserUrl(rawUrl);
|
||||
}
|
||||
s.session.lastActivityAt = at;
|
||||
s.session.updatedAt = at;
|
||||
}
|
||||
|
||||
private withProfile<T>(s: SessionState | string, fn: () => Promise<T>): Promise<T> {
|
||||
const key = typeof s === "string" ? s : s.session.profileKey;
|
||||
return this.lock.run(key, fn, this.cfg.lockWaitMs === undefined ? {} : { waitMs: this.cfg.lockWaitMs });
|
||||
}
|
||||
|
||||
/** 服务层把会话控制权变更(接管/交还/等待)同步给适配器;租约本身由服务层持久化 */
|
||||
setController(sessionId: string, controller: BrowserSession["controller"]): void {
|
||||
const s = this.state(sessionId);
|
||||
s.session.controller = controller;
|
||||
this.touch(s);
|
||||
}
|
||||
|
||||
// ---------- 健康与能力 ----------
|
||||
|
||||
async health(): Promise<BrowserHealth> {
|
||||
const checkedAt = this.now().toISOString();
|
||||
const u = `${this.base}/health`;
|
||||
try {
|
||||
// /health 在 CAMOFOX_ACCESS_KEY 下也免认证(README);503 表示恢复中
|
||||
const res = await this.fetchImpl(u, { signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000) });
|
||||
const body = (await res.json().catch(() => ({}))) as Json;
|
||||
const detail: Json = {
|
||||
httpStatus: res.status,
|
||||
engine: str(body.engine) ?? null,
|
||||
browserConnected: body.browserConnected ?? null,
|
||||
browserRunning: body.browserRunning ?? null,
|
||||
activeTabs: int(body.activeTabs) ?? null,
|
||||
activeSessions: int(body.activeSessions) ?? null,
|
||||
consecutiveFailures: int(body.consecutiveFailures) ?? null,
|
||||
recovering: body.recovering ?? null,
|
||||
};
|
||||
if (res.status === 503) return { status: "degraded", kind: "camofox", detail, checkedAt };
|
||||
if (!res.ok || body.ok === false) return { status: "down", kind: "camofox", detail, checkedAt };
|
||||
return { status: "ok", kind: "camofox", detail, checkedAt };
|
||||
} catch (e) {
|
||||
const name = (e as { name?: string })?.name;
|
||||
return {
|
||||
status: "down",
|
||||
kind: "camofox",
|
||||
detail: { error: name === "TimeoutError" ? "timeout" : "network" },
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** probe=true 时读取上游 /openapi.json 推导;失败回退静态声明(note 标注探测失败) */
|
||||
async capabilities(opts: { probe?: boolean } = {}): Promise<BrowserCapabilities> {
|
||||
const verified = this.cfg.verified ?? {};
|
||||
if (!opts.probe) return camofoxStaticCapabilities(verified);
|
||||
try {
|
||||
const res = await this.request("GET", "/openapi.json");
|
||||
return camofoxCapabilitiesFromOpenApi(await this.json(res, "/openapi.json"), verified, this.now());
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "auth") throw e;
|
||||
return camofoxStaticCapabilities(verified);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 会话 ----------
|
||||
|
||||
async openSession(req: OpenSessionRequest): Promise<OpenSessionResult> {
|
||||
const profileKey = req.profileKey ?? profileKeyFor(req.siteId, req.accountId);
|
||||
if (req.initialUrl) assertNavigable(req.initialUrl, req.budget);
|
||||
return this.withProfile(profileKey, async () => {
|
||||
for (const s of this.sessions.values())
|
||||
if (s.session.profileKey === profileKey && s.session.controller !== "closed")
|
||||
throw new BrowserError("control_conflict", "该站点/账号已有未关闭的浏览器会话");
|
||||
const sessionId = this.newId();
|
||||
const res = await this.request("POST", "/tabs", {
|
||||
body: { userId: profileKey, sessionKey: sessionId, ...(req.initialUrl ? { url: req.initialUrl } : {}) },
|
||||
timeoutMs: req.budget.timeoutMs,
|
||||
});
|
||||
const body = await this.json(res, "/tabs");
|
||||
const tabId = str(body.tabId);
|
||||
if (!tabId) throw new BrowserError("protocol", "camofox POST /tabs 未返回 tabId");
|
||||
const rawUrl = str(body.url) ?? req.initialUrl ?? null;
|
||||
const at = this.now().toISOString();
|
||||
const session: BrowserSession = {
|
||||
id: sessionId,
|
||||
providerId: this.cfg.providerId,
|
||||
siteId: req.siteId,
|
||||
accountId: req.accountId,
|
||||
profileKey,
|
||||
controller: "automation",
|
||||
currentUrl: rawUrl === null ? null : redactBrowserUrl(rawUrl),
|
||||
lastActivityAt: at,
|
||||
upstreamRef: { userId: profileKey, sessionKey: sessionId },
|
||||
recoveryState: {},
|
||||
closedAt: null,
|
||||
closeReason: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
const tab: BrowserTab = {
|
||||
id: this.newId(),
|
||||
sessionId,
|
||||
upstreamTabId: tabId,
|
||||
url: session.currentUrl,
|
||||
title: null,
|
||||
status: "open",
|
||||
lastSnapshotAt: null,
|
||||
closedAt: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
const st: SessionState = {
|
||||
session,
|
||||
tabs: new Map([[tabId, tab]]),
|
||||
currentTabId: tabId,
|
||||
rawUrl,
|
||||
budget: req.budget,
|
||||
};
|
||||
this.sessions.set(sessionId, st);
|
||||
let navigation: NavigationResult | null = null;
|
||||
if (req.initialUrl) {
|
||||
navigation = {
|
||||
url: session.currentUrl ?? "",
|
||||
httpStatus: int(body.httpStatus) ?? null,
|
||||
ok: body.navigationOk !== false,
|
||||
attempts: 1,
|
||||
};
|
||||
this.assertLanded(rawUrl, req.budget);
|
||||
}
|
||||
return { session: { ...session }, tab: { ...tab }, navigation };
|
||||
});
|
||||
}
|
||||
|
||||
/** 重定向落到白名单外:报 domain_blocked(页面已打开,调用方应停止后续动作并转人工) */
|
||||
private assertLanded(rawUrl: string | null, budget: NavigationBudget): void {
|
||||
if (!rawUrl) return;
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!hostAllowed(host, budget.domainAllowlist))
|
||||
throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`);
|
||||
}
|
||||
|
||||
async closeSession(_sessionId: string, _reason?: string): Promise<void> {
|
||||
throw notImplemented("closeSession");
|
||||
}
|
||||
|
||||
async listSessions(): Promise<BrowserSession[]> {
|
||||
throw notImplemented("listSessions");
|
||||
}
|
||||
|
||||
// ---------- 页面 ----------
|
||||
|
||||
async navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise<NavigationResult> {
|
||||
const s = this.state(ref.sessionId);
|
||||
this.assertAutomation(s);
|
||||
assertNavigable(url, budget);
|
||||
return this.withProfile(s, async () => {
|
||||
this.assertAutomation(s);
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/navigate`;
|
||||
let attempts = 0;
|
||||
for (;;) {
|
||||
attempts++;
|
||||
try {
|
||||
const res = await this.request("POST", path, {
|
||||
body: { userId: s.session.profileKey, url },
|
||||
timeoutMs: budget.timeoutMs,
|
||||
});
|
||||
const body = await this.json(res, path);
|
||||
const rawUrl = str(body.url) ?? url;
|
||||
this.touch(s, rawUrl);
|
||||
tab.url = s.session.currentUrl;
|
||||
tab.updatedAt = s.session.updatedAt;
|
||||
s.budget = budget;
|
||||
this.assertLanded(rawUrl, budget);
|
||||
return {
|
||||
url: redactBrowserUrl(rawUrl),
|
||||
httpStatus: int(body.httpStatus) ?? null,
|
||||
ok: body.ok !== false && body.navigationOk !== false,
|
||||
attempts,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.retryable && attempts <= budget.maxRetries) continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async snapshot(ref: TabRef, opts: SnapshotOptions = {}): Promise<BrowserSnapshot> {
|
||||
const s = this.state(ref.sessionId);
|
||||
return this.withProfile(s, async () => {
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/snapshot`;
|
||||
const res = await this.request("GET", path, {
|
||||
query: {
|
||||
userId: s.session.profileKey,
|
||||
format: "text",
|
||||
...(opts.offset !== undefined ? { offset: String(opts.offset) } : {}),
|
||||
},
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const body = await this.json(res, path);
|
||||
let text = str(body.snapshot) ?? "";
|
||||
let truncated = body.truncated === true || body.hasMore === true;
|
||||
const maxBytes = opts.maxBytes ?? s.budget.maxBytes;
|
||||
const enc = new TextEncoder();
|
||||
if (enc.encode(text).byteLength > maxBytes) {
|
||||
// 按字节预算截断;在字符边界上截,避免半个 UTF-8 字符
|
||||
let lo = 0;
|
||||
let hi = text.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (enc.encode(text.slice(0, mid)).byteLength <= maxBytes) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
text = text.slice(0, lo);
|
||||
truncated = true;
|
||||
}
|
||||
const rawUrl = str(body.url) ?? s.rawUrl ?? "";
|
||||
const structure =
|
||||
body.structure && typeof body.structure === "object" && !Array.isArray(body.structure)
|
||||
? (body.structure as Json)
|
||||
: null;
|
||||
const red = redactSnapshot({ text, url: rawUrl, structure }, { extra: opts.extraRedactions ?? [] });
|
||||
this.touch(s, rawUrl || s.rawUrl);
|
||||
const at = s.session.lastActivityAt;
|
||||
tab.lastSnapshotAt = at;
|
||||
tab.url = s.session.currentUrl;
|
||||
const refsCount = int(body.refsCount) ?? new Set(text.match(/\be\d+\b/g) ?? []).size;
|
||||
return {
|
||||
sessionId: s.session.id,
|
||||
tabId: tab.upstreamTabId,
|
||||
url: red.url,
|
||||
format: "a11y_text",
|
||||
text: red.text,
|
||||
structure: red.structure,
|
||||
refsCount,
|
||||
truncated,
|
||||
nextOffset: truncated ? (int(body.nextOffset) ?? null) : null,
|
||||
redactions: red.redactions,
|
||||
capturedAt: at,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async screenshot(ref: TabRef, opts: { maxBytes?: number } = {}): Promise<BrowserScreenshot> {
|
||||
const s = this.state(ref.sessionId);
|
||||
return this.withProfile(s, async () => {
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/screenshot`;
|
||||
const res = await this.request("GET", path, {
|
||||
query: { userId: s.session.profileKey },
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const maxBytes = opts.maxBytes ?? s.budget.maxBytes ?? this.cfg.defaultMaxBytes ?? 8 * 1024 * 1024;
|
||||
const declared = Number(res.headers.get("content-length") ?? "NaN");
|
||||
if (Number.isFinite(declared) && declared > maxBytes * 1.4) {
|
||||
await res.body?.cancel();
|
||||
throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`);
|
||||
}
|
||||
const ctype = res.headers.get("content-type") ?? "";
|
||||
let data: Uint8Array;
|
||||
let mimeType: string;
|
||||
if (ctype.startsWith("image/")) {
|
||||
// 部分版本/fork 直接返回二进制图片(待核验)
|
||||
data = new Uint8Array(await res.arrayBuffer());
|
||||
mimeType = ctype.split(";")[0]!.trim();
|
||||
} else {
|
||||
// OpenAPI 1.17.0:{ screenshot: { data: base64, mimeType } }
|
||||
const body = await this.json(res, path);
|
||||
const shot = (body.screenshot && typeof body.screenshot === "object" ? body.screenshot : body) as Json;
|
||||
const b64 = str(shot.data);
|
||||
if (!b64) throw new BrowserError("protocol", "camofox 截图响应缺少 data");
|
||||
try {
|
||||
data = Uint8Array.from(atob(b64.replace(/^data:[^,]*,/, "")), (c) => c.charCodeAt(0));
|
||||
} catch {
|
||||
throw new BrowserError("protocol", "camofox 截图不是合法 base64");
|
||||
}
|
||||
mimeType = str(shot.mimeType) ?? "image/png";
|
||||
}
|
||||
if (data.byteLength > maxBytes) throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`);
|
||||
this.touch(s);
|
||||
const raw = s.rawUrl;
|
||||
let loginPage = false;
|
||||
try {
|
||||
loginPage = raw !== null && LOGIN_PATH.test(new URL(raw).pathname);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return {
|
||||
mimeType,
|
||||
data,
|
||||
url: raw === null ? null : redactBrowserUrl(raw),
|
||||
modelSafe: raw !== null && !urlHasCredential(raw) && !loginPage,
|
||||
capturedAt: s.session.lastActivityAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- M2 ----------
|
||||
|
||||
async click(_ref: TabRef, _target: ElementTarget): Promise<void> {
|
||||
throw notImplemented("click");
|
||||
}
|
||||
async type(_ref: TabRef, _target: ElementTarget, _text: TypeInput): Promise<void> {
|
||||
throw notImplemented("type");
|
||||
}
|
||||
async extract(_ref: TabRef, _schema: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
throw notImplemented("extract");
|
||||
}
|
||||
async waitForDownload(_ref: TabRef, _opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact> {
|
||||
throw notImplemented("waitForDownload");
|
||||
}
|
||||
async acquireControl(_req: AcquireControlRequest): Promise<BrowserControlLease> {
|
||||
throw notImplemented("acquireControl");
|
||||
}
|
||||
async releaseControl(_leaseId: string, _reason?: string): Promise<void> {
|
||||
throw notImplemented("releaseControl");
|
||||
}
|
||||
}
|
||||
|
||||
export function createCamofoxBrowserProvider(cfg: CamofoxConfig): CamofoxBrowserProvider {
|
||||
return new CamofoxBrowserProvider(cfg);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./capabilities.ts";
|
||||
export * from "./client.ts";
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BrowserErrorKind } from "@umt/contracts";
|
||||
|
||||
/**
|
||||
* 浏览器适配层统一错误。message 只含方法、路径与状态,不含访问凭据、Cookie、页面正文。
|
||||
* retryable:同一预算内是否值得重试(超时/网络/上游 5xx/不可用);认证、预算、控制权冲突一律不重试。
|
||||
*/
|
||||
export class BrowserError extends Error {
|
||||
readonly kind: BrowserErrorKind;
|
||||
readonly httpStatus?: number;
|
||||
constructor(kind: BrowserErrorKind, message?: string, opts: { httpStatus?: number; cause?: unknown } = {}) {
|
||||
super(message ?? kind, opts.cause === undefined ? undefined : { cause: opts.cause });
|
||||
this.name = "BrowserError";
|
||||
this.kind = kind;
|
||||
if (opts.httpStatus !== undefined) this.httpStatus = opts.httpStatus;
|
||||
}
|
||||
get retryable(): boolean {
|
||||
return (
|
||||
this.kind === "timeout" || this.kind === "network" || this.kind === "upstream" || this.kind === "unavailable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const notImplemented = (method: string): BrowserError =>
|
||||
new BrowserError("not_implemented", `${method} 未实现(M2 原型验证后启用)`);
|
||||
|
||||
/** HTTP 状态 → 错误种类(camofox OpenAPI 1.17.0 的响应码:400/401/403/404/409/422/429/500/503) */
|
||||
export function classifyHttpStatus(status: number): BrowserErrorKind {
|
||||
if (status === 401 || status === 403) return "auth";
|
||||
if (status === 404) return "not_found";
|
||||
if (status === 408) return "timeout";
|
||||
if (status === 429) return "rate_limited";
|
||||
if (status === 503) return "unavailable";
|
||||
if (status >= 500) return "upstream";
|
||||
if (status >= 400) return "bad_request";
|
||||
return "protocol";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./budget.ts";
|
||||
export * from "./camofox/index.ts";
|
||||
export * from "./errors.ts";
|
||||
export * from "./none.ts";
|
||||
export * from "./profile-lock.ts";
|
||||
export * from "./provider.ts";
|
||||
export * from "./redact.ts";
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BrowserCapabilities, BrowserCapabilityFlag, BrowserHealth } from "@umt/contracts";
|
||||
import { BROWSER_CAPABILITY_KEYS } from "@umt/contracts";
|
||||
import { BrowserError } from "./errors.ts";
|
||||
import type { BrowserProvider } from "./provider.ts";
|
||||
|
||||
const off: BrowserCapabilityFlag = { supported: false, verified: false, source: "static", note: "未配置浏览器服务" };
|
||||
|
||||
export function noneCapabilities(): BrowserCapabilities {
|
||||
const flags = Object.fromEntries(BROWSER_CAPABILITY_KEYS.map((k) => [k, { ...off }])) as Record<
|
||||
(typeof BROWSER_CAPABILITY_KEYS)[number],
|
||||
BrowserCapabilityFlag
|
||||
>;
|
||||
return { ...flags, upstreamVersion: null, probedAt: null };
|
||||
}
|
||||
|
||||
const unavailable = () => Promise.reject(new BrowserError("unavailable", "未配置浏览器服务"));
|
||||
|
||||
/**
|
||||
* 默认实现:未配置浏览器服务。所有动作返回 unavailable,能力全部 false;
|
||||
* 业务侧据此在界面标注"浏览器不可用",不回退到其他浏览器。
|
||||
*/
|
||||
export function createNoneBrowserProvider(): BrowserProvider {
|
||||
return {
|
||||
kind: "none",
|
||||
async health(): Promise<BrowserHealth> {
|
||||
return { status: "disabled", kind: "none", detail: {}, checkedAt: new Date().toISOString() };
|
||||
},
|
||||
async capabilities() {
|
||||
return noneCapabilities();
|
||||
},
|
||||
openSession: unavailable,
|
||||
closeSession: unavailable,
|
||||
async listSessions() {
|
||||
return [];
|
||||
},
|
||||
navigate: unavailable,
|
||||
snapshot: unavailable,
|
||||
screenshot: unavailable,
|
||||
click: unavailable,
|
||||
type: unavailable,
|
||||
extract: unavailable,
|
||||
waitForDownload: unavailable,
|
||||
acquireControl: unavailable,
|
||||
releaseControl: unavailable,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BrowserError } from "./errors.ts";
|
||||
|
||||
/**
|
||||
* 同一 profile(站点/账号)的浏览器操作串行(REQ §4.4)。
|
||||
* 内存实现只在单进程内有效;多进程/多实例部署需换成持久化实现(PG advisory lock 或
|
||||
* browser_control_lease 表),接口保持不变。
|
||||
*/
|
||||
export interface ProfileLock {
|
||||
/** 在持有 key 锁期间执行 fn;等待超过 waitMs 抛 BrowserError('lock_timeout') */
|
||||
run<T>(key: string, fn: () => Promise<T>, opts?: { waitMs?: number }): Promise<T>;
|
||||
/** 当前持有或排队中的 key 数量(诊断用) */
|
||||
size(): number;
|
||||
}
|
||||
|
||||
interface Waiter {
|
||||
resolve: () => void;
|
||||
reject: (e: unknown) => void;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class InMemoryProfileLock implements ProfileLock {
|
||||
private readonly held = new Map<string, Waiter[]>();
|
||||
constructor(private readonly defaultWaitMs = 30_000) {}
|
||||
|
||||
size(): number {
|
||||
return this.held.size;
|
||||
}
|
||||
|
||||
async run<T>(key: string, fn: () => Promise<T>, opts: { waitMs?: number } = {}): Promise<T> {
|
||||
await this.acquire(key, opts.waitMs ?? this.defaultWaitMs);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.release(key);
|
||||
}
|
||||
}
|
||||
|
||||
private acquire(key: string, waitMs: number): Promise<void> {
|
||||
const queue = this.held.get(key);
|
||||
if (!queue) {
|
||||
this.held.set(key, []);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const w: Waiter = { resolve, reject };
|
||||
w.timer = setTimeout(() => {
|
||||
const i = queue.indexOf(w);
|
||||
if (i >= 0) queue.splice(i, 1);
|
||||
reject(new BrowserError("lock_timeout", `profile 锁等待超时(${waitMs}ms)`));
|
||||
}, waitMs);
|
||||
queue.push(w);
|
||||
});
|
||||
}
|
||||
|
||||
private release(key: string): void {
|
||||
const queue = this.held.get(key);
|
||||
if (!queue) return;
|
||||
const next = queue.shift();
|
||||
if (!next) {
|
||||
this.held.delete(key);
|
||||
return;
|
||||
}
|
||||
if (next.timer) clearTimeout(next.timer);
|
||||
next.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserHealth,
|
||||
BrowserLeaseHolderKind,
|
||||
BrowserProviderKind,
|
||||
BrowserSession,
|
||||
BrowserSnapshot,
|
||||
BrowserTab,
|
||||
DownloadArtifact,
|
||||
NavigationBudget,
|
||||
} from "@umt/contracts";
|
||||
|
||||
/**
|
||||
* BrowserProvider:业务侧访问浏览器服务的唯一合同(REQ §4.7.2)。
|
||||
* 这是本项目接口;上游(camofox-browser 等)的同名能力由各实现映射,不假定上游具备同名端点。
|
||||
* 约束:
|
||||
* - 同一 profileKey(站点/账号)的操作串行(ProfileLock);
|
||||
* - 导航受 NavigationBudget 约束(域名白名单、超时、重试、字节上限);
|
||||
* - 进入模型的快照/截图先经脱敏钩子;凭证经 BrowserSecretResolver 注入,不落日志、不进模型;
|
||||
* - controller 不是 automation 的会话拒绝自动化输入(接管期间,REQ §4.7.3);
|
||||
* - 浏览器没有媒体库写权限,下载产物只交后端暂存核验。
|
||||
*/
|
||||
export interface BrowserProvider {
|
||||
readonly kind: BrowserProviderKind;
|
||||
health(): Promise<BrowserHealth>;
|
||||
capabilities(opts?: { probe?: boolean }): Promise<BrowserCapabilities>;
|
||||
|
||||
openSession(req: OpenSessionRequest): Promise<OpenSessionResult>;
|
||||
closeSession(sessionId: string, reason?: string): Promise<void>;
|
||||
listSessions(): Promise<BrowserSession[]>;
|
||||
|
||||
navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise<NavigationResult>;
|
||||
snapshot(ref: TabRef, opts?: SnapshotOptions): Promise<BrowserSnapshot>;
|
||||
screenshot(ref: TabRef, opts?: { maxBytes?: number }): Promise<BrowserScreenshot>;
|
||||
click(ref: TabRef, target: ElementTarget): Promise<void>;
|
||||
/** text 为明文或凭证引用;凭证引用由服务端解析后直接下发,不进入模型与日志 */
|
||||
type(ref: TabRef, target: ElementTarget, text: TypeInput): Promise<void>;
|
||||
extract(ref: TabRef, schema: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact>;
|
||||
|
||||
acquireControl(req: AcquireControlRequest): Promise<BrowserControlLease>;
|
||||
releaseControl(leaseId: string, reason?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OpenSessionRequest {
|
||||
providerId: string;
|
||||
siteId: string;
|
||||
accountId: string | null;
|
||||
/** 缺省按 siteId/accountId 生成(profileKeyFor) */
|
||||
profileKey?: string;
|
||||
/** 可选初始地址,同样受 budget 约束 */
|
||||
initialUrl?: string;
|
||||
budget: NavigationBudget;
|
||||
}
|
||||
|
||||
export interface OpenSessionResult {
|
||||
session: BrowserSession;
|
||||
tab: BrowserTab;
|
||||
navigation: NavigationResult | null;
|
||||
}
|
||||
|
||||
/** 会话内标签页定位;tabId 缺省 = 会话当前标签页 */
|
||||
export interface TabRef {
|
||||
sessionId: string;
|
||||
tabId?: string;
|
||||
}
|
||||
|
||||
export interface NavigationResult {
|
||||
/** 已脱敏的最终地址 */
|
||||
url: string;
|
||||
httpStatus: number | null;
|
||||
/** 上游报告的文档状态 < 400 */
|
||||
ok: boolean;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface SnapshotOptions {
|
||||
offset?: number;
|
||||
maxBytes?: number;
|
||||
/** 追加脱敏规则(站点特定字段) */
|
||||
extraRedactions?: RegExp[];
|
||||
}
|
||||
|
||||
export interface BrowserScreenshot {
|
||||
mimeType: string;
|
||||
data: Uint8Array;
|
||||
/** 已脱敏的页面地址 */
|
||||
url: string | null;
|
||||
/**
|
||||
* 是否可直接进入模型。像素级遮蔽未实现(M2),当前按页面地址/已知密码框保守判断:
|
||||
* 含凭据的地址或登录页一律 false,调用方只能给人看、不喂模型。
|
||||
*/
|
||||
modelSafe: boolean;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export type ElementTarget = { ref: string } | { selector: string };
|
||||
|
||||
export type TypeInput =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "secret"; siteId: string; accountId: string | null; field: "username" | "password" | "totp" };
|
||||
|
||||
export interface AcquireControlRequest {
|
||||
sessionId: string;
|
||||
holderKind: BrowserLeaseHolderKind;
|
||||
holder: string;
|
||||
reason: string;
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
/** 凭证注入:服务端从 secret store 按需解出明文;适配器不持久化、不打印、不放进错误信息 */
|
||||
export type BrowserSecretRequest =
|
||||
| { kind: "provider_access_key"; providerId: string }
|
||||
| { kind: "site_login"; siteId: string; accountId: string | null; field: "username" | "password" | "totp" };
|
||||
|
||||
export interface BrowserSecretResolver {
|
||||
resolve(req: BrowserSecretRequest): Promise<string | null>;
|
||||
}
|
||||
|
||||
/** 测试/脚本用固定凭证解析器;键为 `provider:<id>` 或 `site:<siteId>:<accountId|->:<field>` */
|
||||
export function staticBrowserSecrets(table: Record<string, string>): BrowserSecretResolver {
|
||||
return {
|
||||
async resolve(req) {
|
||||
const key =
|
||||
req.kind === "provider_access_key"
|
||||
? `provider:${req.providerId}`
|
||||
: `site:${req.siteId}:${req.accountId ?? "-"}:${req.field}`;
|
||||
return table[key] ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 站点/账号维度的 profile 键;同时用作 camofox userId(登录状态隔离单元) */
|
||||
export function profileKeyFor(siteId: string, accountId: string | null): string {
|
||||
return `umt-site-${siteId}-acct-${accountId ?? "anon"}`;
|
||||
}
|
||||
|
||||
/** 只记录方法/路径/状态;实现不得把请求头、正文或快照写进日志 */
|
||||
export type BrowserLogger = (entry: {
|
||||
level: "debug" | "warn";
|
||||
msg: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
status?: number;
|
||||
ms?: number;
|
||||
}) => void;
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { SnapshotRedactionKind } from "@umt/contracts";
|
||||
import { REDACTED } from "../rss/redact.ts";
|
||||
|
||||
/**
|
||||
* 快照/截图进入模型前的脱敏钩子(REQ §4.4、§4.7.3)。
|
||||
* 覆盖:密码框的值、Cookie 值、URL 中的 passkey/token 等凭据参数。
|
||||
* camofox 可访问性快照的精确文本格式("[role eN] name ...")未在真实实例核验,
|
||||
* 这里同时处理 a11y 文本、HTML 片段与 structure JSON,宁可多遮不可漏。
|
||||
*/
|
||||
|
||||
/** URL 查询参数/路径段里的凭据键(PT 站 passkey、RSS key、下载签名等) */
|
||||
const CREDENTIAL_PARAM =
|
||||
/([?&;](?:passkey|authkey|torrent_pass|rsskey|downhash|credential|token|access_token|api_key|apikey|secret|sign|signature|key)=)([^&#\s"'<>)\]]+)/gi;
|
||||
/** 路径形式的 passkey:/passkey/<hex> 或 /rss/<32+ hex> */
|
||||
const CREDENTIAL_PATH = /(\/(?:passkey|rsskey|torrent_pass)\/)([A-Za-z0-9]{8,})/gi;
|
||||
|
||||
/** 常见站点登录/会话 Cookie 名 */
|
||||
const COOKIE_PAIR =
|
||||
/\b(c_secure_[a-z_]+|PHPSESSID|cf_clearance|__cf_bm|sessionid|session_id|laravel_session|XSRF-TOKEN|auth_token|remember_[a-z0-9_]+|tp|uid|pass)=([^;\s"'<>]+)/gi;
|
||||
/** "Cookie: ..." / "set-cookie: ..." 整行 */
|
||||
const COOKIE_HEADER = /\b((?:set-)?cookie\s*[:=]\s*)([^\n]+)/gi;
|
||||
|
||||
/** HTML:type=password 的 input 上的 value */
|
||||
const HTML_PASSWORD_INPUT = /<input\b[^>]*\btype\s*=\s*["']?password["']?[^>]*>/gi;
|
||||
const HTML_VALUE_ATTR = /(\bvalue\s*=\s*)("[^"]*"|'[^']*'|[^\s>]+)/i;
|
||||
|
||||
/**
|
||||
* a11y 文本中名称像密码/凭据的元素,遮蔽其后的取值。
|
||||
* 形如 `[textbox e3] Password: hunter2`、`[textbox e3 password] 密码 "hunter2"`、`- textbox "Password": hunter2`。
|
||||
*/
|
||||
const A11Y_SECRET_FIELD =
|
||||
/(\[[a-z]+ e\d+[^\]]*\][^\n[]*?(?:password|passwd|密码|passkey|2fa|totp|验证码|otp)[^\n[:"]*?(?::\s*|\s+))("[^"\n]*"|[^\s[\n][^[\n]*?)(?=\s{2,}|\s*\[|\n|$)/gi;
|
||||
const A11Y_TYPED_PASSWORD =
|
||||
/(\[[a-z]+ e\d+[^\]]*\bpassword\b[^\]]*\](?:[^\n[:"]*:)?\s*)("[^"\n]*"|[^\s[\n][^[\n]*?)(?=\s{2,}|\s*\[|\n|$)/gi;
|
||||
/(\[[a-z]+ e\d+[^\]]*\bpassword\b[^\]]*\][^\n[]*?(?::\s*|\s+))("[^"\n]*"|[^\s[\n][^[\n]*?)(?=\s{2,}|\s*\[|\n|$)/gi;
|
||||
const YAML_SECRET_FIELD = /(-\s*textbox\s+"[^"\n]*(?:password|passwd|密码|passkey)[^"\n]*"\s*:\s*)(\S[^\n]*)/gi;
|
||||
|
||||
export interface RedactionReport {
|
||||
kind: SnapshotRedactionKind;
|
||||
count: number;
|
||||
}
|
||||
|
||||
class Counter {
|
||||
private readonly m = new Map<SnapshotRedactionKind, number>();
|
||||
add(kind: SnapshotRedactionKind, n = 1) {
|
||||
this.m.set(kind, (this.m.get(kind) ?? 0) + n);
|
||||
}
|
||||
list(): RedactionReport[] {
|
||||
return [...this.m].filter(([, n]) => n > 0).map(([kind, count]) => ({ kind, count }));
|
||||
}
|
||||
}
|
||||
|
||||
function replaceCounting(s: string, re: RegExp, fn: (...m: string[]) => string, onHit: () => void): string {
|
||||
return s.replace(re, (...args: unknown[]) => {
|
||||
const groups = args.filter((a): a is string => typeof a === "string");
|
||||
const out = fn(...groups);
|
||||
if (out !== groups[0]) onHit();
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
const USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s@:]+(?::[^/\s@]*)?)@/gi;
|
||||
|
||||
function redactUrlsIn(s: string, c: Counter): string {
|
||||
let out = replaceCounting(
|
||||
s,
|
||||
USERINFO,
|
||||
(_a, scheme) => scheme,
|
||||
() => c.add("credential_url"),
|
||||
);
|
||||
out = replaceCounting(
|
||||
out,
|
||||
CREDENTIAL_PARAM,
|
||||
(all, k, v) => (v.startsWith("[REDACTED") ? all : `${k}${REDACTED}`),
|
||||
() => c.add("credential_url"),
|
||||
);
|
||||
out = replaceCounting(
|
||||
out,
|
||||
CREDENTIAL_PATH,
|
||||
(_all, k) => `${k}${REDACTED}`,
|
||||
() => c.add("credential_url"),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 单独脱敏一个 URL(日志、导航结果、会话 currentUrl 都用它) */
|
||||
export function redactBrowserUrl(url: string): string {
|
||||
return redactUrlsIn(url, new Counter());
|
||||
}
|
||||
|
||||
/** URL 是否带凭据(用于判定截图能否进入模型) */
|
||||
export function urlHasCredential(url: string): boolean {
|
||||
return redactBrowserUrl(url) !== url;
|
||||
}
|
||||
|
||||
function redactText(s: string, c: Counter, extra: RegExp[]): string {
|
||||
let out = redactUrlsIn(s, c);
|
||||
out = replaceCounting(
|
||||
out,
|
||||
COOKIE_HEADER,
|
||||
(_a, k) => `${k}${REDACTED}`,
|
||||
() => c.add("cookie"),
|
||||
);
|
||||
out = replaceCounting(
|
||||
out,
|
||||
COOKIE_PAIR,
|
||||
(a, k, v) => (v === REDACTED ? a : `${k}=${REDACTED}`),
|
||||
() => c.add("cookie"),
|
||||
);
|
||||
out = out.replace(HTML_PASSWORD_INPUT, (tag) => {
|
||||
if (!HTML_VALUE_ATTR.test(tag)) return tag;
|
||||
c.add("password_field");
|
||||
return tag.replace(HTML_VALUE_ATTR, (_m, k) => `${k}"${REDACTED}"`);
|
||||
});
|
||||
for (const re of [A11Y_TYPED_PASSWORD, A11Y_SECRET_FIELD, YAML_SECRET_FIELD]) {
|
||||
out = replaceCounting(
|
||||
out,
|
||||
re,
|
||||
(a, k, v) => (v === REDACTED || v === `"${REDACTED}"` ? a : `${k}${REDACTED}`),
|
||||
() => c.add("password_field"),
|
||||
);
|
||||
}
|
||||
for (const re of extra) {
|
||||
out = replaceCounting(
|
||||
out,
|
||||
re,
|
||||
() => REDACTED,
|
||||
() => c.add("secret_value"),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SECRET_KEY = /password|passwd|passkey|cookie|token|secret|authkey|credential/i;
|
||||
|
||||
function redactJson(v: unknown, c: Counter, extra: RegExp[], parentIsPassword = false): unknown {
|
||||
if (typeof v === "string") return redactText(v, c, extra);
|
||||
if (Array.isArray(v)) return v.map((x) => redactJson(x, c, extra, parentIsPassword));
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
const isPassword =
|
||||
parentIsPassword ||
|
||||
(typeof o.type === "string" && o.type.toLowerCase() === "password") ||
|
||||
(typeof o.inputType === "string" && o.inputType.toLowerCase() === "password");
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(o)) {
|
||||
if ((isPassword && k === "value") || (SECRET_KEY.test(k) && typeof val === "string" && val !== "")) {
|
||||
c.add(k.toLowerCase().includes("cookie") ? "cookie" : "password_field");
|
||||
out[k] = REDACTED;
|
||||
} else out[k] = redactJson(val, c, extra, false);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export interface SnapshotInput {
|
||||
text: string;
|
||||
url: string;
|
||||
structure?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface RedactedSnapshot {
|
||||
text: string;
|
||||
url: string;
|
||||
structure: Record<string, unknown> | null;
|
||||
redactions: RedactionReport[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照脱敏:隐藏密码框值、Cookie 值、含 passkey 等凭据的 URL。
|
||||
* 输出可直接交给模型;调用方仍不得把未经此函数的上游快照写进 agent 上下文或日志。
|
||||
*/
|
||||
export function redactSnapshot(input: SnapshotInput, opts: { extra?: RegExp[] } = {}): RedactedSnapshot {
|
||||
const c = new Counter();
|
||||
const extra = (opts.extra ?? []).map((r) => new RegExp(r.source, r.flags.includes("g") ? r.flags : `${r.flags}g`));
|
||||
const text = redactText(input.text, c, extra);
|
||||
const url = redactUrlsIn(input.url, c);
|
||||
const structure = input.structure ? (redactJson(input.structure, c, extra) as Record<string, unknown>) : null;
|
||||
return { text, url, structure, redactions: c.list() };
|
||||
}
|
||||
Reference in new issue
Block a user