Files
小夜 cd31096486 fix: improve Roxy cleanup and account operations
Update Roxy session cleanup, bounded proxy retries and Team authorization selection. Persist mailbox export usage and strengthen scoped member removal. Update isolated Roxy fixtures; all 808 tests pass.
2026-10-11 14:18:15 +08:00

692 lines
35 KiB
JavaScript

import puppeteer from 'puppeteer-core';
import { createProxyChainRelay } from './proxy-chain.mjs';
export const DEFAULT_ROXY_CONFIG = Object.freeze({
enabled: false,
headless: false,
useProxyPool: false,
concurrency: 1,
baseUrl: 'http://127.0.0.1:50000',
apiKey: '',
workspaceId: '',
profiles: Object.freeze([]),
});
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
const MAX_RESPONSE_BYTES = 1024 * 1024;
const activeProfiles = new Set();
const retainedProfileRelays = new Map();
const MESSAGES = {
roxy_config_invalid: 'Roxy 配置格式无效',
roxy_concurrency_invalid: 'Roxy 并发数必须是 1 到 3 的整数',
roxy_api_base_invalid: 'Roxy API 地址必须是本机 HTTP 地址,不能包含路径或登录信息',
roxy_api_key_missing: '请先配置 Roxy API 密钥',
roxy_workspace_missing: '请先选择 Roxy 团队',
roxy_disabled: 'Roxy 自动化未启用',
roxy_profile_missing: '该账号尚未绑定 Roxy 窗口,请在参数配置中配置',
roxy_profile_conflict: '每个账号需要独立的 Roxy 窗口,不能重复绑定',
roxy_profile_ambiguous: 'Roxy 中存在多个属于该账号的窗口,请在参数配置中手动选择正确窗口',
roxy_profile_list_incomplete: '未能完整核对 Roxy 窗口列表,暂未创建新窗口,请稍后重试',
roxy_profile_limit: '已达到本工具的 Roxy 窗口绑定上限,请先清理不再使用的账号绑定',
roxy_profile_capacity: 'Roxy 环境数量已达到套餐上限,请释放环境名额或调整套餐',
roxy_profile_busy: '该 Roxy 窗口已打开或正在执行任务,请先结束占用后重试',
roxy_profile_delete_failed: '未能确认 Roxy 环境已移至回收站,请在 Roxy 中检查环境名额',
roxy_proxy_pool_disabled: 'Roxy 已启用代理池,但工具代理池尚未开启,请先启用代理池',
roxy_proxy_missing: '该账号绑定的代理不存在或不可用,请在 Roxy 配置中重新选择代理',
roxy_proxy_invalid: 'Roxy 代理格式无效或协议不支持,请使用 HTTP、HTTPS 或 SOCKS5 代理',
roxy_proxy_chain_failed: '无法建立本机前置代理到账号固定代理的链路,请检查两端代理设置',
roxy_proxy_auth_failed: '无法为 Roxy 隔离登录会话设置代理认证,请检查代理配置后重试',
roxy_api_unavailable: '无法连接 Roxy 本机 API,请检查应用、API 开关与端口',
roxy_api_timeout: 'Roxy 本机 API 请求超时',
roxy_api_auth_failed: 'Roxy API 密钥无效或没有访问权限',
roxy_api_rate_limited: 'Roxy API 请求过于频繁,请稍后重试',
roxy_entitlement_unavailable: 'Roxy 当前套餐或权限不允许此操作,请在 Roxy 中检查',
roxy_api_failed: 'Roxy API 操作失败,请在 Roxy 中检查窗口与套餐状态',
roxy_response_invalid: 'Roxy API 返回格式无效',
roxy_cdp_invalid: 'Roxy 返回的浏览器调试地址不是有效本机地址',
roxy_connect_failed: '无法连接 Roxy 浏览器窗口,请检查内核与自动化权限',
roxy_cleanup_failed: 'Roxy 授权窗口未能自动关闭,请在 Roxy 中检查并关闭该窗口',
};
export class RoxyBrowserError extends Error {
constructor(code, { status = 409 } = {}) {
super(MESSAGES[code] || MESSAGES.roxy_api_failed);
this.name = 'RoxyBrowserError';
this.code = MESSAGES[code] ? code : 'roxy_api_failed';
this.status = status;
this.browserRequired = true;
this.needsInput = true;
}
}
function fail(code, status = 400) {
throw new RoxyBrowserError(code, { status });
}
function identifier(value, { optional = false } = {}) {
if (typeof value !== 'string') fail('roxy_config_invalid');
const result = value.trim();
if (optional && !result) return '';
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(result)) fail('roxy_config_invalid');
return result;
}
export function normalizeRoxyApiBase(value = DEFAULT_ROXY_CONFIG.baseUrl) {
let url;
try { url = new URL(value); } catch { fail('roxy_api_base_invalid'); }
if (url.protocol !== 'http:' || !LOOPBACK_HOSTS.has(url.hostname)
|| url.username || url.password || url.search || url.hash || url.pathname !== '/') {
fail('roxy_api_base_invalid');
}
return url.origin;
}
export function normalizeRoxyConfig(input = {}, previous = {}) {
if (!input || typeof input !== 'object' || Array.isArray(input)
|| !previous || typeof previous !== 'object' || Array.isArray(previous)) fail('roxy_config_invalid');
const value = { ...DEFAULT_ROXY_CONFIG, ...previous, ...input };
if (!Number.isInteger(value.concurrency) || value.concurrency < 1 || value.concurrency > 3) fail('roxy_concurrency_invalid');
if (typeof value.enabled !== 'boolean' || typeof value.headless !== 'boolean'
|| typeof value.useProxyPool !== 'boolean' || typeof value.baseUrl !== 'string'
|| typeof value.apiKey !== 'string' || !Array.isArray(value.profiles)) fail('roxy_config_invalid');
if (value.deleteProfileAfterSuccess !== undefined && typeof value.deleteProfileAfterSuccess !== 'boolean') {
fail('roxy_config_invalid');
}
if (value.deleteProfileAfterFailure !== undefined && typeof value.deleteProfileAfterFailure !== 'boolean') {
fail('roxy_config_invalid');
}
const apiKey = value.apiKey.trim();
if (apiKey.length > 1024 || /[\r\n]/.test(apiKey) || value.profiles.length > 1000) fail('roxy_config_invalid');
const workspaceId = identifier(value.workspaceId, { optional: true });
const children = new Set();
const directories = new Set();
const profiles = value.profiles.map((profile) => {
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) fail('roxy_config_invalid');
const childId = identifier(profile.childId);
const dirId = identifier(profile.dirId);
const proxyId = profile.proxyId === undefined ? '' : identifier(profile.proxyId, { optional: true });
if (children.has(childId) || directories.has(dirId)) fail('roxy_profile_conflict');
children.add(childId);
directories.add(dirId);
return { childId, dirId, ...(proxyId ? { proxyId } : {}) };
});
const profileMode = value.profileMode === undefined ? 'dedicated' : value.profileMode;
if (!['dedicated', 'pooled'].includes(profileMode)) fail('roxy_config_invalid');
const poolSize = value.poolSize === undefined ? 3 : value.poolSize;
const poolProfiles = value.poolProfiles === undefined ? [] : value.poolProfiles;
if (!Number.isInteger(poolSize) || poolSize < 1 || poolSize > 3
|| !Array.isArray(poolProfiles) || poolProfiles.length > 3) fail('roxy_config_invalid');
const poolSlots = new Set();
const poolDirectories = new Set();
const normalizedPoolProfiles = poolProfiles.map((profile) => {
if (!profile || typeof profile !== 'object' || !Number.isInteger(profile.slot)
|| profile.slot < 0 || profile.slot > 2) fail('roxy_config_invalid');
const dirId = identifier(profile.dirId);
if (poolSlots.has(profile.slot) || poolDirectories.has(dirId)) fail('roxy_profile_conflict');
poolSlots.add(profile.slot);
poolDirectories.add(dirId);
return { slot: profile.slot, dirId };
});
const config = { enabled: value.enabled, headless: value.headless, useProxyPool: value.useProxyPool, concurrency: value.concurrency,
baseUrl: normalizeRoxyApiBase(value.baseUrl), apiKey, workspaceId, profiles };
// Preserve old dedicated configurations byte-for-byte while retaining the
// pool mappings when its switch is temporarily turned off.
if (value.profileMode !== undefined || value.poolProfiles !== undefined || value.poolSize !== undefined) {
Object.assign(config, { profileMode, poolSize, poolProfiles: normalizedPoolProfiles });
}
if (value.deleteProfileAfterSuccess !== undefined) config.deleteProfileAfterSuccess = value.deleteProfileAfterSuccess;
if (value.deleteProfileAfterFailure !== undefined) config.deleteProfileAfterFailure = value.deleteProfileAfterFailure;
if (config.enabled && !apiKey) fail('roxy_api_key_missing');
if (config.enabled && !workspaceId) fail('roxy_workspace_missing');
return config;
}
export function publicRoxyConfig(config = {}) {
return {
enabled: config.enabled === true,
headless: config.headless === true,
useProxyPool: config.useProxyPool === true,
concurrency: config.concurrency ?? DEFAULT_ROXY_CONFIG.concurrency,
baseUrl: config.baseUrl || DEFAULT_ROXY_CONFIG.baseUrl,
apiKeySet: Boolean(config.apiKey),
workspaceId: config.workspaceId || '',
profiles: Array.isArray(config.profiles) ? config.profiles.map(({ childId, dirId, proxyId }) => ({ childId, dirId, ...(proxyId ? { proxyId } : {}) })) : [],
...(config.profileMode !== undefined ? { profileMode: config.profileMode, poolSize: config.poolSize ?? 3,
poolProfiles: (config.poolProfiles || []).map(({ slot, dirId }) => ({ slot, dirId })) } : {}),
...(config.deleteProfileAfterSuccess !== undefined ? { deleteProfileAfterSuccess: config.deleteProfileAfterSuccess === true } : {}),
...(config.deleteProfileAfterFailure !== undefined ? { deleteProfileAfterFailure: config.deleteProfileAfterFailure === true } : {}),
};
}
export function roxyProxyInfo(proxyEntry) {
if (!proxyEntry || typeof proxyEntry !== 'object' || Array.isArray(proxyEntry)
|| proxyEntry.enabled === false || !proxyEntry.url) fail('roxy_proxy_missing', 409);
let url;
let username;
let password;
try {
url = new URL(proxyEntry.url);
username = decodeURIComponent(url.username);
password = decodeURIComponent(url.password);
} catch { fail('roxy_proxy_invalid'); }
const categories = { 'http:': 'HTTP', 'https:': 'HTTPS', 'socks:': 'SOCKS5', 'socks5:': 'SOCKS5', 'socks5h:': 'SOCKS5' };
const proxyCategory = categories[url.protocol];
const host = url.hostname.replace(/^\[|\]$/g, '');
const port = Number(url.port || (url.protocol === 'http:' ? 80 : url.protocol === 'https:' ? 443 : 1080));
if (!proxyCategory || !host || (url.pathname && url.pathname !== '/') || url.search || url.hash
|| !Number.isInteger(port) || port < 1 || port > 65535
|| /[\r\n\0]/.test(username + password) || username.length > 1024 || password.length > 1024) {
fail('roxy_proxy_invalid');
}
// moduleId references a proxy saved inside Roxy. A custom endpoint has no
// such ID; even "0" is rejected as nonexistent by some Local API versions.
return { proxyMethod: 'custom', proxyCategory, ipType: host.includes(':') ? 'IPV6' : 'IPV4',
host, port: String(port), proxyUserName: username, proxyPassword: password };
}
function pagination({ pageIndex = 1, pageSize = 15 } = {}) {
if (!Number.isInteger(pageIndex) || pageIndex < 1 || pageIndex > 10000
|| !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) fail('roxy_config_invalid');
return { page_index: pageIndex, page_size: pageSize };
}
function paginated(data, project) {
if (!data || !Array.isArray(data.rows) || !Number.isSafeInteger(data.total) || data.total < 0
|| data.rows.length > 100) fail('roxy_response_invalid', 502);
try { return { total: data.total, rows: data.rows.map(project) }; }
catch (error) {
if (error instanceof RoxyBrowserError && error.code === 'roxy_response_invalid') throw error;
fail('roxy_response_invalid', 502);
}
}
function text(value) { return typeof value === 'string' ? value.slice(0, 500) : ''; }
async function readJson(response) {
const declared = Number(response.headers.get('content-length') || 0);
if (declared > MAX_RESPONSE_BYTES) fail('roxy_response_invalid', 502);
let body = '';
let bytes = 0;
const reader = response.body?.getReader();
if (!reader) fail('roxy_response_invalid', 502);
const decoder = new TextDecoder();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_RESPONSE_BYTES) fail('roxy_response_invalid', 502);
body += decoder.decode(value, { stream: true });
}
body += decoder.decode();
} finally {
await reader.cancel().catch(() => {});
}
try { return JSON.parse(body); } catch { fail('roxy_response_invalid', 502); }
}
// Endpoint names and the token header follow Roxy's official Local API docs.
export function createRoxyApiClient(config = {}, { fetchImpl = globalThis.fetch, timeoutMs = 15000 } = {}) {
const baseUrl = normalizeRoxyApiBase(config.baseUrl || DEFAULT_ROXY_CONFIG.baseUrl);
if (typeof config.apiKey !== 'string' || !config.apiKey.trim()) fail('roxy_api_key_missing');
const apiKey = config.apiKey.trim();
if (apiKey.length > 1024 || /[\r\n]/.test(apiKey)) fail('roxy_config_invalid');
const timeout = Math.min(60000, Math.max(100, Number(timeoutMs) || 15000));
const request = async (path, { method = 'GET', query = {}, body } = {}) => {
const url = new URL(`${baseUrl}${path}`);
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, String(value));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetchImpl(url, {
method, headers: { token: apiKey, ...(body ? { 'content-type': 'application/json' } : {}) },
...(body ? { body: JSON.stringify(body) } : {}), signal: controller.signal, redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
if (response.status === 401 || response.status === 403) fail('roxy_api_auth_failed', response.status);
if (response.status === 429) fail('roxy_api_rate_limited', 429);
fail('roxy_api_failed', 502);
}
const result = await readJson(response);
if (!result || typeof result !== 'object' || Array.isArray(result) || !Number.isInteger(result.code)) {
fail('roxy_response_invalid', 502);
}
if (result.code !== 0) {
if (/(?:窗口|环境|浏览器|profile|browser).*(?:上限|数量不足|数量已满|数量已用完|limit|quota|maximum)|(?:limit|quota|maximum).*(?:profile|browser)/i.test(String(result.msg || ''))) {
fail('roxy_profile_capacity', 409);
}
if (/过期|套餐|订阅|权限|expired|subscription|permission|entitlement/i.test(String(result.msg || ''))) {
fail('roxy_entitlement_unavailable', 409);
}
fail('roxy_api_failed', 502);
}
return result.data;
} catch (error) {
if (error instanceof RoxyBrowserError) throw error;
fail(controller.signal.aborted ? 'roxy_api_timeout' : 'roxy_api_unavailable', 503);
} finally { clearTimeout(timer); }
};
return {
async health() { await request('/health'); return { ok: true }; },
async listWorkspaces(options = {}) {
return paginated(await request('/browser/workspace', { query: pagination(options) }), (row) => ({
id: identifier(row.id), workspaceName: text(row.workspaceName),
}));
},
async listProfiles(workspaceId, options = {}) {
return paginated(await request('/browser/list_v3', { query: { workspaceId: identifier(workspaceId), ...pagination(options) } }), (row) => ({
dirId: identifier(row.dirId), windowSortNum: Number.isSafeInteger(row.windowSortNum) ? row.windowSortNum : null,
windowName: text(row.windowName), windowRemark: text(row.windowRemark), coreType: text(row.coreType), coreVersion: text(row.coreVersion),
}));
},
async createProfile(workspaceId, { windowName, windowRemark } = {}) {
if (typeof windowName !== 'string' || !windowName.trim() || windowName.length > 200
|| typeof windowRemark !== 'string' || !windowRemark.trim() || windowRemark.length > 500) fail('roxy_config_invalid');
// Official Local API create-browser-profile contract: workspaceId is
// required; name, remark and OS are optional. No account secrets are sent.
const data = await request('/browser/create', { method: 'POST', body: {
workspaceId: identifier(workspaceId), windowName, windowRemark,
os: process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux',
coreType: 'Chrome',
} });
try { return { dirId: identifier(data?.dirId) }; }
catch { fail('roxy_response_invalid', 502); }
},
async connectionInfo(dirId) {
const data = await request('/browser/connection_info', { query: { dirIds: identifier(dirId) } });
if (!Array.isArray(data) || data.length > 1000) fail('roxy_response_invalid', 502);
return data.map((row) => {
try { return { dirId: identifier(row.dirId) }; }
catch { fail('roxy_response_invalid', 502); }
});
},
async updateProxy(workspaceId, dirId, proxyEntry) {
// Official Local API: https://roxybrowser.com/docs/api-documentation/api-endpoint.html
// Only update the proxy. Omitted profile, session and fingerprint fields remain untouched.
await request('/browser/mdf', { method: 'POST', body: {
workspaceId: identifier(workspaceId), dirId: identifier(dirId), proxyInfo: roxyProxyInfo(proxyEntry),
} });
},
async open(workspaceId, dirId, { headless = false } = {}) {
if (typeof headless !== 'boolean') fail('roxy_config_invalid');
const data = await request('/browser/open', { method: 'POST', body: {
workspaceId: identifier(workspaceId), dirId: identifier(dirId), forceOpen: false, headless,
} });
return { ws: data?.ws };
},
async close(dirId) { await request('/browser/close', { method: 'POST', body: { dirId: identifier(dirId) } }); },
async softDeleteProfile(workspaceId, dirId) {
// https://roxybrowser.com/docs/api-documentation/api-endpoint.html#delete-browser-profile
// Roxy 4.0.7 defaults this flag to
// false (permanent deletion), so moving to Trash must explicitly use true.
const body = { workspaceId: identifier(workspaceId), dirIds: [identifier(dirId)], isSoftDelete: true };
try { await request('/browser/delete', { method: 'POST', body }); }
catch { fail('roxy_profile_delete_failed', 502); }
},
};
}
async function listCompleteRoxyProfiles(api, workspaceId) {
const profiles = [];
const seen = new Set();
let total;
for (let pageIndex = 1; ; pageIndex += 1) {
if (pageIndex > 100) fail('roxy_profile_list_incomplete', 409);
const page = await api.listProfiles(workspaceId, { pageIndex, pageSize: 100 });
if (!page || !Number.isSafeInteger(page.total) || page.total < 0 || !Array.isArray(page.rows)
|| page.rows.length > 100 || (total !== undefined && page.total !== total)) fail('roxy_profile_list_incomplete', 409);
total = page.total;
for (const profile of page.rows) {
if (!profile || typeof profile.dirId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(profile.dirId)
|| seen.has(profile.dirId)) fail('roxy_profile_list_incomplete', 409);
seen.add(profile.dirId);
profiles.push(profile);
}
if (seen.size === total) return profiles;
if (!page.rows.length || seen.size > total) fail('roxy_profile_list_incomplete', 409);
}
}
function isToolOwnedRoxyProfile(profile) {
return /^auto-team:(?:child:[a-zA-Z0-9_-]{1,128}|pool:[0-2])$/.test(profile.windowRemark || '');
}
export async function deleteClosedRoxyProfile(config, dirId, { client } = {}) {
const normalized = normalizeRoxyConfig(config);
if (!normalized.enabled) fail('roxy_disabled', 409);
const id = identifier(dirId);
const leaseKey = `${normalized.baseUrl}:${id}`;
if (activeProfiles.has(leaseKey)) fail('roxy_profile_busy', 409);
const api = client || createRoxyApiClient(normalized);
activeProfiles.add(leaseKey);
try {
const profiles = await listCompleteRoxyProfiles(api, normalized.workspaceId);
const profile = profiles.find((entry) => entry.dirId === id);
if (!profile) return { deleted: false, absent: true };
if (!isToolOwnedRoxyProfile(profile)) fail('roxy_profile_conflict', 409);
const connections = await api.connectionInfo(id);
if (connections.some((entry) => entry.dirId === id)) fail('roxy_profile_busy', 409);
try {
await api.softDeleteProfile(normalized.workspaceId, id);
const remaining = await listCompleteRoxyProfiles(api, normalized.workspaceId);
if (remaining.some((entry) => entry.dirId === id)) fail('roxy_profile_delete_failed', 502);
} catch { fail('roxy_profile_delete_failed', 502); }
return { deleted: true };
} catch (error) {
if (error instanceof RoxyBrowserError && ['roxy_profile_conflict', 'roxy_profile_busy', 'roxy_profile_list_incomplete'].includes(error.code)) {
throw error;
}
fail('roxy_profile_delete_failed', 502);
} finally { activeProfiles.delete(leaseKey); }
}
export async function resolveRoxyAccountProfile(config, childId, { email = '', client } = {}) {
const normalized = normalizeRoxyConfig(config);
const id = identifier(childId);
if (!normalized.enabled) fail('roxy_disabled', 409);
const bound = normalized.profiles.find((profile) => profile.childId === id);
if (!bound && normalized.profiles.length >= 1000) fail('roxy_profile_limit', 409);
const api = client || createRoxyApiClient(normalized);
const marker = `auto-team:child:${id}`;
const seen = new Set();
const matches = [];
let total;
for (let pageIndex = 1; ; pageIndex += 1) {
// Never create on a partial listing: a previous create may have succeeded
// before local persistence was interrupted. Recover only our exact marker.
if (pageIndex > 100) fail('roxy_profile_list_incomplete', 409);
const page = await api.listProfiles(normalized.workspaceId, { pageIndex, pageSize: 100 });
if (total !== undefined && page.total !== total) fail('roxy_profile_list_incomplete', 409);
total = page.total;
for (const profile of page.rows) {
if (seen.has(profile.dirId)) fail('roxy_profile_list_incomplete', 409);
seen.add(profile.dirId);
if (profile.windowRemark === marker) matches.push(profile);
}
if (seen.size === total) break;
if (!page.rows.length || seen.size > total) fail('roxy_profile_list_incomplete', 409);
}
// A local binding can outlive its remote environment. Verify the complete
// listing before trusting it; explicit manual bindings need no tool marker.
if (bound && seen.has(bound.dirId)) return { ...bound, source: 'bound' };
if (matches.length > 1) fail('roxy_profile_ambiguous', 409);
const existing = matches[0];
if (existing && normalized.profiles.some((profile) => profile.dirId === existing.dirId)) {
fail('roxy_profile_conflict', 409);
}
const profile = existing || await api.createProfile(normalized.workspaceId, {
windowName: `AutoTeam ${String(email || id).replace(/[\r\n]/g, '').slice(0, 170)}`,
windowRemark: marker,
});
if (normalized.profiles.some((entry) => entry.dirId === profile.dirId)) fail('roxy_profile_conflict', 409);
return { childId: id, dirId: profile.dirId, ...(bound?.proxyId ? { proxyId: bound.proxyId } : {}),
source: existing ? 'recovered' : 'created' };
}
export async function resolveRoxyPoolProfile(config, slot, { client } = {}) {
const normalized = normalizeRoxyConfig(config);
if (!normalized.enabled) fail('roxy_disabled', 409);
if (normalized.profileMode !== 'pooled' || !Number.isInteger(slot)
|| slot < 0 || slot >= normalized.poolSize) fail('roxy_config_invalid');
const api = client || createRoxyApiClient(normalized);
const marker = `auto-team:pool:${slot}`;
const profiles = [];
const seen = new Set();
let total;
for (let pageIndex = 1; ; pageIndex += 1) {
if (pageIndex > 100) fail('roxy_profile_list_incomplete', 409);
const page = await api.listProfiles(normalized.workspaceId, { pageIndex, pageSize: 100 });
if (total !== undefined && page.total !== total) fail('roxy_profile_list_incomplete', 409);
total = page.total;
for (const profile of page.rows) {
if (seen.has(profile.dirId)) fail('roxy_profile_list_incomplete', 409);
seen.add(profile.dirId);
profiles.push(profile);
}
if (seen.size === total) break;
if (!page.rows.length || seen.size > total) fail('roxy_profile_list_incomplete', 409);
}
const reserved = new Set(normalized.poolProfiles
.filter((profile) => profile.slot !== slot && profile.slot < normalized.poolSize)
.map((profile) => profile.dirId));
const bound = profiles.find((profile) => profile.dirId === normalized.poolProfiles.find((entry) => entry.slot === slot)?.dirId);
if (bound && !isToolOwnedRoxyProfile(bound)) fail('roxy_profile_conflict', 409);
// A recovered environment may now be bound to a different pool slot. Its
// older marker must not override the current local reservation.
const matches = profiles.filter((profile) => profile.windowRemark === marker && !reserved.has(profile.dirId));
if (matches.length > 1) fail('roxy_profile_ambiguous', 409);
const preferred = bound || matches[0];
const available = async (profile) => !activeProfiles.has(`${normalized.baseUrl}:${profile.dirId}`)
&& !(await api.connectionInfo(profile.dirId)).some((entry) => entry.dirId === profile.dirId);
if (preferred) {
if (reserved.has(preferred.dirId)) fail('roxy_profile_conflict', 409);
if (!await available(preferred)) fail('roxy_profile_busy', 409);
return { slot, dirId: preferred.dirId, source: bound ? 'bound' : 'recovered' };
}
// Migrate only this tool's idle environments. The isolated context used
// below keeps every account's cookies and local storage out of the profile.
const ownedProfiles = profiles.filter(isToolOwnedRoxyProfile);
for (const profile of ownedProfiles) {
if (!reserved.has(profile.dirId)
&& await available(profile)) return { slot, dirId: profile.dirId, source: 'reused' };
}
// Busy and reserved environments still occupy capacity. A missing local
// mapping must not turn a full pool into another remote environment.
if (ownedProfiles.length >= normalized.poolSize) fail('roxy_profile_busy', 409);
const created = await api.createProfile(normalized.workspaceId, {
windowName: `AutoTeam 授权环境 ${slot + 1}`, windowRemark: marker,
});
if (reserved.has(created.dirId) || seen.has(created.dirId)) fail('roxy_profile_conflict', 409);
return { slot, dirId: identifier(created.dirId), source: 'created' };
}
export function validateRoxyCdpEndpoint(value) {
let url;
try { url = new URL(value); } catch { fail('roxy_cdp_invalid', 502); }
if (url.protocol !== 'ws:' || !LOOPBACK_HOSTS.has(url.hostname) || !url.port
|| url.username || url.password || url.search || url.hash
|| !/^\/devtools\/browser\/[a-zA-Z0-9_-]+$/.test(url.pathname)) fail('roxy_cdp_invalid', 502);
return url.toString();
}
export function createRoxyCdpTransport(endpoint, { timeoutMs = 30000, WebSocketImpl = globalThis.WebSocket } = {}) {
const url = validateRoxyCdpEndpoint(endpoint);
return new Promise((resolve, reject) => {
let socket;
let opened = false;
let failed = false;
const stop = () => { try { socket?.close(); } catch { /* Closing a failed handshake is best effort. */ } };
const failure = () => {
clearTimeout(timer);
if (!opened) { failed = true; reject(new RoxyBrowserError('roxy_connect_failed', { status: 503 })); }
};
const timer = setTimeout(() => { failure(); stop(); }, Math.min(60000, Math.max(100, Number(timeoutMs) || 30000)));
try {
// The native WebSocket rejects redirects; Puppeteer's default transport follows them.
socket = new WebSocketImpl(url);
socket.binaryType = 'arraybuffer';
const transport = {
onmessage: undefined,
onclose: undefined,
send(message) { socket.send(message); },
close() { stop(); },
};
socket.addEventListener('message', ({ data }) => {
transport.onmessage?.(typeof data === 'string' ? data : new TextDecoder().decode(data));
});
socket.addEventListener('close', () => { failure(); transport.onclose?.(); });
socket.addEventListener('error', failure);
socket.addEventListener('open', () => {
if (failed) { stop(); return; }
opened = true;
clearTimeout(timer);
resolve(transport);
}, { once: true });
} catch { failure(); stop(); }
});
}
async function connectRoxyBrowser({ browserWSEndpoint, transportTimeout, ...options }) {
const transport = await createRoxyCdpTransport(browserWSEndpoint, { timeoutMs: transportTimeout });
try { return await puppeteer.connect({ ...options, transport }); }
catch { transport.close(); fail('roxy_connect_failed', 503); }
}
async function releaseRetainedProfileRelay(leaseKey) {
const retained = retainedProfileRelays.get(leaseKey);
if (!retained) return;
retainedProfileRelays.delete(leaseKey);
clearTimeout(retained.timer);
await retained.relay.close();
}
function retainProfileRelay(leaseKey, relay, api, dirId) {
const retained = { relay, timer: null };
retainedProfileRelays.set(leaseKey, retained);
const check = async () => {
if (retainedProfileRelays.get(leaseKey) !== retained) return;
try {
const connections = await api.connectionInfo(dirId);
if (retainedProfileRelays.get(leaseKey) !== retained) return;
if (!connections.some((entry) => entry.dirId === dirId)) {
await releaseRetainedProfileRelay(leaseKey);
return;
}
} catch { /* Keep a visible browser usable until it is known to be closed. */ }
if (retainedProfileRelays.get(leaseKey) === retained) {
retained.timer = setTimeout(check, 15000);
retained.timer.unref?.();
}
};
retained.timer = setTimeout(check, 15000);
retained.timer.unref?.();
}
export async function openRoxyBrowserSession({ config, childId, profile: profileOverride, proxyEntry, localProxyUrl = '', timeoutMs = 30000, onProfileAcquired = () => {} }, {
client, connect = connectRoxyBrowser, createRelay = createProxyChainRelay,
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), clock = Date.now,
} = {}) {
const operationDeadline = clock() + Math.min(180000, Math.max(1000, Number(timeoutMs) || 30000));
const normalized = normalizeRoxyConfig(config);
if (!normalized.enabled) fail('roxy_disabled', 409);
const pooled = normalized.profileMode === 'pooled';
const profile = pooled && profileOverride
? { dirId: identifier(profileOverride.dirId), ...(profileOverride.proxyId ? { proxyId: identifier(profileOverride.proxyId) } : {}) }
: normalized.profiles.find((entry) => entry.childId === childId);
if (!profile) fail('roxy_profile_missing', 409);
if (pooled && !normalized.poolProfiles.some((entry) => entry.dirId === profile.dirId)) fail('roxy_profile_missing', 409);
if (normalized.useProxyPool) {
roxyProxyInfo(proxyEntry);
if (profile.proxyId && profile.proxyId !== proxyEntry.id) fail('roxy_proxy_missing', 409);
}
const leaseKey = `${normalized.baseUrl}:${profile.dirId}`;
if (activeProfiles.has(leaseKey)) fail('roxy_profile_busy', 409);
activeProfiles.add(leaseKey);
const api = client || createRoxyApiClient(normalized, { timeoutMs });
let browser;
let page;
let context;
let owned = false;
let closePromise;
let relay;
let browserProxy;
const close = ({ keepOpen = false } = {}) => closePromise ||= (async () => {
let retainWindow = !pooled && keepOpen === true && normalized.headless === false && owned && browser && page;
try {
if (!retainWindow) {
if (context) await context.close().catch(() => {});
else await page?.close().catch(() => {});
}
try { await browser?.disconnect(); } catch { /* Never close a user's unrelated browser. */ }
if (owned && !retainWindow) {
try {
await api.close(profile.dirId);
// The Local API can acknowledge close before the browser exits.
// Keep our lease until the real window slot has become available.
const deadline = clock() + 10000;
for (;;) {
let connections;
try { connections = await api.connectionInfo(profile.dirId); } catch { /* Retry an unconfirmed close. */ }
if (connections && !connections.some((entry) => entry.dirId === profile.dirId)) break;
if (clock() >= deadline) fail('roxy_cleanup_failed', 503);
await wait(Math.min(250, deadline - clock()));
}
}
catch { retainWindow = true; fail('roxy_cleanup_failed', 503); }
}
} finally {
try {
if (relay) {
if (retainWindow) retainProfileRelay(leaseKey, relay, api, profile.dirId);
else await relay.close();
}
} finally { activeProfiles.delete(leaseKey); }
}
})();
try {
const connections = await api.connectionInfo(profile.dirId);
if (connections.some((entry) => entry.dirId === profile.dirId)) fail('roxy_profile_busy', 409);
// Record ownership before proxy/open can fail, but never claim a user's
// already-open window. The caller keeps its queue slot through cleanup.
onProfileAcquired(profile.dirId);
await releaseRetainedProfileRelay(leaseKey);
if (normalized.useProxyPool) {
if (localProxyUrl) {
try { relay = await createRelay({ localProxyUrl, upstreamProxyUrl: proxyEntry.url, timeoutMs }); }
catch { fail('roxy_proxy_chain_failed', 503); }
}
// Roxy sees a per-account loopback relay; the stored binding and backend
// still identify the actual upstream. The relay dials it via the local proxy.
const effectiveProxy = relay ? { ...proxyEntry, url: relay.url } : proxyEntry;
browserProxy = roxyProxyInfo(effectiveProxy);
await api.updateProxy(normalized.workspaceId, profile.dirId, effectiveProxy);
}
const opened = await api.open(normalized.workspaceId, profile.dirId, { headless: normalized.headless });
owned = true;
const browserWSEndpoint = validateRoxyCdpEndpoint(opened.ws);
const deadline = Math.min(operationDeadline, clock() + 60000);
for (;;) {
try {
if (clock() >= deadline) fail('roxy_connect_failed', 503);
browser = await connect({ browserWSEndpoint, defaultViewport: null,
// Websocket startup retries need a short handshake timeout. The
// Puppeteer protocol timeout persists for the entire connection,
// including Page.navigate, so it must retain the OAuth time budget.
transportTimeout: Math.max(100, Math.min(5000, deadline - clock())),
protocolTimeout: Math.max(1000, operationDeadline - clock()) });
if (pooled) {
context = await browser.createBrowserContext();
page = await context.newPage();
} else {
// A dedicated existing profile retains its normal login session.
page = await browser.newPage();
}
break;
} catch {
// Roxy may return the websocket before its browser is ready. Retry
// attachment to the same owned window instead of closing/reopening it.
try { await context?.close(); } catch { /* A failed target may already be closed. */ }
context = undefined;
try { await browser?.disconnect(); } catch { /* A failed attachment may already be disconnected. */ }
browser = undefined;
if (clock() >= deadline) fail('roxy_connect_failed', 503);
await wait(Math.min(500, deadline - clock()));
}
}
if (pooled && ['HTTP', 'HTTPS'].includes(browserProxy?.proxyCategory)
&& (browserProxy.proxyUserName || browserProxy.proxyPassword)) {
// An isolated context cannot rely on proxy-auth extensions/state in
// the persistent Roxy profile. Answer the actual HTTP proxy's challenge
// before the first navigation; a chained relay has its own credentials.
try {
await page.authenticate({ username: browserProxy.proxyUserName, password: browserProxy.proxyPassword });
} catch { fail('roxy_proxy_auth_failed', 503); }
}
return { page, close, keepOpenOnFailure: !pooled && normalized.headless === false };
} catch (error) {
await close();
throw error instanceof RoxyBrowserError ? error : new RoxyBrowserError('roxy_api_failed', { status: 502 });
}
}