camofox reaps a tab after TAB_INACTIVITY_MS (5 min) without API tool calls;
desktop-mode input goes straight to Selkies and screenshots don't count, so
tabs disappeared under the user during takeover.
- BrowserKeepalive (in-process): sessions held by an open desktop WebSocket
(ref-counted), a human takeover lease, or a running/queued agent run that
used browser.* get a keepalive every BROWSER_KEEPALIVE_MS (default 120s,
0 = off). Holds are re-checked each round; release/lease expiry/agent end/
session close stop it and camofox reaps as usual.
- Keepalive call: camofox 1.17 has no keepalive/touch endpoint and /stats does
not bump toolCalls; use GET /tabs/{id}/downloads without includeData/consume
(read-only metadata). Goes through BrowserService.run so restarts or reaped
tabs self-heal via BR-3 reconnect; no lastAutomationAt, no control needed.
- Runbook browser.md §12, deploy env table, compose env; tests against the
testkit mock camofox.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
132 lines
7.4 KiB
TypeScript
132 lines
7.4 KiB
TypeScript
import { join, resolve } from "node:path";
|
|
import { z } from "zod";
|
|
|
|
/** compose 里 `${X:-}` 展开成空串:按未设置处理 */
|
|
const emptyToUndef = (v: unknown) => (v === "" ? undefined : v);
|
|
|
|
/** 可选正整数环境变量(空串 = 未设置) */
|
|
const optionalInt = (max: number) => z.preprocess(emptyToUndef, z.coerce.number().int().min(1).max(max).optional());
|
|
|
|
const LogLevel = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]);
|
|
|
|
export const ConfigSchema = z.object({
|
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
PORT: z.coerce.number().int().min(1).max(65535).default(4380),
|
|
/** 监听地址 */
|
|
HOST: z.string().min(1).default("0.0.0.0"),
|
|
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"),
|
|
SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"),
|
|
/** 主密钥轮换(--role rotate-master-key)的新密钥;只在轮换命令里读取,完成后换成 SECRET_MASTER_KEY 并删掉 */
|
|
SECRET_MASTER_KEY_NEW: z.string().min(16, "SECRET_MASTER_KEY_NEW 至少 16 个字符").optional(),
|
|
/**
|
|
* Passkey 的 RP ID(浏览器地址栏里的主机名,例如 media.example.com;不含协议与端口)。
|
|
* 不设时取请求的 Host(反代后面取 X-Forwarded-Host);换域名后已登记的 Passkey 全部失效。
|
|
*/
|
|
WEBAUTHN_RP_ID: z.string().min(1).optional(),
|
|
/** Passkey 允许的来源,逗号分隔(例如 https://media.example.com);不设时接受主机名等于 / 属于 RP ID 的来源 */
|
|
WEBAUTHN_ORIGINS: z.string().min(1).optional(),
|
|
/** Passkey 注册时显示的站点名 */
|
|
WEBAUTHN_RP_NAME: z.string().min(1).default("UHD Media Tool"),
|
|
LOG_LEVEL: LogLevel.default("info"),
|
|
/** 仅开发模式生效的管理员 Key */
|
|
DEV_ADMIN_KEY: z.string().min(8).optional(),
|
|
/** worker 心跳间隔 */
|
|
WORKER_HEARTBEAT_MS: z.coerce.number().int().min(1000).default(30_000),
|
|
/**
|
|
* 旧变量(WK-1 前的 io 组总并发):现在映射到网络道(WORKER_NETWORK_CONCURRENCY 未设时);
|
|
* 不设 = 用设置页的值。空串视为未设置(compose 里 `${WORKER_CONCURRENCY:-}`)。
|
|
*/
|
|
WORKER_CONCURRENCY: optionalInt(64),
|
|
/** io worker 三条道的并发(WK-1);设置了就覆盖 设置 → 自动化 → 高级 里的值 */
|
|
WORKER_FILE_CONCURRENCY: optionalInt(64),
|
|
WORKER_NETWORK_CONCURRENCY: optionalInt(64),
|
|
WORKER_MAINTENANCE_CONCURRENCY: optionalInt(64),
|
|
/** agent 组 worker(runAgent/resolveDecision:整段会话占一槽数分钟)并发槽数 */
|
|
WORKER_AGENT_CONCURRENCY: z.coerce.number().int().min(1).max(16).default(2),
|
|
/** 手动整理 dryRun / 按路径整理时递归遍历的文件数上限(超出截断并标记 truncated) */
|
|
ORGANIZE_WALK_MAX_FILES: z.coerce.number().int().min(1).max(1_000_000).default(5000),
|
|
/** web 静态产物目录(单容器模式):设置后同端口托管 SPA,非 /api 路径回退 index.html */
|
|
WEB_ROOT: z.string().min(1).optional(),
|
|
/**
|
|
* 应用数据目录(容器内 /data,由卷挂载):agent-sessions/、backups/、exports/、tmp/。
|
|
* 编译后的二进制与 /app 保持只读,所有运行期写入都落在这里。相对路径按进程 cwd 解析。
|
|
*/
|
|
DATA_DIR: z.string().min(1).default("./data"),
|
|
/** Agent JSONL 会话目录;缺省 DATA_DIR/agent-sessions(api 与 worker 必须共享同一目录) */
|
|
AGENT_SESSION_DIR: z.string().min(1).optional(),
|
|
// ── 文件操作挂死隔离(adapters fsproxy,对标 MP fsproxy)──
|
|
/** auto:能起 deno 子进程就把单个文件操作放进执行子进程(可 SIGKILL 回收),否则进程内看门狗;inprocess 强制进程内 */
|
|
FS_PROXY_MODE: z.enum(["auto", "subprocess", "inprocess"]).default("auto"),
|
|
/** 复制/哈希:进度停滞多久判挂死(ms),超时 SIGTERM→SIGKILL 并以 TIMEOUT 失败 */
|
|
FS_HANG_TIMEOUT_MS: z.coerce.number().int().min(1000).default(120_000),
|
|
/** stat/根检查/同卷 move 等快操作的总超时(ms) */
|
|
FS_QUICK_TIMEOUT_MS: z.coerce.number().int().min(500).default(30_000),
|
|
/** 执行子进程上报进度心跳的间隔(ms) */
|
|
FS_PROGRESS_INTERVAL_MS: z.coerce.number().int().min(10).default(1000),
|
|
/** SIGTERM 到 SIGKILL 的宽限(ms) */
|
|
FS_KILL_GRACE_MS: z.coerce.number().int().min(100).default(2000),
|
|
/** 写入前清理目标目录残留 partial 的年龄阈值(ms);0 = 不清 */
|
|
FS_PARTIAL_MAX_AGE_MS: z.coerce
|
|
.number()
|
|
.int()
|
|
.min(0)
|
|
.default(24 * 3600_000),
|
|
/** 单个文件的 TIMEOUT/IO 类失败最多尝试几次(含首次);超限转待处理,不删记录不重下 */
|
|
FS_TRANSFER_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(3),
|
|
/** 替换入库(replace_with_verify)时旧文件去向:trash = 移到库根 .umt-trash;delete = 删除 */
|
|
FS_REPLACE_OLD: z.enum(["trash", "delete"]).default("trash"),
|
|
/** 入库目标已存在时的缺省策略:conflict = 转待处理;skip = 跳过;replace_with_verify = 授权后写临时→校验→原子替换 */
|
|
ORGANIZE_ON_EXISTING: z.enum(["conflict", "skip", "replace_with_verify"]).default("conflict"),
|
|
/** 随行字幕中此语言加 .default 标记(对照 MP DEFAULT_SUB);不设 = 不加 */
|
|
ORGANIZE_DEFAULT_SUBTITLE_LANG: z.enum(["zh-cn", "zh-tw", "en", "ja", "ko"]).optional(),
|
|
// ── 浏览器服务(camofox-browser,docs/runbook/browser.md)──
|
|
/**
|
|
* 部署自带的浏览器服务地址(compose 缺省 http://camofox:9377;接自有实例时改成它的地址)。
|
|
* 设置后启动时登记/更新名为 camofox-env 的浏览器服务;不设则只用设置页登记的。
|
|
*/
|
|
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,
|
|
z
|
|
.enum(["true", "false", "1", "0"])
|
|
.default("true")
|
|
.transform((v) => v === "true" || v === "1"),
|
|
),
|
|
});
|
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
|
|
/** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */
|
|
export function loadConfig(env: Record<string, string | undefined> = Deno.env.toObject()): Config {
|
|
const parsed = ConfigSchema.safeParse(env);
|
|
if (!parsed.success) {
|
|
const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
|
|
throw new Error(`配置无效:\n${lines.join("\n")}`);
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
/** DATA_DIR 下的固定子目录 */
|
|
export const DATA_SUBDIRS = ["agent-sessions", "backups", "exports", "tmp"] as const;
|
|
|
|
/** 解析数据目录与会话目录为绝对路径 */
|
|
export function dataPaths(c: Pick<Config, "DATA_DIR" | "AGENT_SESSION_DIR">, cwd: string = Deno.cwd()) {
|
|
const dataDir = resolve(cwd, c.DATA_DIR);
|
|
return {
|
|
dataDir,
|
|
sessionDir: resolve(cwd, c.AGENT_SESSION_DIR ?? join(dataDir, "agent-sessions")),
|
|
backupsDir: join(dataDir, "backups"),
|
|
exportsDir: join(dataDir, "exports"),
|
|
tmpDir: join(dataDir, "tmp"),
|
|
};
|
|
}
|
|
|
|
export const isDev = (c: Pick<Config, "NODE_ENV">) => c.NODE_ENV !== "production";
|