feat(browser): ship camofox v1.17.0 in compose, verified on a real instance
- compose: camofox service on by default (internal only, volume umt-camofox, healthcheck, CAMOFOX_REPLICAS=0 to skip); app depends on it with service_started/required=false and auto-registers camofox-env from BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED - docker/camofox/Dockerfile: builds upstream tag v1.17.0 (389c996); fixes the upstream Dockerfile (missing postinstall.js before npm ci, sh -c not forwarding SIGTERM so logins were never persisted on restart) - adapter fixes from the real container: coordinate click via evaluate + selector, focus typing with mode=keyboard, password values redacted from ARIA snapshots/extract, downloads field names, 410 after browser restart treated as lost tab; navigate reopens a tab in the same session - verified-capability table keyed by upstream version (1.17.0 all ten); other versions stay unverified; probe reports auth failures - settings: browser providers block (create/edit/delete/probe, capability table); API POST/PATCH/DELETE /browser/providers, POST .../probe - scripts/browser-verify: umtbr stack + mock NexusPHP run of REQ 4.7.4 items - docs: runbook browser §1/§7 (real-instance record), deploy §2.1 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b4c9b1ead2
commit
40dd0e69e4
29 files changed
+1661
-73
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
BrowserError,
|
||||
type BrowserProvider,
|
||||
type BrowserSecretResolver,
|
||||
CAMOFOX_VERIFIED_RELEASES,
|
||||
createCamofoxBrowserProvider,
|
||||
type DownloadSink,
|
||||
type ElementTarget,
|
||||
@@ -224,14 +225,21 @@ export class BrowserService {
|
||||
* 地址与启用由环境变量决定;config 里的其它键(verified、超时等)保留。没设地址时已有的 camofox-env 置为停用。
|
||||
*/
|
||||
syncEnvProvider(): Promise<void> {
|
||||
this.envSync ??= this.doSyncEnvProvider().catch((e) => {
|
||||
this.envSync = undefined;
|
||||
this.ctx.logger.warn({ component: "browser", err: String(e) }, "登记环境变量浏览器服务失败");
|
||||
});
|
||||
this.envSync ??= this.doSyncEnvProvider().then(
|
||||
(probe) => {
|
||||
// 首次登记后在后台探测一次,写回能力与上游版本(不阻塞当前请求;camofox 未就绪时下次"测试连通"再写)
|
||||
if (probe) void this.listProviders(true).catch(() => {});
|
||||
},
|
||||
(e) => {
|
||||
this.envSync = undefined;
|
||||
this.ctx.logger.warn({ component: "browser", err: String(e) }, "登记环境变量浏览器服务失败");
|
||||
},
|
||||
);
|
||||
return this.envSync;
|
||||
}
|
||||
|
||||
private async doSyncEnvProvider(): Promise<void> {
|
||||
/** 返回是否需要后台探测(已启用且还没有探测过能力) */
|
||||
private async doSyncEnvProvider(): Promise<boolean> {
|
||||
const c = this.ctx.config;
|
||||
const url = c?.BROWSER_PROVIDER_URL;
|
||||
const enabled = !!url && c.BROWSER_ENABLED !== false;
|
||||
@@ -244,7 +252,7 @@ export class BrowserService {
|
||||
if (!url) {
|
||||
if (row?.enabled)
|
||||
await db.updateTable("browser_provider").set({ enabled: false }).where("id", "=", row.id).execute();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const config = JSON.stringify({ ...obj(row?.config), managedBy: ENV_MANAGED });
|
||||
if (row)
|
||||
@@ -259,6 +267,7 @@ export class BrowserService {
|
||||
.values({ name: ENV_PROVIDER_NAME, kind: "camofox", base_url: url, enabled, config })
|
||||
.onConflict((oc) => oc.column("name").doNothing())
|
||||
.execute();
|
||||
return enabled && !Object.keys(obj(row?.capabilities)).length;
|
||||
}
|
||||
|
||||
/** 设置页登记浏览器服务:访问凭据加密存 secret;名称 camofox-env 保留给环境变量 */
|
||||
@@ -300,7 +309,8 @@ export class BrowserService {
|
||||
): Promise<BrowserProviderView> {
|
||||
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
if (obj(row.config).managedBy === ENV_MANAGED && (body.baseUrl !== undefined || body.accessKey !== undefined))
|
||||
const changesAddr = body.baseUrl !== undefined && body.baseUrl !== row.base_url;
|
||||
if (obj(row.config).managedBy === ENV_MANAGED && (changesAddr || body.accessKey !== undefined))
|
||||
throw new BrowserServiceError(
|
||||
"conflict",
|
||||
"该浏览器服务由环境变量 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 管理,改部署配置后重启",
|
||||
@@ -331,6 +341,25 @@ export class BrowserService {
|
||||
return this.providerView(id);
|
||||
}
|
||||
|
||||
/** 删除设置页登记的服务:有未关闭会话时拒绝(先关会话);环境变量登记的只能停用 */
|
||||
async deleteProvider(id: string): Promise<{ id: string }> {
|
||||
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
if (obj(row.config).managedBy === ENV_MANAGED)
|
||||
throw new BrowserServiceError("conflict", "该浏览器服务由环境变量登记,只能停用(BROWSER_ENABLED=false)");
|
||||
const open = await this.ctx.db
|
||||
.selectFrom("browser_session")
|
||||
.select("id")
|
||||
.where("provider_id", "=", id)
|
||||
.where("controller", "<>", "closed")
|
||||
.executeTakeFirst();
|
||||
if (open) throw new BrowserServiceError("conflict", "该浏览器服务还有未关闭的会话,先关闭会话");
|
||||
await this.ctx.db.deleteFrom("browser_provider").where("id", "=", id).execute();
|
||||
if (row.secret_ref) await this.ctx.secrets.remove(row.secret_ref).catch(() => {});
|
||||
this.providers.delete(id);
|
||||
return { id };
|
||||
}
|
||||
|
||||
private async providerView(id: string): Promise<BrowserProviderView> {
|
||||
const v = (await this.listProviders(false, id))[0];
|
||||
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
@@ -364,13 +393,20 @@ export class BrowserService {
|
||||
let caps: BrowserCapabilities | null = null;
|
||||
try {
|
||||
caps = await p.capabilities({ probe: health.status !== "down" });
|
||||
} catch {
|
||||
} catch (e) {
|
||||
caps = await p.capabilities();
|
||||
// /health 免认证,只看它会把"凭据错了"显示成健康:探测被 401/403 拒绝时标为离线并注明
|
||||
if (e instanceof BrowserError && e.kind === "auth")
|
||||
health = { ...health, status: "down", detail: { ...health.detail, error: "auth" } };
|
||||
}
|
||||
row = await this.ctx.db
|
||||
.updateTable("browser_provider")
|
||||
.set({
|
||||
capabilities: JSON.stringify(caps),
|
||||
// 上游版本有真机核验记录时,verified_at 取核验日期(不是探测时间)
|
||||
...(caps.upstreamVersion && CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]
|
||||
? { verified_at: new Date(`${CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]!.verifiedAt}T00:00:00Z`) }
|
||||
: {}),
|
||||
health_status: HEALTH[health.status],
|
||||
last_health_at: new Date(health.checkedAt),
|
||||
})
|
||||
@@ -934,7 +970,7 @@ export class BrowserService {
|
||||
try {
|
||||
out = await fn(loaded.provider, ref, { row, site, budget });
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "not_found" && e.httpStatus === 404) {
|
||||
if (e instanceof BrowserError && e.kind === "not_found" && (e.httpStatus === 404 || e.httpStatus === 410)) {
|
||||
const r = await loaded.provider.reconcileTabs(id).catch(() => null);
|
||||
await this.persist(id, loaded.provider, { needsSnapshot: true });
|
||||
if (r?.lost.length) throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面");
|
||||
|
||||
Reference in new issue
Block a user