diff --git a/server/auto-json-integration.test.mjs b/server/auto-json-integration.test.mjs index 623d5c8..fc06827 100644 --- a/server/auto-json-integration.test.mjs +++ b/server/auto-json-integration.test.mjs @@ -57,7 +57,7 @@ function assertPublicAutoJsonLogs(job, children) { assert.doesNotMatch(JSON.stringify(job.logs), /private-|fixture-automatic-code|manual-team-code|JBSWY3DPEHPK3PXP|upstream-private-message|quota_forbidden_private_upstream_error|proxy-private-|auth\.openai\.com|https?:\/\//); } -async function appFixture(t, children, { responses = {}, holdTokens = false, credentialChild = null, challenge = false, mothers = [], teamScenario = null, roxyEnabled = false, concurrency = 2 } = {}) { +async function appFixture(t, children, { responses = {}, tokenStatuses = {}, holdTokens = false, credentialChild = null, challenge = false, mothers = [], teamScenario = null, roxyEnabled = false, concurrency = 2 } = {}) { const grants = []; const paths = []; const selectedWorkspaces = []; @@ -95,7 +95,7 @@ async function appFixture(t, children, { responses = {}, holdTokens = false, cre ? (selected === teamWorkspaceId ? teamScenario.teamToken || token(credentialChild.email, credentialChild.id, { workspaceId: teamWorkspaceId, plan: 'team' }) : token(credentialChild.email, credentialChild.id)) : responses[key]; active -= 1; - return json(response || { error: 'invalid_grant', message: `upstream-private-message-${key} http://proxy-private-user:proxy-private-password@proxy.private.fixture:8888` }, response ? 200 : 400); + return json(response || { error: 'invalid_grant', message: `upstream-private-message-${key} http://proxy-private-user:proxy-private-password@proxy.private.fixture:8888` }, tokenStatuses[key] || (response ? 200 : 400)); } if (pathname === '/sentinel') return json({ token: 'isolated-sentinel-fixture-token' }); if (pathname === '/oauth/authorize') { @@ -209,7 +209,15 @@ async function appFixture(t, children, { responses = {}, holdTokens = false, cre const nativeFetch = globalThis.fetch; globalThis.fetch = async (input, options) => { const url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url); - if (url.port === '50000') throw new Error('isolated_test_roxy_api_disabled'); + if (url.port === '50000') { + // Existing bindings are now checked against the remote inventory before + // login. Only provide this read-only fixture; real Roxy stays disabled. + if (url.hostname === '127.0.0.1' && url.pathname === '/browser/list_v3') { + const rows = ${JSON.stringify(children.map((child) => ({ dirId: `fixture-${child.id}`, windowRemark: `auto-team:child:${child.id}` })))}; + return new Response(JSON.stringify({ code: 0, data: { total: rows.length, rows } }), { headers: { 'content-type': 'application/json' } }); + } + throw new Error('isolated_test_roxy_api_disabled'); + } if (url.hostname === 'auth.openai.com') { const pathname = ['/api/oauth/oauth2/token', '/oauth/token'].includes(url.pathname) ? '/token' : url.pathname; return nativeFetch(${JSON.stringify(`http://127.0.0.1:${upstreamPort}`)} + pathname + url.search, options); @@ -698,7 +706,7 @@ test('Team JSON success keeps failed quota explicitly unknown and never fabricat assert.ok(job.logs.some((entry) => entry.childId === child.id && entry.stage === 'team_json' && entry.status === 'ready')); assert.doesNotMatch(JSON.stringify(job), /quota_forbidden_private_upstream_error|private-quota-team-rt/); const requestsBeforeReuse = app.requests.length; - const repeated = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother' })).job.id); + const repeated = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother', ids: [child.id] })).job.id); assert.equal(repeated.succeeded, 1); assert.equal(repeated.results[0].reused, true); assert.equal(repeated.results[0].quota5h, null); @@ -731,7 +739,7 @@ test('Team authorization readiness rejects wrong plans, expired or missing expir assert.ok(projected.filter((child) => child.id !== valid.id).every((child) => child.teamAuthorizations[0].ready === false)); assert.equal(projected.find((child) => child.id === valid.id).teamAuthorizations[0].ready, true); assert.doesNotMatch(JSON.stringify(projected.map((child) => child.teamAuthorizations)), /private-invalid-team-rt|private-valid-team-rt|private-fixture-signature/); - const job = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother' })).job.id); + const job = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother', ids: [...children, valid].map((child) => child.id) })).job.id); assert.equal(job.results.find((result) => result.id === valid.id).reused, true); assert.ok(job.results.filter((result) => result.id !== valid.id).every((result) => result.status !== 'ready' && result.reused === false)); }); @@ -793,6 +801,170 @@ test('saved exact Team JSON skips all network, pending callbacks and login while assert.deepEqual(saved.teamAuthSessions, child.teamAuthSessions); }); +test('default Team batch excludes saved successful JSON and an explicit ready account remains a harmless skip', async (t) => { + const workspaceId = 'fixture-team-workspace'; + const ready = { id: 'batch-ready', email: 'batch-ready@example.test', team: workspaceId, chatgptUserId: 'batch-ready', + workspaceTokens: { [workspaceId]: { accessToken: token('batch-ready@example.test', 'batch-ready', { workspaceId, plan: 'team' }).access_token, refreshToken: 'private-batch-ready-rt' } } }; + const pending = { id: 'batch-pending', email: 'batch-pending@example.test', team: workspaceId }; + const unrelated = { id: 'batch-unrelated', email: 'batch-unrelated@example.test' }; + const app = await appFixture(t, [ready, pending, unrelated], { mothers: [teamOwner()], teamScenario: { + members: [ownerMember(), { id: pending.id, email: pending.email, role: 'standard-user', seat_type: 'default' }], + } }); + const batch = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother' })).job.id); + assert.equal(batch.total, 1); + assert.deepEqual(batch.results.map((result) => result.id), [pending.id]); + assert.ok(batch.logs.every((entry) => entry.childId !== ready.id && entry.childId !== unrelated.id)); + assert.deepEqual((await app.saved()).children.find((child) => child.id === ready.id).workspaceTokens, ready.workspaceTokens); + const requestCount = app.requests.length; + const single = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother', ids: [ready.id], retryAccount: true })).job.id); + assert.equal(single.total, 1); + assert.equal(single.succeeded, 1); + assert.equal(single.results[0].id, ready.id); + assert.equal(single.results[0].reused, true); + assert.equal(app.requests.length, requestCount); +}); + +test('single account retry requires an explicit Team account and cannot expand or mix retry scopes', async (t) => { + const children = ['retry-target', 'retry-other'].map((id) => ({ id, email: `${id}@example.test` })); + const app = await appFixture(t, children, { mothers: [teamOwner()] }); + const base = { scope: 'team', motherId: 'fixture-mother', retryAccount: true }; + for (const body of [ + base, + { ...base, ids: [] }, + { ...base, ids: [''] }, + { ...base, ids: children.map((child) => child.id) }, + { ...base, ids: [children[0].id], scope: 'free' }, + { ...base, ids: [children[0].id], retryFailed: true }, + { ...base, ids: [children[0].id], retryFailed: false }, + { ...base, ids: [children[0].id], retryJobId: 'previous-job' }, + { ...base, ids: [children[0].id], retryAccount: 'true' }, + ]) { + const result = await app.request('/api/children/auto-json', { method: 'POST', body }); + assert.equal(result.status, 400); + const emptySelection = Array.isArray(body.ids) && !body.ids.some((id) => String(id || '').trim()); + assert.equal(result.data.message, emptySelection ? 'child_ids_required' : 'invalid_retry_request', JSON.stringify(body)); + } + assert.equal(app.requests.length, 0); +}); + +test('single account retry reopens only its paused Team login without an old job ID', async (t) => { + const workspaceId = 'fixture-team-workspace'; + const child = { id: 'single-paused', email: 'single-paused@example.test', password: 'private-single-paused-password', team: workspaceId }; + const ready = { id: 'single-ready-other', email: 'single-ready-other@example.test', team: workspaceId, chatgptUserId: 'single-ready-other', + workspaceTokens: { [workspaceId]: { accessToken: token('single-ready-other@example.test', 'single-ready-other', { workspaceId, plan: 'team' }).access_token, refreshToken: 'private-single-ready-rt' } } }; + const scenario = { noMfa: true, challengeTeam: true, + members: [ownerMember(), { id: child.id, email: child.email, role: 'standard-user', seat_type: 'default' }] }; + const app = await appFixture(t, [child, ready], { credentialChild: child, mothers: [teamOwner()], roxyEnabled: true, teamScenario: scenario }); + const body = { scope: 'team', motherId: 'fixture-mother', ids: [child.id] }; + const first = await app.finish((await app.start(body)).job.id); + assert.equal(first.needsAttention, 1); + assert.equal(app.paths.filter((pathname) => pathname === '/oauth/authorize').length, 1); + const ordinary = await app.finish((await app.start(body)).job.id); + assert.equal(ordinary.needsAttention, 1); + assert.equal(app.paths.filter((pathname) => pathname === '/oauth/authorize').length, 1); + scenario.challengeTeam = false; + const repeated = await app.finish((await app.start({ ...body, retryAccount: true })).job.id); + assert.equal(repeated.total, 1); + assert.equal(repeated.succeeded, 1); + assert.equal(repeated.results[0].id, child.id); + assert.equal(repeated.results[0].quotaChecked, true); + assert.equal(app.paths.filter((pathname) => pathname === '/oauth/authorize').length, 2); + assert.ok(repeated.logs.every((entry) => entry.childId === null || entry.childId === child.id)); + assert.deepEqual((await app.saved()).children.find((entry) => entry.id === ready.id).workspaceTokens, ready.workspaceTokens); + const requests = app.requests.length; + const finished = await app.finish((await app.start({ ...body, retryAccount: true })).job.id); + assert.equal(finished.results[0].reused, true); + assert.equal(app.requests.length, requests); +}); + +test('expired saved Team JSON renews its own RT and cached quota without membership calls or browser login', async (t) => { + const workspaceId = 'fixture-team-workspace'; + const child = { id: 'expired-team-refresh', email: 'expired-team-refresh@example.test', chatgptUserId: 'expired-team-refresh', team: workspaceId, + workspaceHistory: [{ team: workspaceId, status: 'active', quota5h: 0, quota7d: 64, quotaStatus: 'exhausted', quotaUpdatedAt: '2026-01-01T00:00:00.000Z' }], + workspaceTokens: { [workspaceId]: { accessToken: token('expired-team-refresh@example.test', 'expired-team-refresh', { workspaceId, plan: 'business', expiresIn: -60 }).access_token, refreshToken: 'private-expired-team-refresh-rt' } }, + teamLoginStates: { [workspaceId]: { workspaceId, status: 'browser_verification_required', code: 'browser_verification_required', needsInput: true, browserRequired: true } }, + }; + const refreshed = token(child.email, child.chatgptUserId, { workspaceId, plan: 'business' }); + const app = await appFixture(t, [child], { mothers: [teamOwner()], roxyEnabled: true, + responses: { 'private-expired-team-refresh-rt': refreshed } }); + const job = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother', ids: [child.id], retryAccount: true })).job.id); + assert.equal(job.succeeded, 1); + assert.equal(job.results[0].reused, true); + assert.equal(job.results[0].source, 'stored_team_token'); + assert.equal(job.results[0].quotaCached, true); + assert.equal(job.results[0].quotaChecked, false); + assert.equal(job.results[0].quota5h, 0); + assert.equal(job.results[0].quota7d, 64); + assert.deepEqual(app.paths, ['/token']); + assert.equal(app.grants[0].type, 'refresh_token'); + const saved = (await app.saved()).children[0]; + assert.equal(saved.workspaceTokens[workspaceId].accessToken, refreshed.access_token); + assert.equal(saved.workspaceTokens[workspaceId].refreshToken, refreshed.refresh_token); + const repeated = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother' })).job.id); + assert.equal(repeated.total, 0); + assert.deepEqual(app.paths, ['/token']); +}); + +test('completed Team callback replaces an expired JSON before trying its invalid old RT', async (t) => { + const workspaceId = 'fixture-team-workspace'; + const child = { id: 'team-callback-before-refresh', email: 'team-callback-before-refresh@example.test', + chatgptUserId: 'team-callback-before-refresh', team: workspaceId, + workspaceHistory: [{ team: workspaceId, status: 'active', role: 'standard-user' }], + workspaceTokens: { [workspaceId]: { accessToken: token('team-callback-before-refresh@example.test', 'team-callback-before-refresh', { workspaceId, plan: 'team', expiresIn: -60 }).access_token, refreshToken: 'private-invalid-old-team-rt' } }, + teamAuthSessions: { [workspaceId]: { state: 'private-completed-team-state', codeVerifier: 'private-completed-team-verifier', + callbackReceivedAt: new Date().toISOString(), callbackUrl: 'http://localhost:1455/auth/callback?code=private-completed-team-code&state=private-completed-team-state' } }, + teamLoginStates: { [workspaceId]: { workspaceId, status: 'browser_verification_required', code: 'browser_verification_required', needsInput: true, browserRequired: true } }, + }; + const credentials = token(child.email, child.chatgptUserId, { workspaceId, plan: 'team' }); + const app = await appFixture(t, [child], { mothers: [teamOwner()], roxyEnabled: true, + responses: { 'private-completed-team-code': credentials }, + teamScenario: { members: [ownerMember(), { id: child.id, email: child.email, role: 'standard-user', seat_type: 'default' }] }, + }); + const job = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother', ids: [child.id] })).job.id); + assert.equal(job.succeeded, 1); + assert.equal(job.results[0].id, child.id); + assert.equal(job.results[0].quotaChecked, true); + assert.deepEqual(app.grants, [{ key: 'private-completed-team-code', type: 'authorization_code', verifier: 'private-completed-team-verifier' }]); + assert.ok(app.paths.includes(`/backend-api/accounts/${workspaceId}/users`)); + assert.ok(!app.paths.some((pathname) => pathname === '/oauth/authorize' || pathname.startsWith('/api/accounts/'))); + assert.ok(!job.logs.some((entry) => /重复浏览器登录|正在启动独立浏览器|正在打开账号绑定/.test(entry.message))); + const saved = (await app.saved()).children[0]; + assert.equal(saved.workspaceTokens[workspaceId].accessToken, credentials.access_token); + assert.equal(saved.workspaceTokens[workspaceId].refreshToken, credentials.refresh_token); + assert.equal(saved.teamAuthSessions[workspaceId], undefined); + assert.doesNotMatch(JSON.stringify(job), /private-completed-team-code|private-completed-team-verifier|private-invalid-old-team-rt/); +}); + +test('Team RT failure or a wrong refreshed token preserves prior JSON and never opens a browser', async (t) => { + const workspaceId = 'fixture-team-workspace'; + for (const scenario of [ + { name: 'invalid-grant' }, + { name: 'upstream-unavailable', tokenStatus: 503 }, + { name: 'wrong-email', email: 'foreign@example.test' }, + { name: 'wrong-subject', subject: 'foreign-user' }, + { name: 'wrong-workspace', workspaceId: 'foreign-workspace' }, + { name: 'wrong-plan', plan: 'free' }, + { name: 'expired-response', expiresIn: -60 }, + ]) await t.test(scenario.name, async (subtest) => { + const child = { id: 'expired-invalid-refresh', email: 'expired-invalid-refresh@example.test', password: 'private-unused-login-password', + chatgptUserId: 'expired-invalid-refresh', team: workspaceId, workspaceHistory: [{ team: workspaceId, status: 'active' }], + workspaceTokens: { [workspaceId]: { accessToken: token('expired-invalid-refresh@example.test', 'expired-invalid-refresh', { workspaceId, plan: 'team', expiresIn: -60 }).access_token, refreshToken: 'private-original-team-rt' } }, + }; + const response = scenario.name === 'invalid-grant' ? null : token(scenario.email || child.email, scenario.subject || child.chatgptUserId, + { workspaceId: scenario.workspaceId || workspaceId, plan: scenario.plan || 'team', expiresIn: scenario.expiresIn ?? 3600 }); + const app = await appFixture(subtest, [child], { credentialChild: child, mothers: [teamOwner()], roxyEnabled: true, + responses: { 'private-original-team-rt': response }, tokenStatuses: { 'private-original-team-rt': scenario.tokenStatus } }); + const job = await app.finish((await app.start({ scope: 'team', motherId: 'fixture-mother', ids: [child.id] })).job.id); + assert.equal(job.succeeded, 0); + assert.equal(job.failed, 1); + assert.ok(['team_token_refresh_failed', 'team_token_changed', 'token_identity_mismatch', 'team_workspace_mismatch'].includes(job.results[0].code), job.results[0].code); + assert.deepEqual(app.paths, ['/token']); + assert.equal(app.grants[0].type, 'refresh_token'); + assert.deepEqual((await app.saved()).children[0].workspaceTokens, child.workspaceTokens); + assert.doesNotMatch(JSON.stringify(job), /private-original-team-rt|private-fixture-new-rt|private-fixture-signature|upstream-private-message/); + }); +}); + test('serial Team batch settles failures once, continues to the next account and explicitly retries only selected failures', async (t) => { const workspaceId = 'fixture-team-workspace'; const selected = { id: 'selected-failure', email: 'selected-failure@example.test', password: 'private-selected-password', team: workspaceId }; diff --git a/server/batch-auth-integration.test.mjs b/server/batch-auth-integration.test.mjs index 0353ba1..5ca030b 100644 --- a/server/batch-auth-integration.test.mjs +++ b/server/batch-auth-integration.test.mjs @@ -880,7 +880,7 @@ for (const status of [408, 429, 503]) { test('a completed Sub2API browser authorization is consumed on the next Free JSON click instead of creating another link', async (t) => { const id = 'provider-callback-after-link-child'; - const email = 'callback-user@example.test'; + const email = 'halortappiahs@example.test'; const state = 'provider-callback-after-link-state'; const code = 'provider-callback-after-link-code'; const sessionId = 'provider-callback-after-link-session'; diff --git a/server/index.mjs b/server/index.mjs index f755beb..1520881 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -21,12 +21,12 @@ import { automaticRotationTeams, normalizeTeamRotationEnabled } from './team-rot import { currentSeatQuantity, normalizeBillingPreview } from './billing-preview.mjs'; import { clearQuotaKickDelay, normalizeDelayedKickMinutes, quotaKickDelayDue, updateQuotaKickDelay } from './quota-kick-delay.mjs'; import { createDashboardAuth } from './dashboard-auth.mjs'; -import { DEFAULT_ROXY_CONFIG, RoxyBrowserError, createRoxyApiClient, normalizeRoxyConfig, openRoxyBrowserSession, publicRoxyConfig, resolveRoxyAccountProfile, roxyProxyInfo } from './roxy-browser.mjs'; +import { DEFAULT_ROXY_CONFIG, RoxyBrowserError, createRoxyApiClient, deleteClosedRoxyProfile, normalizeRoxyConfig, openRoxyBrowserSession, publicRoxyConfig, resolveRoxyAccountProfile, resolveRoxyPoolProfile, roxyProxyInfo } from './roxy-browser.mjs'; import { completeBrowserOAuth } from './openai-browser-login.mjs'; import { buildAccountExportCandidates, selectAccountExports } from './account-exports.mjs'; import { createConcurrencyQueue } from './concurrency-queue.mjs'; import { MotherVaultError, createMotherVaultRecord, exportMotherVaultRecords, fullMotherVaultRecord, importMotherVaultRecords, publicMotherVaultRecord, selectMotherVaultRecords, updateMotherVaultRecord } from './mother-vault.mjs'; -import { MailboxVaultError, createMailboxVaultRecord, exportMailboxVaultRecords, fullMailboxVaultRecord, importMailboxVaultRecords, publicMailboxVaultRecord, selectMailboxVaultRecords, updateMailboxVaultRecord } from './mailbox-vault.mjs'; +import { MailboxVaultError, createMailboxVaultRecord, exportMailboxVaultRecords, fullMailboxVaultRecord, importMailboxVaultRecords, markMailboxVaultRecordsExported, publicMailboxVaultRecord, selectMailboxVaultRecords, updateMailboxVaultRecord } from './mailbox-vault.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const dataDir = path.resolve(process.env.TEAM_ROTATION_DATA_DIR || path.join(root, 'data')); @@ -1022,16 +1022,17 @@ function saveTeamInvitation(child, mother, fields = {}) { return entry; } -function completeTeamJsonFor(child, mother) { +function completeTeamJsonFor(child, mother, { allowExpired = false } = {}) { const workspaceId = configuredTeamId(mother); const record = child?.workspaceTokens?.[workspaceId]; - const details = teamTokenDetails(record?.accessToken, workspaceId); - const plan = String(details?.claims?.planType || record?.planType || '').toLowerCase(); - const expiresAt = Date.parse(details?.claims?.expiresAt || ''); - const identityKnown = Boolean(details?.claims?.userId && child?.chatgptUserId === details.claims.userId) - || Boolean(details?.claims?.email && workspaceIdKey(details.claims.email) === workspaceIdKey(child?.email)); - return record?.refreshToken && details && ['team', 'business', 'self_serve_business_prolite'].includes(plan) - && identityKnown && Number.isFinite(expiresAt) && expiresAt > Date.now() + 30_000 && childTokenIdentityMatches(child, record.accessToken) ? record : null; + const claims = accessTokenClaims(record?.accessToken); + const plan = String(claims.planType || record?.planType || '').toLowerCase(); + const expiresAt = Date.parse(claims.expiresAt || ''); + const identityKnown = Boolean(claims.userId && child?.chatgptUserId === claims.userId) + || Boolean(claims.email && workspaceIdKey(claims.email) === workspaceIdKey(child?.email)); + return record?.refreshToken && workspaceId && claims.accountId === workspaceId && ['team', 'business', 'self_serve_business_prolite'].includes(plan) + && identityKnown && Number.isFinite(expiresAt) && (allowExpired || expiresAt > Date.now() + 30_000) + && childTokenIdentityMatches(child, record.accessToken) ? record : null; } function publicChild(child, teamId = null) { @@ -2942,6 +2943,14 @@ function childKickTarget(childId, body = {}) { if (!body || typeof body !== 'object' || Array.isArray(body)) return { failure: fail(400, 'team_kick_scope_required') }; const child = findChild(childId); if (!child) return { failure: fail(404, 'child_not_found') }; + if (Object.hasOwn(body, 'expectedEmail')) { + if (typeof body.expectedEmail !== 'string' || !body.expectedEmail.trim()) { + return { failure: fail(400, 'team_kick_identity_required') }; + } + if (workspaceIdKey(body.expectedEmail) !== workspaceIdKey(child.email)) { + return { failure: fail(409, 'child_identity_changed_before_kick') }; + } + } const explicit = Object.hasOwn(body || {}, 'motherId') || Object.hasOwn(body || {}, 'workspaceId'); if (explicit && (typeof body.motherId !== 'string' || !body.motherId.trim() || typeof body.workspaceId !== 'string' || !body.workspaceId.trim())) { @@ -3859,11 +3868,26 @@ const childAuthQueues = new Map(); const childTeamAuthOperations = new Map(); let roxyProfileProvisioning = Promise.resolve(); const autoJsonExecution = new AsyncLocalStorage(); -const roxyBrowserQueue = createConcurrencyQueue(() => state.settings.integrations?.roxy?.concurrency || 1); +const roxyExecution = new AsyncLocalStorage(); +// Only locally produced cleanup metadata may survive an unexpected exception; +// exception messages and arbitrary provider payloads remain private. +const roxyAuthorizationFailureCleanup = new WeakMap(); +function roxyConcurrency(config = state.settings.integrations?.roxy) { + return Math.min(config?.concurrency || 1, config?.profileMode === 'pooled' ? config.poolSize || 3 : 3); +} +const roxyBrowserQueue = createConcurrencyQueue(() => roxyConcurrency()); +const activeRoxyPoolSlots = new Set(); const roxyCleanupBlocks = new Map(); +let roxyProfileCleanupJobs = 0; +let roxyProfileCleanupFinished = Promise.resolve(); -async function verifyRoxyCleanup() { +function runRoxyQueued(operation) { + return roxyExecution.getStore() ? operation() : roxyBrowserQueue.run(operation); +} + +async function verifyRoxyCleanup(target = null) { for (const [key, { config, dirId }] of roxyCleanupBlocks) { + if (target && (target.dirId !== dirId || target.baseUrl !== config.baseUrl)) continue; try { const current = state.settings.integrations?.roxy; const connectionConfig = current?.baseUrl === config.baseUrl ? { ...config, apiKey: current.apiKey } : config; @@ -3874,23 +3898,26 @@ async function verifyRoxyCleanup() { } } -async function ensureChildRoxyProfile(child, onProgress) { +async function ensureChildRoxyProfile(child, execution, onProgress) { // Share the provision/persist lock across Free and all Team authorization // paths. Different accounts must not overwrite each other's mappings. const pending = roxyProfileProvisioning.catch(() => {}).then(async () => { const current = state.settings.integrations?.roxy; - if (!current?.enabled || current.profiles.some((profile) => profile.childId === child.id)) return; + if (!current?.enabled) return; if (findChild(child.id) !== child) throw new RoxyBrowserError('roxy_profile_missing'); + const bound = current.profiles.find((profile) => profile.childId === child.id); // Validate the proxy pool before consuming a Roxy environment slot. bindRoxyProxyProfiles({ ...structuredClone(current), profiles: [ - ...structuredClone(current.profiles), { childId: child.id, dirId: 'pending_profile' }, + ...structuredClone(current.profiles), ...(!bound ? [{ childId: child.id, dirId: 'pending_profile' }] : []), ] }); - onProgress('roxy_profile_preparing', '正在为该账号查找或创建独立 Roxy 窗口'); + onProgress('roxy_profile_preparing', '正在核验账号绑定的 Roxy 环境,失效时重新分配'); const { source, ...profile } = await resolveRoxyAccountProfile(current, child.id, { email: child.email }); + if (source === 'created') execution.preparedProfile = { config: current, dirId: profile.dirId }; if (findChild(child.id) !== child || state.settings.integrations.roxy !== current) { throw new RoxyBrowserError('roxy_profile_conflict'); } - current.profiles.push(profile); + if (source === 'bound') return; + current.profiles = [...current.profiles.filter((entry) => entry.childId !== child.id), profile]; // Preserve this binding even if proxy selection/login later fails. The // next attempt resumes the same environment instead of creating another. await persist(); @@ -3901,22 +3928,161 @@ async function ensureChildRoxyProfile(child, onProgress) { return pending; } -async function runChildBrowserAuthorization(child, { browserLogin, callbackUrl, onProgress }, operation) { +async function prepareRoxyPoolAccount(child, execution, onProgress) { + const pending = roxyProfileProvisioning.catch(() => {}).then(async () => { + const current = state.settings.integrations?.roxy; + if (findChild(child.id) !== child || current?.profileMode !== 'pooled') throw new RoxyBrowserError('roxy_profile_missing'); + bindRoxyProxyProfiles(structuredClone(current)); + if (current.useProxyPool) { + const entries = state.settings.proxy.entries.filter((entry) => entry?.url && entry.enabled !== false); + const existing = child.roxyProxyId || current.profiles.find((profile) => profile.childId === child.id)?.proxyId; + const selected = entries.find((entry) => entry.id === existing) || entries.reduce((best, entry) => { + const count = (id) => state.children.filter((item) => item.roxyProxyId === id).length; + return count(entry.id) < count(best.id) ? entry : best; + }, entries[0]); + if (!selected) throw new RoxyBrowserError('roxy_proxy_missing'); + roxyProxyInfo(selected); + child.roxyProxyId = selected.id; + } + const slots = Array.from({ length: current.poolSize || 3 }, (_, index) => index) + .filter((index) => !activeRoxyPoolSlots.has(index)); + onProgress('roxy_pool_preparing', '正在分配空闲 Roxy 环境'); + let resolved; + for (const slot of slots) { + activeRoxyPoolSlots.add(slot); + execution.slot = slot; + try { + resolved = await resolveRoxyPoolProfile(current, slot); + execution.preparedProfile = { config: current, dirId: resolved.dirId }; + break; + } catch (error) { + activeRoxyPoolSlots.delete(slot); + delete execution.slot; + // A user-opened environment must not block another idle pool slot, + // or cause the tool to create an extra environment beyond its limit. + if (error?.code !== 'roxy_profile_busy') throw error; + } + } + if (!resolved) throw new RoxyBrowserError('roxy_profile_busy'); + if (findChild(child.id) !== child || state.settings.integrations.roxy !== current) throw new RoxyBrowserError('roxy_profile_conflict'); + const slot = execution.slot; + current.poolProfiles = [...(current.poolProfiles || []).filter((entry) => entry.slot !== slot && entry.dirId !== resolved.dirId), { slot, dirId: resolved.dirId }]; + execution.profile = { dirId: resolved.dirId, ...(child.roxyProxyId ? { proxyId: child.roxyProxyId } : {}) }; + await persist(); + onProgress('roxy_pool_acquired', '已分配空闲环境,本次账号使用隔离登录会话'); + }); + roxyProfileProvisioning = pending; + return pending; +} + +async function releaseSavedRoxyProfile(child, config, dirId, onProgress = () => {}, { failed = false } = {}) { + const pending = roxyProfileProvisioning.catch(() => {}).then(async () => { + const current = state.settings.integrations?.roxy; + if (current !== config || findChild(child.id) !== child) throw new RoxyBrowserError('roxy_profile_conflict'); + // One failed close still blocks new browser starts, but must not prevent + // another independently closed account from releasing its saved slot. + await verifyRoxyCleanup({ baseUrl: config.baseUrl, dirId }); + onProgress(failed ? 'roxy_failed_profile_deleting' : 'roxy_profile_deleting', failed + ? '本次授权失败,正在确认窗口关闭并释放 Roxy 环境名额' : 'JSON 已保存,正在关闭并释放 Roxy 环境名额'); + const result = await deleteClosedRoxyProfile(config, dirId); + // The proxy remains attached to the account for quota/RT requests after + // its browser environment has moved to the recoverable recycle bin. + for (const binding of current.profiles.filter((entry) => entry.dirId === dirId)) { + const account = findChild(binding.childId); + if (account && binding.proxyId && (current.profileMode !== 'pooled' || !account.roxyProxyId)) account.roxyProxyId = binding.proxyId; + } + current.profiles = current.profiles.filter((entry) => entry.dirId !== dirId); + current.poolProfiles = (current.poolProfiles || []).filter((entry) => entry.dirId !== dirId); + addHistory('释放 Roxy 环境', failed + ? `${child.email} 本次授权失败,授权环境已移入 Roxy 回收站,可在运行日志中重试` + : `${child.email} 的授权 JSON 已保留,授权环境已移入 Roxy 回收站`); + await persist(); + onProgress(failed ? 'roxy_failed_profile_deleted' : 'roxy_profile_deleted', failed + ? '失败环境已移入 Roxy 回收站,窗口用量已释放,可在运行日志中重试' + : '授权环境已移入 Roxy 回收站,窗口用量已释放,本机 JSON 保留'); + return { deleted: result.deleted, released: true }; + }); + roxyProfileProvisioning = pending; + return pending; +} + +async function runChildBrowserAuthorization(child, { browserLogin, callbackUrl, onProgress }, operation, finalizeLogin = (result) => result) { try { // Called only after cached AT/RT paths and credential checks. A completed // manual callback never needs a Roxy window or consumes an environment slot. if (browserLogin && !callbackUrl && state.settings.integrations?.roxy?.enabled && findChild(child.id) === child) { - await ensureChildRoxyProfile(child, onProgress); + onProgress('roxy_queued', '等待空闲 Roxy 窗口,前一个窗口释放后继续'); + // Historical cleanup holds several consecutive profile deletions. A + // newly queued login must not lease a profile between those deletions. + if (roxyProfileCleanupJobs) await roxyProfileCleanupFinished; + return await runRoxyQueued(() => roxyExecution.run({}, async () => { + const execution = roxyExecution.getStore(); + let result; + let failureError; + try { + try { + await verifyRoxyCleanup(); + if (state.settings.integrations.roxy.profileMode === 'pooled') await prepareRoxyPoolAccount(child, execution, onProgress); + else await ensureChildRoxyProfile(child, execution, onProgress); + result = await withChildAuthorizationNetwork(child, browserLogin, async () => + finalizeLogin(await operation(automaticBrowserOptions(child, browserLogin)))); + } catch (error) { + if (!(error instanceof RoxyBrowserError)) throw error; + result = await finalizeLogin({ ok: false, status: error.status, code: error.code, message: error.message, browserRequired: true, needsInput: true }); + } + return result; + } catch (error) { + failureError = error; + throw error; + } finally { + try { + const owned = execution.openedProfile || execution.preparedProfile; + const token = result?.tokenScope === 'team' ? workspaceTokenFor(child, result.accountId) : child; + // The account wrapper later reports incomplete credentials as + // json_incomplete even if the OAuth exchange itself returned ok. + const failed = result?.ok !== true || result.exportable && (!token?.accessToken || !token?.refreshToken); + const releaseSuccess = !failed && result.exportable && token?.accessToken && token?.refreshToken + && execution.openedProfile?.config.deleteProfileAfterSuccess === true; + if (releaseSuccess || failed && owned?.config.deleteProfileAfterFailure === true) { + let cleanup; + try { cleanup = await releaseSavedRoxyProfile(child, owned.config, owned.dirId, onProgress, { failed }); } + catch { + // Cleanup is secondary: keep the saved JSON or original + // authorization failure, never rerun OAuth or invent release. + cleanup = { deleted: false, released: false, code: 'roxy_profile_delete_failed' }; + onProgress(failed ? 'roxy_failed_profile_delete_failed' : 'roxy_profile_delete_failed', failed + ? '授权失败且环境名额未释放,请检查 Roxy 窗口状态后再重试' + : 'JSON 已保存,但环境名额未释放,请检查 Roxy 回收站或重试清理'); + addHistory('释放 Roxy 环境', failed + ? `${child.email} 本次授权失败,且 Roxy 环境名额未能释放` + : `${child.email} 的 JSON 已保存,但 Roxy 环境名额未能释放`, 'partial'); + await persist().catch(() => {}); + } + if (result) result.roxyCleanup = cleanup; + else if (failureError && typeof failureError === 'object') roxyAuthorizationFailureCleanup.set(failureError, cleanup); + } + } finally { + if (execution.slot !== undefined) activeRoxyPoolSlots.delete(execution.slot); + } + } + })); } - return await withChildAuthorizationNetwork(child, browserLogin, () => operation(automaticBrowserOptions(child, browserLogin))); + return await withChildAuthorizationNetwork(child, browserLogin, async () => finalizeLogin(await operation(automaticBrowserOptions(child, browserLogin)))); } catch (error) { if (!(error instanceof RoxyBrowserError)) throw error; - return { ok: false, status: error.status, code: error.code, message: error.message, browserRequired: true, needsInput: true }; + return finalizeLogin({ ok: false, status: error.status, code: error.code, message: error.message, browserRequired: true, needsInput: true }); } } function bindRoxyProxyProfiles(config, proxy = state.settings.proxy) { - const entries = Array.isArray(proxy?.entries) ? proxy.entries.filter((entry) => entry?.url) : []; + const entries = Array.isArray(proxy?.entries) ? proxy.entries.filter((entry) => entry?.url && entry.enabled !== false) : []; + if (config.profileMode === 'pooled') { + if (config.enabled && config.useProxyPool) { + if (!proxy?.enabled) throw new RoxyBrowserError('roxy_proxy_pool_disabled', { status: 400 }); + if (!entries.length) throw new RoxyBrowserError('roxy_proxy_missing', { status: 400 }); + } + return config; + } if (config.profiles.some((profile) => profile.proxyId && !entries.some((entry) => entry.id === profile.proxyId))) { throw new RoxyBrowserError('roxy_proxy_missing', { status: 400 }); } @@ -3929,7 +4095,8 @@ function bindRoxyProxyProfiles(config, proxy = state.settings.proxy) { roxyProxyInfo(entries.find((entry) => entry.id === profile.proxyId)); continue; } - const selected = entries.reduce((best, entry) => counts.get(entry.id) < counts.get(best.id) ? entry : best, entries[0]); + const retained = entries.find((entry) => entry.id === findChild(profile.childId)?.roxyProxyId); + const selected = retained || entries.reduce((best, entry) => counts.get(entry.id) < counts.get(best.id) ? entry : best, entries[0]); roxyProxyInfo(selected); profile.proxyId = selected.id; counts.set(selected.id, counts.get(selected.id) + 1); @@ -3941,7 +4108,9 @@ async function withChildAuthorizationNetwork(child, browserLogin, operation) { const current = state.settings.integrations?.roxy; if (authorizationNetwork.getStore()?.childId === child.id) return operation(); if (browserLogin === false || !current?.enabled || !current.useProxyPool) return operation(); - const profile = current.profiles.find((entry) => entry.childId === child.id); + const pooled = current.profileMode === 'pooled'; + const profile = pooled ? roxyExecution.getStore()?.profile || (child.roxyProxyId ? { proxyId: child.roxyProxyId } : null) + : current.profiles.find((entry) => entry.childId === child.id) || (child.roxyProxyId ? { proxyId: child.roxyProxyId } : null); // Cached credentials/callbacks remain usable without a browser mapping. // A new profile is provisioned only when browser login is actually needed. if (!profile) return operation(); @@ -3949,7 +4118,8 @@ async function withChildAuthorizationNetwork(child, browserLogin, operation) { let config; try { config = bindRoxyProxyProfiles(structuredClone(current)); - proxyEntry = structuredClone(state.settings.proxy.entries.find((entry) => entry.id === config.profiles.find((entry) => entry.childId === child.id).proxyId)); + proxyEntry = structuredClone(state.settings.proxy.entries.find((entry) => entry.id === (pooled ? child.roxyProxyId : config.profiles.find((entry) => entry.childId === child.id)?.proxyId || child.roxyProxyId))); + if (!proxyEntry || proxyEntry.enabled === false) throw new RoxyBrowserError('roxy_proxy_missing'); } catch (error) { const safe = error instanceof RoxyBrowserError ? error : new RoxyBrowserError('roxy_proxy_missing'); return { ok: false, status: safe.status, code: safe.code, message: safe.message, needsInput: true, child: publicChild(child) }; @@ -3957,27 +4127,62 @@ async function withChildAuthorizationNetwork(child, browserLogin, operation) { const proxySettings = { ...structuredClone(state.settings.proxy), enabled: true, strategy: 'failover', maxRetries: 0, entries: [proxyEntry] }; // Reserve before awaiting persistence so deletion cannot race startup. - activeAuthorizationProxies.set(proxyEntry.id, (activeAuthorizationProxies.get(proxyEntry.id) || 0) + 1); + const network = { childId: child.id, config, proxyEntry, proxySettings, reservedProxyIds: new Set() }; + reserveAuthorizationProxy(network, proxyEntry.id); try { if (!profile.proxyId) { profile.proxyId = proxyEntry.id; await persist(); } - return await authorizationNetwork.run({ childId: child.id, config, proxyEntry, proxySettings }, operation); + return await authorizationNetwork.run(network, operation); } finally { - const remaining = activeAuthorizationProxies.get(proxyEntry.id) - 1; - if (remaining) activeAuthorizationProxies.set(proxyEntry.id, remaining); - else activeAuthorizationProxies.delete(proxyEntry.id); + for (const id of network.reservedProxyIds) { + const remaining = activeAuthorizationProxies.get(id) - 1; + if (remaining > 0) activeAuthorizationProxies.set(id, remaining); + else activeAuthorizationProxies.delete(id); + } } } +function reserveAuthorizationProxy(network, id) { + if (network.reservedProxyIds.has(id)) return; + network.reservedProxyIds.add(id); + activeAuthorizationProxies.set(id, (activeAuthorizationProxies.get(id) || 0) + 1); +} + +async function switchRoxyAuthorizationProxy(child, network, attempted) { + const current = state.settings.integrations?.roxy; + if (network?.childId !== child.id || !current?.enabled || !current.useProxyPool + || !state.settings.proxy.enabled || findChild(child.id) !== child || attempted.size >= 3) return false; + const entries = state.settings.proxy.entries.filter((entry) => entry?.url && entry.enabled !== false); + const index = entries.findIndex((entry) => entry.id === network.proxyEntry.id); + const ordered = [...entries.slice(index + 1), ...entries.slice(0, index + 1)]; + const selected = ordered.find((entry) => !attempted.has(entry.id)); + if (!selected) return false; + roxyProxyInfo(selected); + const pooled = current.profileMode === 'pooled'; + const profile = pooled ? roxyExecution.getStore()?.profile : current.profiles.find((entry) => entry.childId === child.id); + if (!profile) throw new RoxyBrowserError('roxy_profile_missing'); + reserveAuthorizationProxy(network, selected.id); + profile.proxyId = selected.id; + child.roxyProxyId = selected.id; + // Mutate the existing per-account context: the OAuth code exchange and later + // requests in the outer authorization operation must use the winning route. + network.config = structuredClone(current); + network.proxyEntry = structuredClone(selected); + network.proxySettings = { ...structuredClone(state.settings.proxy), enabled: true, + strategy: 'failover', maxRetries: 0, entries: [network.proxyEntry] }; + await persist(); + return true; +} + function childLoginConfiguration(child) { const roxy = state.settings.integrations?.roxy; if (!roxy?.enabled) return 'protocol'; const profile = roxy.profiles.find((entry) => entry.childId === child.id); - const proxy = roxy.useProxyPool ? state.settings.proxy?.entries?.find((entry) => entry.id === profile?.proxyId) : null; + const proxy = roxy.useProxyPool ? state.settings.proxy?.entries?.find((entry) => entry.id === (roxy.profileMode === 'pooled' ? child.roxyProxyId : profile?.proxyId || child.roxyProxyId)) : null; return createHash('sha256').update(JSON.stringify({ baseUrl: roxy.baseUrl, workspaceId: roxy.workspaceId, - dirId: profile?.dirId, headless: roxy.headless === true, useProxyPool: roxy.useProxyPool === true, + profileMode: roxy.profileMode || 'dedicated', dirId: roxy.profileMode === 'pooled' ? undefined : profile?.dirId, headless: roxy.headless === true, useProxyPool: roxy.useProxyPool === true, proxyId: proxy?.id, proxyUrl: proxy?.url, localProxyEnabled: state.settings.proxy?.localProxyEnabled === true, localProxyUrl: state.settings.proxy?.localProxyEnabled ? state.settings.proxy.localProxyUrl : '', @@ -4002,13 +4207,20 @@ function automaticBrowserOptions(child, browserLogin) { // Roxy, the original protocol login remains the default authorization path. if (!browserLogin || state.settings.integrations?.roxy?.enabled !== true) return { browserLogin: false, protocolOnly: true }; const network = authorizationNetwork.getStore(); - const config = network?.childId === child.id ? network.config : structuredClone(state.settings.integrations.roxy); return { browserLogin: true, protocolOnly: false, - browserOAuth: (options) => roxyBrowserQueue.run(async () => { + allowLocalAuthorizationFallback: true, + browserOAuth: (options) => runRoxyQueued(async () => { + const execution = autoJsonExecution.getStore() || roxyExecution.getStore() || {}; + const attempted = execution.proxyAttempts ||= new Set(); + const attempt = async () => { + const config = network?.childId === child.id ? { ...network.config, + ...(network.config.profileMode === 'pooled' + ? { poolProfiles: structuredClone(state.settings.integrations.roxy.poolProfiles || []) } + : { profiles: structuredClone(state.settings.integrations.roxy.profiles || []) }) } + : structuredClone(state.settings.integrations.roxy); await verifyRoxyCleanup(); - const execution = autoJsonExecution.getStore(); if (findChild(child.id) !== child || child.email !== options.email) { throw Object.assign(new Error('账号已变更,未启动浏览器'), { code: 'token_identity_mismatch', status: 409 }); } @@ -4026,9 +4238,15 @@ function automaticBrowserOptions(child, browserLogin) { ...options, acquireSession: ({ timeoutMs }) => { pendingAcquisition = (async () => { - const dirId = config.profiles.find((profile) => profile.childId === child.id)?.dirId; + const poolProfile = roxyExecution.getStore()?.profile; + const dirId = poolProfile?.dirId || config.profiles.find((profile) => profile.childId === child.id)?.dirId; let session; try { session = await openRoxyBrowserSession({ config, childId: child.id, timeoutMs, + ...(poolProfile ? { profile: poolProfile } : {}), + onProfileAcquired: (acquiredDirId) => { + const lease = roxyExecution.getStore(); + if (lease) lease.preparedProfile = { config: state.settings.integrations.roxy, dirId: acquiredDirId }; + }, proxyEntry: network?.childId === child.id ? network.proxyEntry : null, localProxyUrl: network?.childId === child.id && network.proxySettings?.localProxyEnabled ? network.proxySettings.localProxyUrl : '' }); } @@ -4036,7 +4254,10 @@ function automaticBrowserOptions(child, browserLogin) { if (dirId && error?.code === 'roxy_cleanup_failed') roxyCleanupBlocks.set(`${config.baseUrl}:${dirId}`, { config, dirId }); throw error; } - const closeFailedWindow = autoJsonExecution.getStore()?.closeFailedWindow === true; + const lease = roxyExecution.getStore(); + if (lease) lease.openedProfile = { config: state.settings.integrations.roxy, dirId }; + const closeFailedWindow = config.deleteProfileAfterFailure === true || config.profileMode === 'pooled' + || config.useProxyPool || autoJsonExecution.getStore()?.closeFailedWindow === true; const owned = { ...session, keepOpenOnFailure: !closeFailedWindow && session.keepOpenOnFailure, close: (closeOptions) => pendingClose ||= (async () => { try { await session.close(closeFailedWindow || oauthFinished ? { keepOpen: false } : closeOptions); } @@ -4057,6 +4278,29 @@ function automaticBrowserOptions(child, browserLogin) { await pendingAcquisition?.catch(() => {}); await pendingClose?.catch(() => {}); } + }; + for (;;) { + if (network?.childId === child.id && network.proxyEntry) { + attempted.add(network.proxyEntry.id); + options.onProgress?.('roxy_proxy_attempt', `正在使用第 ${attempted.size}/3 条代理授权,账号会话独立`); + } + try { return await attempt(); } + catch (error) { + // Retry only a diagnosed proxy/tunnel connection failure. Official + // rejection, credential errors and Roxy API failures keep their cause. + if (error?.code !== 'browser_oauth_proxy_connection_failed' || !network?.proxyEntry) throw error; + await verifyRoxyCleanup(); + if (!await switchRoxyAuthorizationProxy(child, network, attempted)) { + options.onProgress?.('roxy_proxy_exhausted', `已尝试 ${attempted.size} 条代理,本账号停止重试;批量任务继续下一个账号`); + throw error; + } + if (options.workspaceMode === 'team') { + child.teamLoginConfigurations ||= {}; + child.teamLoginConfigurations[options.workspaceId] = childLoginConfiguration(child); + } else child.loginConfiguration = childLoginConfiguration(child); + options.onProgress?.('roxy_proxy_switch', `代理连接失败,旧窗口已关闭,切换第 ${attempted.size + 1}/3 条代理`); + } + } }), }; } @@ -4167,7 +4411,7 @@ async function acquireChildAuthInternal(child, { refresh = false, verificationCo const activeOAuthAdapter = authSession?.state && authSession?.codeVerifier && !authSession?.authorization ? null : oauthAdapter; - const login = await runChildBrowserAuthorization(child, { browserLogin, + return runChildBrowserAuthorization(child, { browserLogin, callbackUrl: completedCallback || pendingOAuthCallback(authSession), onProgress }, async (browserOptions) => { if (!completedCallback) { child.loginBackend = browserOptions.browserLogin ? 'roxy' : 'protocol'; @@ -4195,7 +4439,7 @@ async function acquireChildAuthInternal(child, { refresh = false, verificationCo }, ...(activeOAuthAdapter || {}), }); - }); + }, async (login) => { if (login.ok) { const needsPostSync = !login.sub2api && sub2apiConfig?.enabled === true; if (needsPostSync) { @@ -4242,6 +4486,7 @@ async function acquireChildAuthInternal(child, { refresh = false, verificationCo addHistory('登录 Free 账号', `${child.email} ${message}`, 'partial'); await persist(); return { ok: false, status: login.status || 202, upstreamStatus: login.upstreamStatus || null, code: loginBanMessage ? 'account_banned' : login.code || (browserRequired ? 'verification_required' : 'login_failed'), message: loginBanMessage || message, banned: Boolean(loginBanMessage), stage: login.stage, browserRequired, needsInput: login.needsInput, authUrl: login.authUrl || null, child: publicChild(child) }; + }); } async function acquireTeamAuth(mother, child, options = {}) { @@ -4315,7 +4560,7 @@ async function acquireTeamAuthInternal(mother, child, { verificationCode = '', c child.teamLoginStatus = 'authenticating'; child.teamLoginMessage = completedCallback ? '正在处理已完成的 Team 浏览器授权' : `正在通过 OAuth 登录并选择 Team ${workspaceId}`; setTeamLoginState(child, workspaceId, { status: 'authenticating', message: child.teamLoginMessage }); - const login = await runChildBrowserAuthorization(child, { browserLogin, + return runChildBrowserAuthorization(child, { browserLogin, callbackUrl: completedCallback || pendingOAuthCallback(compatibleSession), onProgress }, async (browserOptions) => { if (!completedCallback) { if (!child.teamLoginBackends || typeof child.teamLoginBackends !== 'object' || Array.isArray(child.teamLoginBackends)) child.teamLoginBackends = {}; @@ -4348,7 +4593,7 @@ async function acquireTeamAuthInternal(mother, child, { verificationCode = '', c }, ...(activeOAuthAdapter || {}), }); - }); + }, async (login) => { // The browser intentionally runs outside the Team maintenance lock. Never // persist a callback against a deleted/replaced account or changed workspace. if (trackedMother && findMother(mother.id) !== mother || String(mother.accountId || '').trim() !== workspaceId) { @@ -4478,6 +4723,7 @@ async function acquireTeamAuthInternal(mother, child, { verificationCode = '', c addHistory('生成 Team JSON', `${child.email} 已通过 OAuth 登录并选择 ${mother.team}${fallbackMessage}${syncMessage}`, syncPartial ? 'partial' : 'success'); await persist(); return { ok: true, status: 200, code: 'ready', source: login.oauthFallback ? 'oauth_email_password_2fa_team' : login.sub2api ? 'sub2api_oauth_email_password_2fa_team' : 'oauth_email_password_2fa_team', format: 'team-json', tokenScope: 'team', accountId: workspaceId, expiresAt: workspaceToken.expiresAt, partial: syncPartial, oauthFallback: login.oauthFallback || null, sub2api: login.sub2api || null, exportable: true, child: publicChild(child, mother.team) }; + }); } function hasTeamLoginCredentials(child) { @@ -5500,6 +5746,7 @@ const AUTO_JSON_MAX_LOGS = 3000; const AUTO_JSON_PROGRESS_MESSAGES = { initializing: '正在建立登录会话', oauth_link: '正在获取 OAuth 授权入口', + oauth_local_fallback: '授权链接服务暂不可用,改用本机 OAuth 入口继续本次登录', authenticating: '正在后台校验账号登录', protocol_email: '正在通过协议提交账号邮箱', protocol_password: '正在通过协议校验账号密码', @@ -5514,14 +5761,32 @@ const AUTO_JSON_PROGRESS_MESSAGES = { browser_verification_required: '登录要求浏览器验证,自动流程已暂停', browser_start: '正在启动独立浏览器,打开官方授权页面', roxy_start: '正在打开账号绑定的 Roxy 窗口', - roxy_profile_preparing: '正在为该账号查找或创建独立 Roxy 窗口', + roxy_queued: '等待空闲 Roxy 窗口,前一个窗口释放后继续', + roxy_pool_preparing: '正在分配空闲 Roxy 环境', + roxy_pool_acquired: '已分配空闲环境,本次账号使用隔离登录会话', + roxy_profile_preparing: '正在核验账号绑定的 Roxy 环境,失效时重新分配', roxy_profile_bound: '已保存该账号的独立 Roxy 窗口绑定', + roxy_profile_deleting: 'JSON 已保存,正在确认 Roxy 窗口关闭并释放环境名额', + roxy_profile_deleted: '授权环境已移入 Roxy 回收站,窗口用量已释放,本机 JSON 保留', + roxy_profile_delete_failed: 'JSON 已保存,但 Roxy 环境名额未释放,请检查 Roxy 后重试清理', + roxy_failed_profile_deleting: '本次授权失败,正在确认 Roxy 窗口关闭并释放环境名额', + roxy_failed_profile_deleted: '失败环境已移入 Roxy 回收站,窗口用量已释放,可在运行日志中重试', + roxy_failed_profile_delete_failed: '本次授权失败,且 Roxy 环境名额未释放,请检查窗口状态后再重试', + roxy_proxy_attempt: '正在使用代理授权,每账号最多尝试 3 条', + roxy_proxy_switch: '代理连接失败,已确认旧窗口关闭,正在切换下一条代理', + roxy_proxy_exhausted: '可用代理尝试已结束,本账号停止重试,批量任务继续下一个', roxy_cleanup_failed: 'Roxy 窗口关闭未完成,请在 Roxy 中检查并关闭本次窗口', roxy_window_left_open: '自动登录已暂停,有头 Roxy 窗口已保留;请查看页面提示,关闭窗口后再重试', browser_email: '正在提交账号邮箱', browser_password: '正在提交账号密码', browser_totp: '正在提交当前六位 2FA 验证码', browser_workspace: '正在选择目标授权工作空间', + browser_workspace_wait: '正在等待目标 Team 工作空间加载', + browser_navigation: '已连接 Roxy 浏览器,正在打开官方授权页面', + browser_oauth_proxy_connection_failed: '浏览器代理连接失败,请检查本机代理和上游代理', + browser_oauth_network_failed: '官方授权页面网络连接失败,本账号已停止', + browser_oauth_browser_disconnected: '授权过程中浏览器连接已断开,本账号已停止', + browser_oauth_navigation_timeout: '打开官方授权页面超时,本账号已停止', browser_callback: '已收到授权回调,正在兑换并保存 JSON', browser_http_forbidden: '官方登录页面或认证接口返回 HTTP 403,自动流程已暂停', }; @@ -5536,9 +5801,15 @@ function appendAutoJsonLog(job, child, stage, status, message, httpStatus = null } function autoJsonLoginProgress(job, child, stage) { - return (phase) => { - const message = Object.hasOwn(AUTO_JSON_PROGRESS_MESSAGES, phase) ? AUTO_JSON_PROGRESS_MESSAGES[phase] : null; - if (message) appendAutoJsonLog(job, child, stage, phase === 'roxy_cleanup_failed' ? 'warning' : ['phone_verification_required', 'browser_verification_required'].includes(phase) ? 'needs_attention' : 'running', message); + return (phase, detail) => { + let message = Object.hasOwn(AUTO_JSON_PROGRESS_MESSAGES, phase) ? AUTO_JSON_PROGRESS_MESSAGES[phase] : null; + if (['roxy_proxy_attempt', 'roxy_proxy_switch', 'roxy_proxy_exhausted'].includes(phase)) { + const count = String(detail || '').match(/^(?:正在使用第 |代理连接失败,旧窗口已关闭,切换第 |已尝试 )([1-3])(?:\/3 条代理| 条代理)/)?.[1]; + // Only a bounded counter enters the public log, never arbitrary detail + // from an upstream exception, authorization URL or proxy credential. + if (count) message += `(${count}/3)`; + } + if (message) appendAutoJsonLog(job, child, stage, ['roxy_cleanup_failed', 'roxy_proxy_exhausted', 'roxy_profile_delete_failed', 'roxy_failed_profile_delete_failed'].includes(phase) ? 'warning' : ['phone_verification_required', 'browser_verification_required'].includes(phase) ? 'needs_attention' : 'running', message); }; } @@ -5558,6 +5829,7 @@ function publicAutoJsonJob(job) { motherId: job.motherId || null, workspaceId: job.workspaceId || null, retryJobId: job.retryJobId || null, + retryAccount: Boolean(job.retryAccount), retriedIds: [...(job.retriedIds || [])], status: job.status, total: job.ids.length, @@ -5611,7 +5883,12 @@ function autoJsonResult(child, acquired, { mother = null, phase = 'free_auth', a password_invalid: '登录密码校验失败,请检查账号凭据', browser_oauth_credentials_rejected: '官方提示邮箱、密码或验证码不正确,请核对该账号保存的凭据', browser_oauth_input_mismatch: '浏览器输入与已保存凭据不一致,已停止提交,请检查页面输入控件', - browser_oauth_timeout: '授权等待超时;有头 Roxy 窗口会保留,请检查页面提示', + browser_oauth_timeout: '授权等待超时,本账号已停止;可检查网络或页面提示后勾选重试', + browser_oauth_proxy_connection_failed: '浏览器代理连接失败,请检查本机代理和上游代理后勾选重试', + browser_oauth_network_failed: '官方授权页面网络连接失败,请检查网络后勾选重试', + browser_oauth_browser_disconnected: '授权过程中浏览器连接已断开,请检查 Roxy 后勾选重试', + browser_oauth_navigation_timeout: '打开官方授权页面超时,请检查网络后勾选重试', + browser_oauth_failed: '浏览器授权未完成,请根据阶段日志检查后勾选重试', oauth_session_expired: '原授权会话已过期,请通过手动获取重新授权', account_banned: '账号已封禁,无法获取 JSON', token_identity_mismatch: '授权账号身份不一致,已保留原凭据', @@ -5623,6 +5900,8 @@ function autoJsonResult(child, acquired, { mother = null, phase = 'free_auth', a member_verification_failed: '无法确认目标 Team 成员身份,请检查母号权限', team_workspace_changed: '目标 Team 配置已改变,请重新选择 Team', team_membership_changed: '账号已移出目标 Team 或成员状态已变更,请重新核验后授权', + team_token_changed: 'Team 授权已更新,请重新获取当前状态', + team_token_refresh_failed: '已有 Team JSON 的令牌刷新失败,已保留原凭据;请检查网络或通过手动获取重新授权', seat_capacity_unavailable: '无法确认已购席位,未接受邀请', seat_capacity_exhausted: '目标 Team 没有可用的已购席位,未接受邀请', seat_type_mismatch: '成员实际席位与预期席位不一致,已停止后续 Team 授权,请核实 Team 默认席位设置', @@ -5648,6 +5927,7 @@ function autoJsonResult(child, acquired, { mother = null, phase = 'free_auth', a httpStatus: ready ? null : Number(acquired.upstreamStatus || acquired.status || login.httpStatus) || null, source: acquired.source || null, reused: ready && acquired.source === 'stored_team_token', + ...(acquired.roxyCleanup ? { roxyCleanup: acquired.roxyCleanup } : {}), browserRequired: !ready && Boolean(acquired.browserRequired ?? login.browserRequired), authUrl: attention ? safeAutoJsonAuthUrl(acquired.authUrl || login.authUrl) : null, }; @@ -5691,8 +5971,8 @@ async function performAutoFreeJson(id, onProgress = () => {}, { retryFailed = fa }); if (findChild(id) !== child) return autoJsonResult(null, { id, ok: false, code: 'child_not_found' }); return autoJsonResult(child, result); - } catch { - return autoJsonResult(child, { ok: false, code: 'auto_json_failed' }); + } catch (error) { + return autoJsonResult(child, { ok: false, code: 'auto_json_failed', roxyCleanup: roxyAuthorizationFailureCleanup.get(error) }); } } @@ -5716,6 +5996,68 @@ function storedAutoTeamJsonResult(job, child, mother) { }; } +async function refreshStoredAutoTeamJson(job, child, mother) { + const latestMembership = latestMembershipHistoryFor(child, mother.team); + if (!isChildMemberOfTeam(child, mother.team) || (latestMembership && latestMembership.status !== 'active') + || !completeTeamJsonFor(child, mother, { allowExpired: true })) return null; + const workspaceId = job.workspaceId; + const email = child.email; + const userId = child.chatgptUserId; + const membership = membershipFor(child, mother.team); + const team = mother.team; + const fail = (code, status = null) => autoJsonResult(child, { ok: false, code, status }, { mother, phase: 'team_auth' }); + const currentFailure = () => { + if (findChild(child.id) !== child || child.email !== email || child.chatgptUserId !== userId) return fail('token_identity_mismatch'); + if (findMother(mother.id) !== mother || configuredTeamId(mother) !== workspaceId || mother.team !== team) return fail('team_workspace_changed'); + if (membershipFor(child, team) !== membership || !isChildMemberOfTeam(child, team) + || latestMembershipHistoryFor(child, team) !== latestMembership) return fail('team_membership_changed'); + return childIsBanned(child) ? fail('account_banned') : null; + }; + const key = String(child.id); + const previous = childAuthQueues.get(key) || Promise.resolve(); + const current = previous.catch(() => {}).then(async () => { + if (currentFailure()) return currentFailure(); + const ready = storedAutoTeamJsonResult(job, child, mother); + if (ready) return ready; + const token = completeTeamJsonFor(child, mother, { allowExpired: true }); + if (!token) return fail('team_token_changed'); + const beforeAccessToken = token.accessToken; + const beforeRefreshToken = token.refreshToken; + appendAutoJsonLog(job, child, 'team_auth', 'running', '已有 Team JSON,正在刷新到期令牌,无需重复浏览器登录'); + try { + const result = await withChildAuthorizationNetwork(child, true, async () => { + if (currentFailure()) return currentFailure(); + const refreshed = await refreshOpenAiAccessToken(beforeRefreshToken, token.clientId, OPENAI_REQUEST_TIMEOUT_MS, proxyFetch); + if (currentFailure()) return currentFailure(); + const latest = child.workspaceTokens?.[workspaceId]; + if (latest?.accessToken !== beforeAccessToken || latest?.refreshToken !== beforeRefreshToken) return fail('team_token_changed'); + const candidate = { ...token, accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken || beforeRefreshToken }; + if (!refreshed.ok || !completeTeamJsonFor({ ...child, workspaceTokens: { [workspaceId]: candidate } }, mother)) { + return fail('team_token_refresh_failed', refreshed.status); + } + const saved = saveWorkspaceToken(child, workspaceId, refreshed.accessToken, accessTokenClaims(refreshed.accessToken), { + refreshToken: candidate.refreshToken, idToken: refreshed.idToken || token.idToken || '', + clientId: token.clientId || '', source: 'team_refresh_token', + }); + resetTeamLoginState(child, workspaceId, '已有 Team JSON 的令牌已刷新'); + if (membership) Object.assign(membership, { workspaceTokenStatus: 'ready', workspaceTokenUpdatedAt: saved.acquiredAt, + teamAuthLastError: null, teamAuthRetryAfter: null }); + await persist(); + if (currentFailure()) return currentFailure(); + const stored = storedAutoTeamJsonResult(job, child, mother); + return stored ? { ...stored, refreshed: true, message: '已有 Team JSON 的令牌已刷新,已跳过重复登录' } : fail('team_token_changed'); + }); + return result?.id === child.id ? result : fail('team_token_refresh_failed'); + } catch { + return fail('team_token_refresh_failed'); + } + }); + childAuthQueues.set(key, current); + try { return await current; } finally { + if (childAuthQueues.get(key) === current) childAuthQueues.delete(key); + } +} + async function autoAcquireTeamJson(job, id) { return withAutoJsonAccountWork(id, `team:${job.workspaceId}`, async () => { const child = findChild(id); @@ -5729,6 +6071,12 @@ async function autoAcquireTeamJson(job, id) { // member APIs, token refresh or browser login. Quota has its own refresh UI. const storedResult = storedAutoTeamJsonResult(job, child, mother); if (storedResult) return storedResult; + // A completed browser callback can replace an invalid old RT. Consume it + // through the existing verified callback path instead of retrying that RT. + if (!pendingOAuthCallback(child.teamAuthSessions?.[job.workspaceId])) { + const refreshedResult = await refreshStoredAutoTeamJson(job, child, mother); + if (refreshedResult) return refreshedResult; + } const email = child.email; let confirmedMembership = null; const currentFailure = () => { @@ -5818,8 +6166,8 @@ async function autoAcquireTeamJson(job, id) { let membership = await synchronizeMembership(false); if (membership.result) return membership.result; if (membership.needsFree) { - appendAutoJsonLog(job, child, 'free_auth', 'running', '正在准备接受邀请所需的子号授权'); - const free = await performAutoFreeJson(id, autoJsonLoginProgress(job, child, 'free_auth'), { retryFailed: job.retryFailed }); + appendAutoJsonLog(job, child, 'free_auth', 'running', '先获取接受邀请所需的个人授权;加入确认后会继续获取目标 Team JSON'); + const free = await performAutoFreeJson(id, autoJsonLoginProgress(job, child, 'free_auth'), { retryFailed: job.retryFailed || job.retryAccount }); if (currentFailure()) return currentFailure(); if (free.status !== 'ready') return { ...free, scope: 'team', authScope: 'free', phase: 'free_auth', motherId: mother.id, workspaceId: job.workspaceId, quotaChecked: false, quotaStatus: 'unknown', quota5h: null, quota7d: null }; appendAutoJsonLog(job, child, 'free_auth', 'ready', '子号授权已就绪'); @@ -5841,7 +6189,7 @@ async function autoAcquireTeamJson(job, id) { if (callback) acquired = await acquireTeamAuth(mother, child, { force: true, callbackUrl: callback, persistOwner: false, browserLogin: true, expectedMembership: confirmedMembership, onProgress }); else { const login = teamLoginFor(child, job.workspaceId); - if (!job.retryFailed && (login?.needsInput || login?.browserRequired) && login.code !== 'workspace_list_unavailable' + if (!job.retryFailed && !job.retryAccount && (login?.needsInput || login?.browserRequired) && login.code !== 'workspace_list_unavailable' && !String(login.code || '').startsWith('roxy_') && !retryWithChangedLoginBackend(child, job.workspaceId)) acquired = { ok: false, code: login.code || login.status, stage: login.stage, status: login.httpStatus, needsInput: true, browserRequired: login.browserRequired, authUrl: login.authUrl }; else acquired = await acquireTeamAuth(mother, child, { force: true, persistOwner: false, browserLogin: true, expectedMembership: confirmedMembership, onProgress }); @@ -5855,7 +6203,7 @@ async function autoAcquireTeamJson(job, id) { appendAutoJsonLog(job, child, 'quota', 'running', '正在使用 Team 授权查询真实额度'); // A quota transport failure must never turn a saved JSON into an auth // failure (and invite an unnecessary second browser login). - const quota = await probeUsage(teamToken.accessToken, job.workspaceId) + const quota = await withChildAuthorizationNetwork(child, true, () => probeUsage(teamToken.accessToken, job.workspaceId)) .catch(() => ({ ok: false, status: 502, message: 'quota_query_failed' })); return withTeamMaintenanceLock(job.motherId, 'auto_team_json_quota', async () => { if (currentFailure()) return currentFailure(); @@ -5877,8 +6225,8 @@ async function autoAcquireTeamJson(job, id) { quotaCode: 'quota_save_failed', quotaHttpStatus: Number(quota.status) || null, message: 'Team JSON 已保存,额度处理或保存失败,请重新检测额度' }; }); - } catch { - return autoJsonResult(child, { ok: false, code: 'auto_json_failed' }, context); + } catch (error) { + return autoJsonResult(child, { ok: false, code: 'auto_json_failed', roxyCleanup: roxyAuthorizationFailureCleanup.get(error) }, context); } }); } @@ -5926,10 +6274,19 @@ function startAutoJsonJob(body) { return { status: 400, payload: { message: 'child_ids_required' } }; } const ids = body.ids === undefined - ? state.children.filter((child) => !childIsBanned(child) && (mother ? invitationForTeam(child, mother)?.status === 'invited' || isChildMemberOfTeam(child, mother.team) : !child.refreshToken || freeTokenNeedsRefresh(child))).map((child) => child.id) + ? state.children.filter((child) => !childIsBanned(child) && (mother + ? (invitationForTeam(child, mother)?.status === 'invited' || isChildMemberOfTeam(child, mother.team)) + && !storedAutoTeamJsonResult({ workspaceId }, child, mother) + : !child.refreshToken || freeTokenNeedsRefresh(child))).map((child) => child.id) : [...new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean))]; if (body.ids !== undefined && !ids.length) return { status: 400, payload: { message: 'child_ids_required' } }; if (ids.some((id) => !findChild(id))) return { status: 404, payload: { message: 'child_not_found' } }; + const retryAccount = body.retryAccount === true; + if ((body.retryAccount !== undefined && typeof body.retryAccount !== 'boolean') + || (retryAccount && (scope !== 'team' || body.ids === undefined || ids.length !== 1 + || body.retryFailed !== undefined || body.retryJobId !== undefined))) { + return { status: 400, payload: { message: 'invalid_retry_request' } }; + } const retryFailed = body.retryFailed === true; const retryJobId = retryFailed ? String(body.retryJobId || '').trim() : null; if ((body.retryFailed !== undefined && typeof body.retryFailed !== 'boolean') || (!retryFailed && body.retryJobId !== undefined)) { @@ -5945,7 +6302,7 @@ function startAutoJsonJob(body) { const failedIds = new Set(retrySource.results.filter((result) => ['failed', 'needs_attention'].includes(result.status)).map((result) => result.id)); if (ids.some((id) => !failedIds.has(id))) return { status: 400, payload: { message: 'retry_failed_accounts_only' } }; } - const key = JSON.stringify([scope, mother?.id || null, workspaceId, [...ids].sort(), retryJobId]); + const key = JSON.stringify([scope, mother?.id || null, workspaceId, [...ids].sort(), retryJobId, retryAccount]); for (const [id, job] of autoJsonJobs) { if (job.status === 'complete' && Date.now() - Date.parse(job.finishedAt) > AUTO_JSON_JOB_TTL_MS) autoJsonJobs.delete(id); else if (job.key === key && (job.status !== 'complete' || retryFailed)) return { status: 202, payload: { job: publicAutoJsonJob(job), reused: true } }; @@ -5957,8 +6314,8 @@ function startAutoJsonJob(body) { autoJsonJobs.delete(complete[0]); } const concurrency = state.settings.integrations?.roxy?.enabled - ? state.settings.integrations.roxy.concurrency : normalizeConcurrency(state.settings.concurrency); - const job = { id: randomUUID(), key, ids, scope, motherId: mother?.id || null, workspaceId, retryFailed, retryJobId, status: 'queued', concurrency, createdAt: now(), finishedAt: null, results: [], logs: [], logSequence: 0 }; + ? roxyConcurrency() : normalizeConcurrency(state.settings.concurrency); + const job = { id: randomUUID(), key, ids, scope, motherId: mother?.id || null, workspaceId, retryFailed, retryJobId, retryAccount, status: 'queued', concurrency, createdAt: now(), finishedAt: null, results: [], logs: [], logSequence: 0 }; if (retrySource) { retrySource.retriedIds ||= new Set(); ids.forEach((id) => retrySource.retriedIds.add(id)); @@ -7611,6 +7968,13 @@ async function handleApi(req, res, url) { if (segments.length === 3 && segments[2] === 'export' && method === 'POST') { return sendJson(res, 200, exportMailboxVaultRecords(state.mailboxVault, body?.ids)); } + if (segments.length === 3 && segments[2] === 'mark-exported' && method === 'POST') { + const selected = markMailboxVaultRecordsExported(state.mailboxVault, body?.records); + const updated = new Map(selected.map((record) => [record.id, record])); + state.mailboxVault = state.mailboxVault.map((record) => updated.get(record.id) || record); + await persist(); + return sendJson(res, 200, { records: selected.map(publicMailboxVaultRecord) }); + } if (segments.length === 3 && segments[2] === 'batch-delete' && method === 'POST') { const selected = selectMailboxVaultRecords(state.mailboxVault, body?.ids); const ids = new Set(selected.map((record) => record.id)); @@ -7803,6 +8167,9 @@ async function handleApi(req, res, url) { for (const profile of state.settings.integrations?.roxy?.profiles || []) { if (ids.has(profile.proxyId)) profile.proxyId = ''; } + for (const child of state.children) { + if (ids.has(child.roxyProxyId)) child.roxyProxyId = ''; + } addHistory('删除代理', `已移除 ${ids.size} 条代理;相关窗口下次授权时重新分配代理`); await persist(); return sendJson(res, 200, { deletedIds: [...ids], proxy: publicState({ includeHistory: false }).settings.proxy, state: publicState({ includeHistory: false }) }); @@ -7816,11 +8183,13 @@ async function handleApi(req, res, url) { // An omitted key preserves the encrypted secret; an explicit empty key clears it. const incoming = { ...body.roxy }; if (!Object.hasOwn(incoming, 'apiKey')) incoming.apiKey = current.roxy?.apiKey || ''; + if (current.roxy?.poolProfiles && ((incoming.workspaceId !== undefined && incoming.workspaceId !== current.roxy?.workspaceId) + || (incoming.baseUrl !== undefined && incoming.baseUrl !== current.roxy?.baseUrl))) incoming.poolProfiles = []; const nextRoxy = normalizeRoxyConfig(incoming, current.roxy || { ...DEFAULT_ROXY_CONFIG, profiles: [] }); if (nextRoxy.profiles.some((entry) => !findChild(entry.childId))) { return sendJson(res, 400, { code: 'roxy_child_not_found', message: 'Roxy 绑定的本地账号不存在,请刷新账号列表' }); } - if (childAuthQueues.size && JSON.stringify(nextRoxy) !== JSON.stringify(current.roxy)) { + if ((childAuthQueues.size || roxyProfileCleanupJobs) && JSON.stringify(nextRoxy) !== JSON.stringify(current.roxy)) { return sendJson(res, 409, { code: 'roxy_profile_busy', message: '账号授权正在运行,请等待任务结束后修改 Roxy 配置' }); } current.roxy = bindRoxyProxyProfiles(nextRoxy); @@ -7846,6 +8215,49 @@ async function handleApi(req, res, url) { await persist(); return sendJson(res, 200, { settings: publicState({ includeHistory: false }).settings, state: publicState({ includeHistory: false }) }); } + if (method === 'POST' && url.pathname === '/api/integrations/roxy/cleanup-successful') { + const config = state.settings.integrations?.roxy; + if (!config?.enabled || config.deleteProfileAfterSuccess !== true) { + return sendJson(res, 409, { code: 'roxy_cleanup_disabled', message: '请先开启成功后释放环境名额' }); + } + if (childAuthQueues.size || activeRoxyPoolSlots.size || roxyProfileCleanupJobs) { + return sendJson(res, 409, { code: 'roxy_profile_busy', message: '请等待授权任务结束后清理已成功账号的环境' }); + } + if (!Array.isArray(body.childIds) || !body.childIds.length || body.childIds.length > 1000) { + return sendJson(res, 400, { code: 'child_ids_required', message: '请选择已保存 JSON 的账号' }); + } + const ids = new Set(body.childIds.map(String)); + const seen = new Set(); + const candidates = config.profiles.filter((profile) => { + const child = findChild(profile.childId); + const claims = accessTokenClaims(child?.accessToken); + const knownIdentity = claims.email && workspaceIdKey(claims.email) === workspaceIdKey(child?.email) + || claims.userId && child?.chatgptUserId === claims.userId; + const saved = child && (child.accessToken && child.refreshToken && knownIdentity && childTokenIdentityMatches(child, child.accessToken) + && Number.isFinite(Date.parse(claims.expiresAt || '')) + || state.mothers.some((mother) => completeTeamJsonFor(child, mother, { allowExpired: true }))); + if (!ids.has(profile.childId) || !saved || seen.has(profile.dirId)) return false; + seen.add(profile.dirId); + return true; + }); + roxyProfileCleanupJobs += 1; + let finishCleanup; + roxyProfileCleanupFinished = new Promise((resolve) => { finishCleanup = resolve; }); + const results = []; + try { + for (const profile of candidates) { + try { + const result = await releaseSavedRoxyProfile(findChild(profile.childId), config, profile.dirId); + results.push({ childId: profile.childId, dirId: profile.dirId, ...result }); + } catch { + results.push({ childId: profile.childId, dirId: profile.dirId, deleted: false, released: false, code: 'roxy_profile_delete_failed' }); + } + } + } finally { roxyProfileCleanupJobs -= 1; finishCleanup(); } + return sendJson(res, 200, { ok: results.every((result) => result.released), + deleted: results.filter((result) => result.deleted).length, results, + state: publicState({ includeHistory: false }) }); + } if (method === 'POST' && url.pathname === '/api/integrations/roxy/check') { try { const current = state.settings.integrations?.roxy || { ...DEFAULT_ROXY_CONFIG, profiles: [] }; @@ -8571,6 +8983,7 @@ function canHandleScopedMutationConcurrently(req) { try { pathname = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`).pathname; } catch { return false; } if (pathname === '/mcp' || pathname === '/api/auth/login' || pathname === '/api/auth/logout') return true; if (pathname === '/api/children/auto-json') return true; + if (pathname === '/api/integrations/roxy/cleanup-successful') return true; if (['/api/maintenance/check', '/api/maintenance/check-all', '/api/maintenance/refill', '/api/maintenance/refill-all', '/api/sub2api/team-push'].includes(pathname)) return true; if (/^\/api\/mothers\/[^/]+$/.test(pathname)) return true; if (/^\/api\/mothers\/[^/]+\/recover$/.test(pathname)) return true; diff --git a/server/mailbox-vault-integration.test.mjs b/server/mailbox-vault-integration.test.mjs index 9bb3fa7..ef7ef11 100644 --- a/server/mailbox-vault-integration.test.mjs +++ b/server/mailbox-vault-integration.test.mjs @@ -160,6 +160,7 @@ test('every mailbox route enforces existing authentication, origin checks and fr ['/api/mailbox-vault', 'POST', { account: 'x@example.test' }], [`/api/mailbox-vault/${id}`, 'PATCH', { password: 'x' }], ['/api/mailbox-vault/import', 'POST', { text: 'x@example.test----p----t' }], ['/api/mailbox-vault/export', 'POST', { ids: [id] }], + ['/api/mailbox-vault/mark-exported', 'POST', { records: [{ id, account: created.data.record.account }] }], ['/api/mailbox-vault/batch-delete', 'POST', { ids: [id] }], [`/api/mailbox-vault/${id}`, 'DELETE'], ]; const saved = await app.saved(); @@ -170,3 +171,70 @@ test('every mailbox route enforces existing authentication, origin checks and fr assert.equal(await app.saved(), saved); assert.equal((await app.request('/api/mailbox-vault/missing')).status, 404); }); + +test('mailbox export marking is explicit, atomic, persistent and permits exporting used emails again', async (t) => { + const app = await fixture(t); + const imported = await app.request('/api/mailbox-vault/import', { method: 'POST', body: { records: [ + { account: 'a@example.test', password: 'a----secret', totp: 'first-2fa', exportedAt: 'forged' }, + { account: 'b@example.test', password: ' b,secret ', totp: 'second-2fa' }, + { account: 'c@example.test', password: 'c-secret', totp: '' }, + ] } }); + assert.equal(imported.status, 200); + const [a, b, c] = imported.data.records; + assert.ok(imported.data.records.every((record) => record.exportedAt === null)); + const saved = await app.saved(); + const before = await app.decoded(); + const exported = await app.request('/api/mailbox-vault/export', { method: 'POST', body: { ids: [b.id, a.id] } }); + assert.equal(exported.status, 200); + assert.ok(exported.data.records.every((record) => record.exportedAt === null)); + assert.ok((await app.request('/api/mailbox-vault')).data.records.every((record) => record.exportedAt === null)); + assert.equal((await app.request(`/api/mailbox-vault/${a.id}`)).data.record.exportedAt, null); + assert.equal(await app.saved(), saved, 'loading list, credentials and export never mark mailboxes'); + for (const records of [undefined, [], [null], [{ id: a.id }], [{ id: a.id, account: 123 }], + [{ id: a.id, account: a.account }, { id: a.id, account: a.account }]]) { + assert.equal((await app.request('/api/mailbox-vault/mark-exported', { method: 'POST', body: { records } })).status, 400); + } + for (const records of [ + [{ id: a.id, account: a.account }, { id: b.id, account: 'old@example.test' }], + [{ id: a.id, account: a.account }, { id: 'missing', account: b.account }], + ]) assert.equal((await app.request('/api/mailbox-vault/mark-exported', { method: 'POST', body: { records } })).status, 409); + assert.equal(await app.saved(), saved, 'invalid selections do not partially mark records'); + const marked = await app.request('/api/mailbox-vault/mark-exported', { method: 'POST', body: { + records: [{ id: b.id, account: 'B@EXAMPLE.TEST' }, { id: a.id, account: a.account }], + } }); + assert.equal(marked.status, 200); + assert.deepEqual(marked.data.records.map((record) => record.id), [b.id, a.id]); + assert.ok(marked.data.records.every((record) => Number.isFinite(Date.parse(record.exportedAt)))); + assert.ok(marked.data.records.every((record) => !('password' in record) && !('totp' in record))); + const stamp = marked.data.records[0].exportedAt; + const listed = (await app.request('/api/mailbox-vault')).data.records; + assert.equal(listed.find((record) => record.id === c.id).exportedAt, null); + assert.equal(listed.find((record) => record.id === a.id).exportedAt, stamp); + assert.equal(JSON.parse(await app.saved()).encrypted, true); + const decoded = await app.decoded(); + for (const field of ['mothers', 'motherVault', 'children', 'history', 'settings']) assert.deepEqual(decoded[field], before[field]); + await app.restart(); + assert.deepEqual((await app.request('/api/mailbox-vault')).data.records, listed); + assert.equal((await app.request(`/api/mailbox-vault/${a.id}`)).data.record.exportedAt, stamp); + const repeated = await app.request('/api/mailbox-vault/export', { method: 'POST', body: { ids: [b.id, a.id] } }); + assert.equal(repeated.status, 200); + assert.equal(repeated.data.records[0].exportedAt, stamp); + assert.equal(exportMailboxVaultText(repeated.data.records), exportMailboxVaultText(exported.data.records)); + assert.equal((await app.request('/api/mailbox-vault/mark-exported', { method: 'POST', body: { + records: repeated.data.records.map(({ id, account }) => ({ id, account })), + } })).status, 200); + const bStamp = (await app.request(`/api/mailbox-vault/${b.id}`)).data.record.exportedAt; + const edited = await app.request(`/api/mailbox-vault/${b.id}`, { method: 'PATCH', body: { + account: 'B@example.test', password: 'new-password', totp: 'new-2fa', exportedAt: null, + } }); + assert.equal(edited.status, 200); + assert.equal(edited.data.record.exportedAt, bStamp); + const changed = await app.request(`/api/mailbox-vault/${b.id}`, { method: 'PATCH', body: { account: 'changed@example.test', exportedAt: bStamp } }); + assert.equal(changed.data.record.exportedAt, null); + const afterEdit = await app.saved(); + assert.equal((await app.request('/api/mailbox-vault/mark-exported', { method: 'POST', body: { + records: [{ id: a.id, account: a.account }, { id: b.id, account: b.account }], + } })).status, 409); + assert.equal(await app.saved(), afterEdit); + assert.doesNotMatch(app.output(), /UNEXPECTED_MAILBOX_NETWORK_REQUEST|a----secret|new-password/); +}); diff --git a/server/mailbox-vault.mjs b/server/mailbox-vault.mjs index 5f35d46..7e9f103 100644 --- a/server/mailbox-vault.mjs +++ b/server/mailbox-vault.mjs @@ -33,12 +33,12 @@ function recordFields(input, previous) { export function publicMailboxVaultRecord(record) { return { id: record.id, account: record.account, hasPassword: Boolean(record.password), hasTotp: Boolean(record.totp), - createdAt: record.createdAt, updatedAt: record.updatedAt }; + createdAt: record.createdAt, updatedAt: record.updatedAt, exportedAt: record.exportedAt ?? null }; } export function fullMailboxVaultRecord(record) { return { id: record.id, ...Object.fromEntries(fields.map((field) => [field, record[field]])), - createdAt: record.createdAt, updatedAt: record.updatedAt }; + createdAt: record.createdAt, updatedAt: record.updatedAt, exportedAt: record.exportedAt ?? null }; } export function createMailboxVaultRecord(input, records, timestamp = new Date().toISOString()) { @@ -46,7 +46,7 @@ export function createMailboxVaultRecord(input, records, timestamp = new Date(). if (records.some((record) => accountKey(record.account) === accountKey(values.account))) { throw new MailboxVaultError(409, 'mailbox_vault_duplicate', '此邮箱已保存,请编辑已有记录'); } - return { id: `mailbox_vault_${randomUUID()}`, ...values, createdAt: timestamp, updatedAt: timestamp }; + return { id: `mailbox_vault_${randomUUID()}`, ...values, createdAt: timestamp, updatedAt: timestamp, exportedAt: null }; } export function updateMailboxVaultRecord(previous, input, records, timestamp = new Date().toISOString()) { @@ -54,7 +54,8 @@ export function updateMailboxVaultRecord(previous, input, records, timestamp = n if (records.some((record) => record.id !== previous.id && accountKey(record.account) === accountKey(values.account))) { throw new MailboxVaultError(409, 'mailbox_vault_duplicate', '此邮箱已保存,请编辑已有记录'); } - return { ...fullMailboxVaultRecord(previous), ...values, updatedAt: timestamp }; + return { ...fullMailboxVaultRecord(previous), ...values, updatedAt: timestamp, + exportedAt: accountKey(previous.account) === accountKey(values.account) ? previous.exportedAt ?? null : null }; } function importRows(input) { @@ -102,7 +103,7 @@ export function importMailboxVaultRecords(input, existing, timestamp = new Date( const key = accountKey(value.account); if (seen.has(key)) { skipped += 1; continue; } seen.add(key); - added.push({ id: `mailbox_vault_${randomUUID()}`, ...value, createdAt: timestamp, updatedAt: timestamp }); + added.push({ id: `mailbox_vault_${randomUUID()}`, ...value, createdAt: timestamp, updatedAt: timestamp, exportedAt: null }); } return { added, skipped }; } @@ -120,3 +121,16 @@ export function exportMailboxVaultRecords(records, ids, timestamp = new Date().t return { format: MAILBOX_VAULT_FORMAT, version: 1, exportedAt: timestamp, records: selectMailboxVaultRecords(records, ids).map(fullMailboxVaultRecord) }; } + +export function markMailboxVaultRecordsExported(records, selection, timestamp = new Date().toISOString()) { + if (!Array.isArray(selection) || !selection.length || selection.some((item) => !item || typeof item !== 'object' + || Array.isArray(item) || typeof item.account !== 'string' || item.account.length > limits.account + || !isMailboxAccount(item.account.trim()))) { + throw new MailboxVaultError(400, 'mailbox_vault_selection_required', '请提供本次导出的邮箱记录'); + } + const selected = selectMailboxVaultRecords(records, selection.map((item) => item.id)); + if (selected.some((record, index) => accountKey(record.account) !== accountKey(selection[index].account))) { + throw new MailboxVaultError(409, 'mailbox_vault_selection_stale', '部分邮箱已修改,请刷新列表后重新导出'); + } + return selected.map((record) => ({ ...fullMailboxVaultRecord(record), exportedAt: timestamp, updatedAt: timestamp })); +} diff --git a/server/mailbox-vault.test.mjs b/server/mailbox-vault.test.mjs index 32ca1db..cbe7eb7 100644 --- a/server/mailbox-vault.test.mjs +++ b/server/mailbox-vault.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createMailboxVaultRecord, exportMailboxVaultRecords, fullMailboxVaultRecord, importMailboxVaultRecords, publicMailboxVaultRecord, selectMailboxVaultRecords, updateMailboxVaultRecord } from './mailbox-vault.mjs'; +import { createMailboxVaultRecord, exportMailboxVaultRecords, fullMailboxVaultRecord, importMailboxVaultRecords, markMailboxVaultRecordsExported, publicMailboxVaultRecord, selectMailboxVaultRecords, updateMailboxVaultRecord } from './mailbox-vault.mjs'; import { exportMailboxVaultText, parseMailboxVaultText } from '../shared/mailbox-vault-text.mjs'; const firstTime = '2026-10-10T01:00:00.000Z'; @@ -8,13 +8,13 @@ const secondTime = '2026-10-10T02:00:00.000Z'; const sample = { account: ' Mail@Example.test ', password: ' p----中文,|\t"\\\r\n ', totp: 'opaque 2fa ---- value' }; test('mailbox metadata stays separate and edits preserve credentials and server identities', () => { - const record = createMailboxVaultRecord({ ...sample, id: 'forged', createdAt: 'forged', note: 'ignored' }, [], firstTime); + const record = createMailboxVaultRecord({ ...sample, id: 'forged', createdAt: 'forged', note: 'ignored', exportedAt: firstTime }, [], firstTime); assert.match(record.id, /^mailbox_vault_/); assert.equal(record.account, 'Mail@Example.test'); assert.equal(record.password, sample.password); assert.equal(record.totp, sample.totp); assert.equal(record.note, undefined); - assert.deepEqual(publicMailboxVaultRecord(record), { id: record.id, account: record.account, hasPassword: true, hasTotp: true, createdAt: firstTime, updatedAt: firstTime }); + assert.deepEqual(publicMailboxVaultRecord(record), { id: record.id, account: record.account, hasPassword: true, hasTotp: true, createdAt: firstTime, updatedAt: firstTime, exportedAt: null }); assert.deepEqual(fullMailboxVaultRecord(record), record); const updated = updateMailboxVaultRecord(record, { totp: '', id: 'forged' }, [record], secondTime); assert.equal(updated.password, sample.password); @@ -166,3 +166,47 @@ test('export/delete selections are explicit, ordered and reject stale or repeate for (const ids of [undefined, [], [a.id, a.id], [1], [''], 'all']) assert.throws(() => selectMailboxVaultRecords([a, b], ids), { status: 400 }); assert.throws(() => selectMailboxVaultRecords([a, b], [a.id, 'missing']), { status: 409 }); }); + +test('marking exported mailboxes validates every identity without changing unselected or stale records', () => { + const a = createMailboxVaultRecord({ account: 'a@example.test' }, [], firstTime); + const b = createMailboxVaultRecord({ account: 'b@example.test' }, [], firstTime); + const records = [a, b]; + const before = structuredClone(records); + const selection = [{ id: b.id, account: ' B@EXAMPLE.TEST ' }]; + const marked = markMailboxVaultRecordsExported(records, selection, secondTime); + assert.deepEqual(marked, [{ ...b, exportedAt: secondTime, updatedAt: secondTime }]); + assert.deepEqual(records, before); + for (const invalid of [undefined, null, [], {}, [null], [[b.id]], [{ id: b.id }], + [{ id: b.id, account: 123 }], [{ id: b.id, account: 'invalid' }], + [{ account: b.account }], [{ id: 123, account: b.account }], + [{ id: b.id, account: b.account }, { id: b.id, account: b.account }]]) { + assert.throws(() => markMailboxVaultRecordsExported(records, invalid), { status: 400 }); + } + for (const stale of [ + [{ id: a.id, account: a.account }, { id: b.id, account: 'changed@example.test' }], + [{ id: a.id, account: a.account }, { id: 'deleted', account: b.account }], + ]) assert.throws(() => markMailboxVaultRecordsExported(records, stale), { status: 409 }); + assert.deepEqual(records, before); +}); + +test('export markers survive credential edits, clear for new addresses, and never import from caller metadata', () => { + const record = createMailboxVaultRecord(sample, [], firstTime); + const marked = markMailboxVaultRecordsExported([record], [{ id: record.id, account: record.account }], secondTime)[0]; + const updated = updateMailboxVaultRecord(marked, { account: 'mail@example.test', password: 'new', totp: 'new2fa', exportedAt: null }, [marked]); + assert.equal(updated.exportedAt, secondTime); + assert.equal(updateMailboxVaultRecord(updated, { account: 'new@example.test', exportedAt: secondTime }, [updated]).exportedAt, null); + const legacy = { ...record }; + delete legacy.exportedAt; + assert.equal(publicMailboxVaultRecord(legacy).exportedAt, null); + assert.equal(fullMailboxVaultRecord(legacy).exportedAt, null); + assert.equal(updateMailboxVaultRecord(legacy, { password: 'edited' }, [legacy]).exportedAt, null); + const exported = exportMailboxVaultRecords([marked], [marked.id]); + assert.equal(exported.records[0].exportedAt, secondTime); + assert.equal(marked.exportedAt, secondTime); + const restored = importMailboxVaultRecords({ text: JSON.stringify(exported) }, []).added[0]; + assert.equal(restored.exportedAt, null); + assert.equal(importMailboxVaultRecords({ records: [marked] }, []).added[0].exportedAt, null); + const txtRestored = importMailboxVaultRecords({ text: exportMailboxVaultText(exported.records) }, []).added[0]; + for (const key of ['account', 'password', 'totp']) assert.equal(txtRestored[key], marked[key]); + assert.equal(txtRestored.exportedAt, null); +}); diff --git a/server/openai-browser-login.mjs b/server/openai-browser-login.mjs index c72b493..f1bc4a1 100644 --- a/server/openai-browser-login.mjs +++ b/server/openai-browser-login.mjs @@ -9,7 +9,8 @@ const SELECTORS = { email: 'input[type="email"], input[name="email"], input[autocomplete="username"]', password: 'input[type="password"]', totp: 'input[autocomplete="one-time-code"], input[name="code"], input[name="otp"], input[name="totp"], input[inputmode="numeric"][maxlength="6"]', - workspace: 'input[type="radio"][name="workspace_id"]', + workspace: 'input[type="radio"][name="workspace_id"], select[name="workspace_id"], button[name="workspace_id"], button[data-workspace-id], [role="radio"][data-workspace-id], [role="radio"][data-value], [role="option"][data-workspace-id], [role="option"][data-value], [role="option"][value]', + workspaceDropdown: 'button[role="combobox"], button[aria-haspopup="listbox"]', submit: 'button[type="submit"], input[type="submit"]', }; const ERROR_MESSAGES = { @@ -27,7 +28,11 @@ const ERROR_MESSAGES = { browser_oauth_input_mismatch: '登录输入框内容未能正确填入,已停止提交,请检查浏览器输入状态', browser_oauth_timeout: '浏览器授权等待超时,可通过手动授权继续', browser_oauth_unavailable: '无法启动本机授权浏览器,请检查 Chrome 安装', - browser_oauth_failed: '浏览器授权未完成,请查看阶段日志并通过手动授权重试', + browser_oauth_failed: '浏览器授权未完成,请查看阶段日志后重试', + browser_oauth_proxy_connection_failed: '浏览器无法建立代理连接或上游隧道,请检查本机代理、代理池及上游连通性后重试', + browser_oauth_network_failed: '浏览器连接官方授权服务失败,请检查网络或代理连通性后重试', + browser_oauth_browser_disconnected: '授权浏览器或标签页连接已断开,请检查 Roxy 窗口是否被关闭或占用后重试', + browser_oauth_navigation_timeout: '官方授权页面加载超时,请检查网络或代理后重试', browser_oauth_proxy_invalid: '浏览器代理配置无效,未使用直连', browser_oauth_socks_auth_unsupported: '浏览器不支持带认证的 SOCKS 代理,请改用 HTTP 代理,未使用直连', }; @@ -43,6 +48,55 @@ export class BrowserOAuthError extends Error { } } +function logUnknownBrowserFailure(error, navigation) { + const safeNames = new Set(['Error', 'TypeError', 'TimeoutError', 'ProtocolError', 'TargetCloseError', 'AbortError', 'AggregateError']); + const safeMethods = new Set([ + 'Page.navigate', 'Page.enable', 'Page.getFrameTree', 'Page.createIsolatedWorld', + 'Runtime.evaluate', 'Runtime.callFunctionOn', 'Runtime.addBinding', + 'Network.enable', 'Network.setRequestInterception', 'Fetch.enable', 'Fetch.continueRequest', 'Fetch.failRequest', + 'Target.createTarget', 'Target.createBrowserContext', 'Target.closeTarget', 'Target.getTargets', + 'Target.attachToTarget', 'Target.detachFromTarget', 'Target.setAutoAttach', + 'Browser.getVersion', 'DOM.describeNode', 'DOM.resolveNode', 'DOM.getContentQuads', + ]); + const message = String(error?.message || ''); + const method = message.match(/^Protocol error \(([A-Za-z]+\.[A-Za-z]+)\):/)?.[1] + || message.match(/^([A-Za-z]+\.[A-Za-z]+) timed out\./)?.[1]; + // Extract only a Chromium error token at the start of its error clause; + // never scan URLs, query strings or arbitrary payloads for diagnostic text. + const chromiumCode = message.match(/^(?:Protocol error \([A-Za-z]+\.[A-Za-z]+\): )?(?:net::)?(ERR_[A-Z_]{1,64})(?=\s|:|$)/)?.[1] || ''; + try { + console.warn('[browser-oauth] unclassified browser error', JSON.stringify({ + phase: navigation ? 'navigation' : 'browser', + errorName: safeNames.has(error?.name) ? error.name : 'OtherError', + chromiumCode, + cdpMethod: safeMethods.has(method) ? method : '', + })); + } catch { /* Diagnostics must not replace the original sanitized failure. */ } +} + +function classifyBrowserFailure(error, { navigation = false } = {}) { + if (error instanceof BrowserOAuthError || error instanceof RoxyBrowserError) return error; + // Raw Chromium/CDP messages can contain credentials, proxy addresses and the + // OAuth URL. Inspect them only here; expose fixed codes and messages below. + const detail = `${String(error?.name || '')} ${String(error?.code || '')} ${String(error?.message || '')}`; + let code = 'browser_oauth_failed'; + let status = 502; + if (/\bERR_(?:PROXY_CONNECTION_FAILED|TUNNEL_CONNECTION_FAILED|SOCKS_CONNECTION_FAILED|SOCKS_CONNECTION_HOST_UNREACHABLE|NO_SUPPORTED_PROXIES|PROXY_AUTH_UNSUPPORTED|PROXY_AUTH_REQUESTED|INVALID_AUTH_CREDENTIALS|PROXY_CERTIFICATE_INVALID|PROXY_HTTP_1_1_REQUIRED)\b/i.test(detail)) { + code = 'browser_oauth_proxy_connection_failed'; + } else if (/TargetCloseError|Target closed|Session closed|Connection closed|browser (?:has )?disconnected|Target page, context or browser has been closed|Browser closed/i.test(detail)) { + code = 'browser_oauth_browser_disconnected'; + status = 503; + } else if (navigation && (/TimeoutError|Navigation timeout|navigation.*timed out|Timed out.*navigat/i.test(detail) + || /^Page\.navigate timed out\./.test(String(error?.message || '')))) { + code = 'browser_oauth_navigation_timeout'; + status = 504; + } else if (/\bERR_(?:CONNECTION_[A-Z_]+|NAME_NOT_RESOLVED|DNS_[A-Z_]+|NETWORK_[A-Z_]+|INTERNET_DISCONNECTED|ADDRESS_[A-Z_]+|SSL_[A-Z_]+|CERT_[A-Z_]+|TIMED_OUT|EMPTY_RESPONSE|HTTP_RESPONSE_CODE_FAILURE)\b|\b(?:ECONNRESET|ECONNREFUSED|ENETUNREACH|EHOSTUNREACH|ENOTFOUND|EAI_AGAIN|ETIMEDOUT)\b/i.test(detail)) { + code = 'browser_oauth_network_failed'; + } + if (code === 'browser_oauth_failed') logUnknownBrowserFailure(error, navigation); + return new BrowserOAuthError(code, { status, browserRequired: false, needsInput: false }); +} + function urlFor(value, code = 'browser_oauth_options_invalid') { try { const url = new URL(String(value || '')); @@ -124,7 +178,8 @@ function normalizeProxy(proxy) { export function selectBrowserWorkspace(entries, workspaceId, workspaceMode = 'team') { const personal = (entry) => /^(?:personal|individual|free)$/i.test(entry.kind || '') - || /\bpersonal\b|个人(?:空间|账户|账号)?/i.test(entry.label || ''); + || (!/^(?:team|business|enterprise)$/i.test(entry.kind || '') + && /\bpersonal\b|个人(?:空间|账户|账号)?/i.test(entry.label || '')); const matches = workspaceMode === 'team' ? entries.filter((entry) => entry.value === workspaceId && !personal(entry)) : entries.filter(personal); @@ -160,14 +215,138 @@ function inspectControl(element) { return { visible: rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden', disabled: element.disabled || element.readOnly, - value: element.type === 'radio' ? element.value : '', + value: element.type === 'radio' || element.name === 'workspace_id' ? element.value : '', kind: element.dataset.kind || element.dataset.workspaceType || element.dataset.type || '', label: label.slice(0, 300), - action: element.form?.getAttribute('action') || '', - hasForm: Boolean(element.form), + action: (element.form || element.closest('form'))?.getAttribute('action') || '', + hasForm: Boolean(element.form || element.closest('form')), }; } +function inspectWorkspaceControl(element) { + const visible = (item) => { + const rect = item.getBoundingClientRect(); + const style = window.getComputedStyle(item); + return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; + }; + const form = element.form || element.closest('form'); + const label = [...(element.labels || [])].map((item) => item.innerText || '').join(' ') + || element.getAttribute('aria-label') || element.innerText || ''; + const radio = element.tagName === 'INPUT' && element.type === 'radio'; + const value = element.name === 'workspace_id' ? element.value + : element.dataset.workspaceId || element.dataset.value || element.getAttribute('value') || ''; + const formValues = form ? [...form.querySelectorAll('[name="workspace_id"]')] + .filter((item) => !item.disabled && (item.type !== 'radio' || item.checked) + && item.tagName !== 'BUTTON' && item.type !== 'submit').map((item) => item.value) : []; + return { + visible: visible(element) || (radio && [...(element.labels || [])].some(visible)), + clickLabel: radio && !visible(element), + disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true'), + value, label: label.slice(0, 300), + kind: element.dataset.kind || element.dataset.workspaceType || element.dataset.type || '', + action: form?.getAttribute('action') || '', hasForm: Boolean(form), + controlType: element.tagName === 'SELECT' ? 'select' : radio ? 'radio' : 'choice', + directSubmit: element.tagName === 'BUTTON' && element.name === 'workspace_id' + && element.type === 'submit' && Boolean(form), + selected: radio ? element.checked : element.tagName === 'SELECT' ? Boolean(value) + : element.getAttribute('aria-checked') === 'true' || element.getAttribute('aria-selected') === 'true' + || (formValues.length === 1 && formValues[0] === value), + formValues, + options: element.tagName === 'SELECT' ? [...element.options].filter((option) => !option.disabled).map((option) => ({ + value: option.value, label: option.textContent || '', + kind: option.dataset.kind || option.dataset.workspaceType || option.dataset.type || '', + })) : null, + }; +} + +async function workspaceControls(page) { + const entries = []; + for (const handle of await page.$$(SELECTORS.workspace)) { + const info = await handle.evaluate(inspectWorkspaceControl); + if (!info.visible || info.disabled) continue; + if (info.options) entries.push(...info.options.filter((option) => option.value).map((option) => ({ ...info, ...option, handle }))); + else if (info.value) entries.push({ ...info, handle }); + } + return entries; +} + +function inspectWorkspaceFormSelection(expectedValue) { + const fields = [...document.querySelectorAll('input[name="workspace_id"], select[name="workspace_id"]')] + .filter((element) => !element.disabled && element.form + && element.type !== 'submit' && (element.type !== 'radio' || element.checked)); + return fields.length === 1 && fields[0].value === expectedValue; +} + +async function workspaceSelectionConfirmed(page, selected) { + const actual = await selected.handle.evaluate(inspectWorkspaceControl); + if (actual.selected && actual.value === selected.value + && !actual.formValues?.some((value) => value !== selected.value)) return true; + // A custom dropdown may remove its option from the DOM after clicking it. + // Its controlled, form-associated workspace field remains authoritative. + return selected.controlType === 'choice' + && page.evaluate(inspectWorkspaceFormSelection, selected.value); +} + +async function submitWorkspace(page, selected, beforeSubmit, deadline) { + assertAuthPage(page.url()); + if (selected.hasForm) assertControlAction(selected, page.url()); + const fresh = await selected.handle.evaluate(inspectWorkspaceControl); + if (!fresh.visible || fresh.disabled || (fresh.controlType !== 'select' && fresh.value !== selected.value)) { + throw new BrowserOAuthError('browser_oauth_workspace_unconfirmed'); + } + if (fresh.directSubmit) { + assertControlAction(fresh, page.url()); + if (fresh.formValues?.some((value) => value !== selected.value)) { + throw new BrowserOAuthError('browser_oauth_workspace_unconfirmed'); + } + beforeSubmit(); + await selected.handle.click(); + return; + } + if (fresh.controlType === 'select') { + await selected.handle.select(selected.value); + } else if (fresh.clickLabel) { + const label = await selected.handle.evaluateHandle((element) => [...(element.labels || [])].find((item) => { + const rect = item.getBoundingClientRect(); + const style = window.getComputedStyle(item); + return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; + })); + try { + const clickable = label.asElement(); + if (!clickable) throw new BrowserOAuthError('browser_oauth_control_unavailable'); + await clickable.click(); + } finally { await label.dispose(); } + } else { + await selected.handle.click(); + } + assertAuthPage(page.url()); + // Read back the actual submitted field, including controlled React inputs. + // Clicking a label alone is not proof that the target workspace was selected. + let confirmed = await workspaceSelectionConfirmed(page, selected); + const selectionDeadline = Math.min(deadline, Date.now() + 1500); + while (!confirmed && Date.now() < selectionDeadline) { + await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(1, selectionDeadline - Date.now())))); + assertAuthPage(page.url()); + confirmed = await workspaceSelectionConfirmed(page, selected); + } + if (!confirmed) { + throw new BrowserOAuthError('browser_oauth_workspace_unconfirmed'); + } + const safety = await page.evaluate(inspectPageSafety); + if (safety.challenge) throw new BrowserOAuthError('browser_oauth_verification_required'); + const buttons = await controls(page, SELECTORS.submit); + const submitButtons = buttons.filter((button) => !button.value); + if (submitButtons.length !== 1) throw new BrowserOAuthError('browser_oauth_control_unavailable'); + assertControlAction(submitButtons[0], page.url()); + // Re-read immediately before clicking Continue: a stale personal default + // must never be accepted as confirmation of a Team authorization. + if (!await workspaceSelectionConfirmed(page, selected)) { + throw new BrowserOAuthError('browser_oauth_workspace_unconfirmed'); + } + beforeSubmit(); + await submitButtons[0].handle.click(); +} + async function controls(page, selector) { const handles = await page.$$(selector); const entries = []; @@ -233,6 +412,7 @@ export async function completeBrowserOAuth({ let timeout; let cancelled = false; let completed = false; + let navigating = false; const progress = (phase, message) => { try { onProgress(phase, message); } catch { /* Progress cannot alter the login flow. */ } }; const execute = async () => { let page; @@ -293,7 +473,7 @@ export async function completeBrowserOAuth({ } await request.continue(); } catch (error) { - failure = error instanceof BrowserOAuthError ? error : new BrowserOAuthError('browser_oauth_failed'); + failure ||= classifyBrowserFailure(error); await request.abort().catch(() => {}); } }); @@ -314,12 +494,24 @@ export async function completeBrowserOAuth({ failure = new BrowserOAuthError('browser_oauth_verification_required', { status: 403 }); } }); + progress('browser_navigation', '浏览器已连接,正在通过当前网络打开官方授权页面'); + navigating = true; try { await page.goto(validated.authorization.toString(), { waitUntil: 'domcontentloaded', timeout: Math.max(1, deadline - Date.now()) }); - } catch { - if (!callbackUrl && !failure) throw new BrowserOAuthError('browser_oauth_failed'); - } + } catch (error) { + if (!callbackUrl && !failure) { + // A redirect can replace the document while goto is waiting. Continue + // only on a recognized official page; real network errors still stop. + if (!/net::ERR_ABORTED|Execution context was destroyed/i.test(String(error?.message || ''))) { + throw classifyBrowserFailure(error, { navigation: true }); + } + assertAuthPage(page.url()); + } + } finally { navigating = false; } const submitted = new Set(); + let workspaceWaitStarted = 0; + let workspaceDropdownOpened = false; + const workspaceWaitMs = Math.min(10000, Math.max(250, limit / 4)); for (let step = 0; step < MAX_STEPS && Date.now() < deadline && !cancelled; step += 1) { if (failure) throw failure; if (callbackUrl) { @@ -327,18 +519,34 @@ export async function completeBrowserOAuth({ return { callbackUrl }; } const current = assertAuthPage(page.url()); - const safety = await page.evaluate(inspectPageSafety); + let safety; + let emailFields; + let passwordFields; + let otpFields; + let workspaceFields; + try { + safety = await page.evaluate(inspectPageSafety); + emailFields = await controls(page, SELECTORS.email); + passwordFields = await controls(page, SELECTORS.password); + otpFields = await controls(page, SELECTORS.totp); + workspaceFields = await workspaceControls(page); + } catch (error) { + if (!/Execution context was destroyed|Cannot find context with specified id|detached (?:frame|Frame)/i.test(String(error?.message || ''))) throw error; + // Navigation invalidates DOM handles briefly. Re-read the next known + // page before interacting; never retry a credential submission here. + await new Promise((resolve) => setTimeout(resolve, Math.min(150, Math.max(1, deadline - Date.now())))); + continue; + } if (safety.challenge) throw new BrowserOAuthError('browser_oauth_verification_required'); if (safety.rejected) { const error = new BrowserOAuthError('browser_oauth_credentials_rejected', { browserRequired: false }); if (safety.passwordRejected) error.reason = 'password_invalid'; throw error; } - const emailFields = await controls(page, SELECTORS.email); - const passwordFields = await controls(page, SELECTORS.password); - const otpFields = await controls(page, SELECTORS.totp); - const workspaceFields = await controls(page, SELECTORS.workspace); - const stage = passwordFields.length ? 'password' : emailFields.length ? 'email' : otpFields.length ? 'totp' : workspaceFields.length ? 'workspace' : ''; + if (page.url() !== current.toString()) continue; + const workspacePage = /^\/(?:workspace|sign-in-with-chatgpt\/codex\/consent)\/?$/.test(current.pathname); + const stage = passwordFields.length ? 'password' : emailFields.length ? 'email' : otpFields.length ? 'totp' + : workspaceFields.length || workspacePage ? 'workspace' : ''; const submissionKey = `${current.pathname}:${stage}`; if (stage && !submitted.has(submissionKey)) { const entries = { email: emailFields, password: passwordFields, totp: otpFields, workspace: workspaceFields }[stage]; @@ -364,9 +572,31 @@ export async function completeBrowserOAuth({ progress('browser_totp', '提交账号的当前六位 2FA 验证码'); await submit(page, entries[0], String(code)); } else { - const selected = selectBrowserWorkspace(entries, String(workspaceId), workspaceMode); - progress('browser_workspace', workspaceMode === 'team' ? '选择与目标 Team ID 完全匹配的工作空间' : '选择个人工作空间'); - await submit(page, selected, null, () => { workspaceSelected = true; }); + if (!workspacePage) throw new BrowserOAuthError('browser_oauth_page_unsupported'); + let selected; + try { selected = selectBrowserWorkspace(entries, String(workspaceId), workspaceMode); } + catch (error) { + if (error?.code !== 'browser_oauth_workspace_unavailable') throw error; + if (!workspaceWaitStarted) { + workspaceWaitStarted = Date.now(); + progress('browser_workspace_wait', '正在等待目标工作空间列表加载,不会选择个人空间或其他 Team'); + } + if (!workspaceDropdownOpened) { + const dropdowns = await controls(page, SELECTORS.workspaceDropdown); + if (dropdowns.length === 1) { + if (dropdowns[0].hasForm) assertControlAction(dropdowns[0], page.url()); + await dropdowns[0].handle.click(); + workspaceDropdownOpened = true; + } + } + if (Date.now() - workspaceWaitStarted >= workspaceWaitMs) throw error; + await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, deadline - Date.now())))); + continue; + } + progress('browser_workspace', workspaceMode === 'team' ? '选择与目标 Team ID 完全匹配的工作空间,并核对实际选中值' : '选择个人工作空间'); + await submitWorkspace(page, selected, () => { workspaceSelected = true; }, deadline); + workspaceWaitStarted = 0; + workspaceDropdownOpened = false; } submitted.add(submissionKey); } @@ -381,14 +611,20 @@ export async function completeBrowserOAuth({ new Promise((_, reject) => { timeout = setTimeout(() => { cancelled = true; - reject(new BrowserOAuthError('browser_oauth_timeout', { status: 408 })); + reject(new BrowserOAuthError(navigating ? 'browser_oauth_navigation_timeout' : 'browser_oauth_timeout', navigating + ? { status: 504, browserRequired: false, needsInput: false } : { status: 408 })); }, limit); }), ]); completed = true; return result; } catch (error) { - throw error instanceof BrowserOAuthError || error instanceof RoxyBrowserError ? error : new BrowserOAuthError('browser_oauth_failed'); + const classified = classifyBrowserFailure(error); + if (['browser_oauth_proxy_connection_failed', 'browser_oauth_network_failed', 'browser_oauth_browser_disconnected', + 'browser_oauth_navigation_timeout', 'browser_oauth_failed'].includes(classified.code)) { + progress(classified.code, classified.message); + } + throw classified; } finally { cancelled = true; clearTimeout(timeout); diff --git a/server/openai-browser-login.test.mjs b/server/openai-browser-login.test.mjs index ba87ad0..2f69b0f 100644 --- a/server/openai-browser-login.test.mjs +++ b/server/openai-browser-login.test.mjs @@ -13,12 +13,13 @@ const authUrl = `https://auth.openai.com/oauth/authorize?state=${state}&redirect const options = { authUrl, state, redirectUri, workspaceMode: 'team', workspaceId: 'target-team' }; function fixture({ stages, resultCallback = callbackUrl, responseStatus = 200, responseRequests = [], gotoError, afterFieldClick, gotoCallback = false, - alterTypedValue } = {}) { + alterTypedValue, workspaceSelectionSticks = true, workspaceSelectionDelay = 0, readErrors = [], afterWorkspaceRead, gotoNeverCompletes = false } = {}) { const events = {}; const typed = []; const selected = []; const intercepted = []; - const runtime = { contextClosed: 0, browserClosed: 0, options: null, authentication: null, submits: 0, keyboard: [], fieldValues: new Map() }; + const runtime = { contextClosed: 0, browserClosed: 0, options: null, authentication: null, submits: 0, keyboard: [], fieldValues: new Map(), + workspaceValue: 'personal-account', workspaceReads: 0, dropdownClicks: 0, labelClicks: 0 }; const pages = stages || [ { url: 'https://auth.openai.com/log-in', control: 'email' }, { url: 'https://auth.openai.com/log-in/password', control: 'password' }, @@ -42,12 +43,49 @@ function fixture({ stages, resultCallback = callbackUrl, responseStatus = 200, r function handle(info, name) { const fieldKey = `${position}:${name}`; if (!runtime.fieldValues.has(fieldKey)) runtime.fieldValues.set(fieldKey, stage().initialValue || ''); + const selectWorkspace = (value) => { + selected.push(value); + if (workspaceSelectionSticks) { + if (workspaceSelectionDelay) setTimeout(() => { runtime.workspaceValue = value; }, workspaceSelectionDelay); + else runtime.workspaceValue = value; + } + }; + const form = { getAttribute: () => info.action || '', querySelectorAll: () => info.directSubmit ? [] : [{ + tagName: 'INPUT', type: 'hidden', disabled: false, value: runtime.workspaceValue, + }] }; + const visibleRect = () => ({ width: 180, height: 32 }); + const element = { + tagName: info.controlType === 'select' ? 'SELECT' : info.controlType === 'choice' ? 'BUTTON' : 'INPUT', + type: info.controlType === 'choice' ? info.directSubmit ? 'submit' : 'button' : 'radio', + name: info.controlType !== 'choice' || info.directSubmit ? 'workspace_id' : '', + dataset: { value: info.value, kind: info.kind || '' }, + get value() { return info.controlType === 'select' ? runtime.workspaceValue : info.value; }, + get checked() { return runtime.workspaceValue === info.value; }, + labels: [{ innerText: info.label || '', getBoundingClientRect: visibleRect }], + form: info.hasForm === false ? null : form, + closest: () => info.hasForm === false ? null : form, + getBoundingClientRect: () => info.clickLabel ? { width: 0, height: 0 } : visibleRect(), + getAttribute: (attribute) => attribute === 'aria-checked' && !info.detachedChoice + ? String(runtime.workspaceValue === info.value) : null, + options: info.options?.map((option) => ({ value: option.value, textContent: option.label, + disabled: option.disabled, dataset: { kind: option.kind || '' } })), + }; return { - evaluate: async (fn, ...args) => fn.name === 'inspectControl' - ? { visible: true, disabled: false, hasForm: true, action: '', label: '', kind: '', value: '', ...info } - : fn({ value: runtime.fieldValues.get(fieldKey) }, ...args), + evaluate: async (fn, ...args) => { + if (fn.name === 'inspectControl') return { visible: true, disabled: false, hasForm: true, action: '', label: '', kind: '', value: '', ...info }; + if (fn.name === 'inspectWorkspaceControl') return runInNewContext(`(${fn.toString()})(element)`, { + element, window: { getComputedStyle: () => ({ visibility: 'visible', display: 'block' }) }, + }); + return fn({ value: runtime.fieldValues.get(fieldKey) }, ...args); + }, click: async () => { - if (name === 'submit') { + if (name === 'dropdown') { + runtime.dropdownClicks += 1; + stage().dropdownOpen = true; + return; + } + if (name === 'submit' || info.directSubmit) { + if (info.directSubmit) selected.push(info.value); runtime.submits += 1; if (stage().forbiddenResponse) emitForbidden(stage().forbiddenResponse); if (position < pages.length - 1) { position += 1; return; } @@ -60,7 +98,9 @@ function fixture({ stages, resultCallback = callbackUrl, responseStatus = 200, r } else { focused = fieldKey; allSelected = false; - if (name === 'workspace') selected.push(info.value); + if (name === 'workspace') { + selectWorkspace(info.value); + } afterFieldClick?.(pages, position, name); } }, @@ -69,6 +109,13 @@ function fixture({ stages, resultCallback = callbackUrl, responseStatus = 200, r const next = runtime.fieldValues.get(fieldKey) + value; runtime.fieldValues.set(fieldKey, alterTypedValue ? alterTypedValue(next, name) : next); }, + select: async (value) => { selectWorkspace(value); }, + evaluateHandle: async (fn) => { + assert.ok(runInNewContext(`(${fn.toString()})(element)`, { + element, window: { getComputedStyle: () => ({ visibility: 'visible', display: 'block' }) }, + })); + return { asElement: () => ({ click: async () => { runtime.labelClicks += 1; selectWorkspace(info.value); } }), dispose: async () => {} }; + }, }; } const page = { @@ -86,7 +133,8 @@ function fixture({ stages, resultCallback = callbackUrl, responseStatus = 200, r setRequestInterception: async () => {}, on: (name, listener) => { events[name] = listener; }, mainFrame: () => frame, url: () => stage().url, goto: async () => { - if (gotoError) throw new Error(gotoError); + if (gotoNeverCompletes) await new Promise(() => {}); + if (gotoError) throw gotoError instanceof Error ? gotoError : new Error(gotoError); if (gotoCallback) { await events.request({ url: () => resultCallback, method: () => 'GET', isNavigationRequest: () => true, @@ -97,7 +145,11 @@ function fixture({ stages, resultCallback = callbackUrl, responseStatus = 200, r if (responseStatus === 403) emitForbidden({ navigation: true, resourceType: 'document' }); responseRequests.forEach(emitForbidden); }, - evaluate: async (fn) => { + evaluate: async (fn, ...args) => { + if (readErrors.length) throw new Error(readErrors.shift()); + if (fn.name === 'inspectWorkspaceFormSelection') return runInNewContext(`(${fn.toString()})(expected)`, { + expected: args[0], document: { querySelectorAll: () => [{ disabled: false, form: {}, type: 'hidden', value: runtime.workspaceValue }] }, + }); const inspected = runInNewContext(`(${fn.toString()})()`, { document: { body: { innerText: stage().bodyText || '' }, querySelectorAll: () => [] }, window: { getComputedStyle: () => ({ visibility: 'visible', display: 'block' }) }, @@ -106,8 +158,13 @@ function fixture({ stages, resultCallback = callbackUrl, responseStatus = 200, r rejected: Boolean(stage().rejected) || inspected.rejected }; }, $$: async (selector) => { + if (selector.startsWith('button[role="combobox"]')) return stage().dropdown ? [handle({}, 'dropdown')] : []; if (selector.startsWith('button')) return stage().noSubmit ? [] : [handle({ action: stage().submitAction || '' }, 'submit')]; if (selector.startsWith('input[type="radio"]')) { + runtime.workspaceReads += 1; + afterWorkspaceRead?.(stage(), runtime.workspaceReads); + if (stage().dropdown && !stage().dropdownOpen) return []; + if (stage().control === 'workspace-select') return [handle({ controlType: 'select', options: stage().entries }, 'workspace')]; return stage().control === 'workspace' ? (stage().entries || []).map((entry) => handle(entry, 'workspace')) : []; } const name = selector.startsWith('input[type="email"]') ? 'email' @@ -141,7 +198,7 @@ test('ordinary browser form flow submits email, password and TOTP, chooses exact assert.deepEqual(fake.selected, ['target-team']); assert.deepEqual(fake.intercepted, ['abort']); assert.deepEqual(logs.map((entry) => entry.phase), [ - 'browser_start', 'browser_email', 'browser_password', 'browser_totp', 'browser_workspace', 'browser_callback', + 'browser_start', 'browser_navigation', 'browser_email', 'browser_password', 'browser_totp', 'browser_workspace', 'browser_callback', ]); assert.equal(fake.runtime.contextClosed, 1); assert.equal(fake.runtime.browserClosed, 1); @@ -338,7 +395,7 @@ test('Team selection never falls back to personal space or another Team', async url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent', control: 'workspace', entries: [{ value: 'personal', label: 'Personal account' }, { value: 'wrong-team', label: 'Team' }], }] }); - await assert.rejects(fake.login(), { code: 'browser_oauth_workspace_unavailable' }); + await assert.rejects(fake.login({ timeoutMs: 1000 }), { code: 'browser_oauth_workspace_unavailable' }); assert.deepEqual(fake.selected, []); assert.equal(fake.runtime.contextClosed, 1); assert.equal(fake.runtime.browserClosed, 1); @@ -352,6 +409,102 @@ test('Team selection rejects a known personal workspace even when its radio valu ]) assert.throws(() => selectBrowserWorkspace([entry], 'target-team', 'team'), { code: 'browser_oauth_workspace_unavailable' }); }); +test('native workspace dropdown overrides the default personal space by exact Team ID', async () => { + const fake = fixture({ stages: [{ + url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent', control: 'workspace-select', + entries: [{ value: 'personal-account', label: 'Personal account' }, { value: 'wrong-team', label: 'Target Team' }, + { value: 'target-team', label: 'Target Team', kind: 'team' }], + }] }); + assert.deepEqual(await fake.login(), { callbackUrl }); + assert.deepEqual(fake.selected, ['target-team']); + assert.equal(fake.runtime.submits, 1); +}); + +test('hidden workspace radios use their visible label and verify the checked value before Continue', async () => { + const fake = fixture({ stages: [{ url: 'https://auth.openai.com/workspace', control: 'workspace', entries: [ + { value: 'personal-account', label: 'Personal account', clickLabel: true }, + { value: 'target-team', label: 'Team', clickLabel: true }, + ] }] }); + assert.deepEqual(await fake.login(), { callbackUrl }); + assert.deepEqual(fake.selected, ['target-team']); + assert.equal(fake.runtime.labelClicks, 1); +}); + +test('workspace submit buttons submit the exact target directly without a second generic click', async () => { + const fake = fixture({ stages: [{ url: 'https://auth.openai.com/workspace', control: 'workspace', noSubmit: true, entries: [ + { value: 'personal-account', label: 'Personal account', controlType: 'choice', directSubmit: true }, + { value: 'target-team', label: 'Team', controlType: 'choice', directSubmit: true }, + ] }] }); + assert.deepEqual(await fake.login(), { callbackUrl }); + assert.deepEqual(fake.selected, ['target-team']); + assert.equal(fake.runtime.submits, 1); +}); + +test('custom workspace dropdown opens once and confirms its form value after the option disappears', async () => { + const fake = fixture({ stages: [{ url: 'https://auth.openai.com/workspace', control: 'workspace', dropdown: true, entries: [ + { value: 'personal-account', label: 'Personal account', controlType: 'choice', hasForm: false, detachedChoice: true }, + { value: 'target-team', label: 'Team', controlType: 'choice', hasForm: false, detachedChoice: true }, + ] }] }); + assert.deepEqual(await fake.login(), { callbackUrl }); + assert.deepEqual(fake.selected, ['target-team']); + assert.equal(fake.runtime.dropdownClicks, 1); + assert.equal(fake.runtime.submits, 1); +}); + +for (const control of ['workspace', 'workspace-select']) { + test(`a ${control} that remains on personal space after selection never submits consent`, async () => { + const fake = fixture({ workspaceSelectionSticks: false, stages: [{ + url: 'https://auth.openai.com/workspace', control, entries: [ + { value: 'personal-account', label: 'Personal account' }, { value: 'target-team', label: 'Team' }, + ], + }] }); + await assert.rejects(fake.login(), { code: 'browser_oauth_workspace_unconfirmed' }); + assert.equal(fake.runtime.submits, 0); + assert.deepEqual(fake.intercepted, []); + }); +} + +test('partially loaded workspace list waits for the exact Team instead of failing or choosing personal space', async () => { + const fake = fixture({ stages: [{ url: 'https://auth.openai.com/workspace', control: 'workspace', entries: [ + { value: 'personal-account', label: 'Personal account' }, + ] }], afterWorkspaceRead: (stage, reads) => { + if (reads === 2) stage.entries.push({ value: 'target-team', label: 'Team' }); + } }); + const logs = []; + assert.deepEqual(await fake.login({ onProgress: (phase) => logs.push(phase) }), { callbackUrl }); + assert.deepEqual(fake.selected, ['target-team']); + assert.equal(logs.filter((phase) => phase === 'browser_workspace_wait').length, 1); +}); + +test('asynchronous controlled workspace selection waits for its real form value before Continue', async () => { + const fake = fixture({ workspaceSelectionDelay: 50, stages: [{ + url: 'https://auth.openai.com/workspace', control: 'workspace', entries: [ + { value: 'personal-account', label: 'Personal account' }, { value: 'target-team', label: 'Team' }, + ], + }] }); + assert.deepEqual(await fake.login(), { callbackUrl }); + assert.equal(fake.runtime.workspaceValue, 'target-team'); + assert.equal(fake.runtime.submits, 1); +}); + +test('known Team metadata permits a Team name containing Personal without choosing a personal account', () => { + assert.equal(selectBrowserWorkspace([{ value: 'target-team', kind: 'team', label: 'Personal Assistant Team' }], 'target-team').value, 'target-team'); +}); + +test('transient DOM replacement during official page navigation is retried before credential entry', async () => { + const fake = fixture({ readErrors: ['Execution context was destroyed, most likely because of a navigation.'] }); + assert.deepEqual(await fake.login(), { callbackUrl }); + assert.deepEqual(fake.typed.map((entry) => entry.name), ['email', 'password', 'totp']); +}); + +test('an aborted initial navigation may continue only on a recognized official page', async () => { + const fake = fixture({ gotoError: 'net::ERR_ABORTED' }); + assert.deepEqual(await fake.login(), { callbackUrl }); + const unsafe = fixture({ gotoError: 'net::ERR_ABORTED', stages: [{ url: 'https://evil.example.test/log-in', control: 'email' }] }); + await assert.rejects(unsafe.login(), { code: 'browser_oauth_page_unsupported' }); + assert.deepEqual(unsafe.typed, []); +}); + test('Free login accepts a validated direct callback without a workspace selector and lets the runner inspect real claims', async () => { const fake = fixture({ gotoCallback: true }); assert.deepEqual(await fake.login({ workspaceMode: 'free', workspaceId: '' }), { callbackUrl }); @@ -495,16 +648,114 @@ test('browser proxy is applied with separate credentials and never falls back to } }); -test('unexpected raw browser error is sanitized and closes isolated resources', async () => { +test('unexpected raw browser error is sanitized and closes isolated resources', async (t) => { + const diagnostics = []; + t.mock.method(console, 'warn', (...args) => diagnostics.push(args)); const fake = fixture({ gotoError: 'fixture-password fixture-cookie fixture-access-token' }); await assert.rejects(fake.login(), (error) => { assert.ok(error instanceof BrowserOAuthError); assert.equal(error.code, 'browser_oauth_failed'); + assert.equal(error.browserRequired, false); + assert.equal(error.needsInput, false); assert.doesNotMatch(JSON.stringify(error) + error.message, /fixture-password|fixture-cookie|fixture-access-token/); return true; }); assert.equal(fake.runtime.contextClosed, 1); assert.equal(fake.runtime.browserClosed, 1); + assert.equal(diagnostics.length, 1); + assert.deepEqual(JSON.parse(diagnostics[0][1]), { phase: 'navigation', errorName: 'Error', chromiumCode: '', cdpMethod: '' }); + assert.doesNotMatch(JSON.stringify(diagnostics), /fixture-password|fixture-cookie|fixture-access-token/); +}); + +for (const [rawError, expectedCode, expectedStatus] of [ + ['net::ERR_PROXY_CONNECTION_FAILED', 'browser_oauth_proxy_connection_failed', 502], + ['net::ERR_TUNNEL_CONNECTION_FAILED', 'browser_oauth_proxy_connection_failed', 502], + ['net::ERR_SOCKS_CONNECTION_FAILED', 'browser_oauth_proxy_connection_failed', 502], + ['net::ERR_INVALID_AUTH_CREDENTIALS', 'browser_oauth_proxy_connection_failed', 502], + ['net::ERR_PROXY_CERTIFICATE_INVALID', 'browser_oauth_proxy_connection_failed', 502], + ['net::ERR_PROXY_HTTP_1_1_REQUIRED', 'browser_oauth_proxy_connection_failed', 502], + ['net::ERR_CONNECTION_RESET', 'browser_oauth_network_failed', 502], + ['net::ERR_NAME_NOT_RESOLVED', 'browser_oauth_network_failed', 502], + ['net::ERR_TIMED_OUT', 'browser_oauth_network_failed', 502], + ['Protocol error (Page.navigate): Target closed', 'browser_oauth_browser_disconnected', 503], + ['Protocol error: Session closed. Most likely the page has been closed.', 'browser_oauth_browser_disconnected', 503], + ['Navigation timeout of 20000 ms exceeded', 'browser_oauth_navigation_timeout', 504], +]) { + test(`navigation failure reports ${expectedCode} without leaking raw error or requesting manual verification: ${rawError}`, async () => { + const fake = fixture({ gotoError: `${rawError} at https://private-user:private-password@private-proxy.test/path?state=private-state&code=private-code` }); + const logs = []; + await assert.rejects(fake.login({ onProgress: (phase, message) => logs.push({ phase, message }) }), (error) => { + assert.equal(error.code, expectedCode); + assert.equal(error.status, expectedStatus); + assert.equal(error.browserRequired, false); + assert.equal(error.needsInput, false); + assert.doesNotMatch(JSON.stringify(error) + error.message + JSON.stringify(logs), /private-|https?:\/\//); + return true; + }); + assert.deepEqual(logs.map((entry) => entry.phase), ['browser_start', 'browser_navigation', expectedCode]); + assert.deepEqual(fake.typed, []); + assert.equal(fake.runtime.browserClosed, 1); + }); +} + +for (const [name, message, chromiumCode, cdpMethod] of [ + ['Error', 'net::ERR_BLOCKED_BY_CLIENT at https://private-user:private-password@private-proxy.test/?code=private-code', 'ERR_BLOCKED_BY_CLIENT', ''], + ['ProtocolError', 'Protocol error (Page.navigate): ERR_BLOCKED_BY_CLIENT at https://private-proxy.test/?token=private-token', 'ERR_BLOCKED_BY_CLIENT', 'Page.navigate'], + ['ProtocolError', 'Protocol error (Network.setRequestInterception): private-password https://private-proxy.test/?token=private-token', '', 'Network.setRequestInterception'], + ['private-error-name', 'private-password https://private-proxy.test/?ERR_SECRET_NAME=private-token', '', ''], + ['ProtocolError', 'Protocol error (Private.password): private-password', '', ''], +]) { + test(`unknown failure diagnostic only includes approved identifiers: ${name}:${chromiumCode}:${cdpMethod}`, async (t) => { + const diagnostics = []; + t.mock.method(console, 'warn', (...args) => diagnostics.push(args)); + const error = new Error(message); + error.name = name; + const fake = fixture({ gotoError: error }); + await assert.rejects(fake.login(), { code: 'browser_oauth_failed', browserRequired: false, needsInput: false }); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0][0], '[browser-oauth] unclassified browser error'); + assert.deepEqual(JSON.parse(diagnostics[0][1]), { + phase: 'navigation', errorName: name === 'private-error-name' ? 'OtherError' : name, chromiumCode, cdpMethod, + }); + assert.doesNotMatch(JSON.stringify(diagnostics), /private-|https?:|ERR_SECRET_NAME|Private\.password/); + }); +} + +test('Puppeteer Page.navigate protocolTimeout errors are reported as navigation timeouts', async (t) => { + const diagnostics = []; + t.mock.method(console, 'warn', (...args) => diagnostics.push(args)); + const error = new Error("Page.navigate timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed."); + error.name = 'ProtocolError'; + const fake = fixture({ gotoError: error }); + const logs = []; + await assert.rejects(fake.login({ onProgress: (phase) => logs.push(phase) }), { + code: 'browser_oauth_navigation_timeout', status: 504, browserRequired: false, needsInput: false, + }); + assert.equal(logs.at(-1), 'browser_oauth_navigation_timeout'); + assert.equal(diagnostics.length, 0); + assert.deepEqual(fake.typed, []); +}); + +test('navigation deadline is distinguished from waiting for an already loaded authorization page', async () => { + const fake = fixture({ gotoNeverCompletes: true }); + const logs = []; + await assert.rejects(fake.login({ timeoutMs: 1000, onProgress: (phase) => logs.push(phase) }), { + code: 'browser_oauth_navigation_timeout', status: 504, browserRequired: false, needsInput: false, + }); + assert.equal(logs.at(-1), 'browser_oauth_navigation_timeout'); + assert.deepEqual(fake.typed, []); + assert.equal(fake.runtime.browserClosed, 1); +}); + +test('browser disconnect after page navigation receives the same sanitized transport classification', async () => { + const fake = fixture({ readErrors: ['Protocol error (Runtime.callFunctionOn): Target closed; private-debug-endpoint'] }); + const logs = []; + await assert.rejects(fake.login({ onProgress: (phase, message) => logs.push({ phase, message }) }), { + code: 'browser_oauth_browser_disconnected', status: 503, browserRequired: false, needsInput: false, + }); + assert.equal(logs.at(-1).phase, 'browser_oauth_browser_disconnected'); + assert.doesNotMatch(JSON.stringify(logs), /private-debug-endpoint/); + assert.deepEqual(fake.typed, []); }); test('callback with another state is aborted and never returned for token exchange', async () => { diff --git a/server/openai-login.mjs b/server/openai-login.mjs index 4dc409f..83b7915 100644 --- a/server/openai-login.mjs +++ b/server/openai-login.mjs @@ -1108,10 +1108,18 @@ export async function loginFreeAccount(options = {}) { }; } } + const providerTransportFailure = runner.phase === 'oauth_link' + && /^(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/.test(String(error?.code || '')); const providerFailure = Boolean(options.authorizationProvider) && (/^sub2api_oauth_/i.test(String(error?.code || '')) - || /^oauth_provider_/i.test(String(error?.code || ''))); - if (options.singleAttempt !== true && providerFailure && !runner.session.callbackUrl && !options.callbackUrl) { + || /^oauth_provider_/i.test(String(error?.code || '')) || providerTransportFailure); + const localEntryFallback = options.allowLocalAuthorizationFallback === true && options.browserLogin === true + && runner.phase === 'oauth_link' && !runner.session.authorization + && (providerTransportFailure || Number(error?.status) >= 500); + if ((options.singleAttempt !== true || localEntryFallback) && providerFailure && !runner.session.callbackUrl && !options.callbackUrl) { + // An unavailable link provider precedes the first browser login. Use + // local PKCE once without retrying a submitted login or existing code. + runner.progress('oauth_local_fallback', '授权链接服务暂不可用,改用本机 OAuth 入口继续本次登录'); oauthFallback = { code: error?.code || 'oauth_provider_failed', message: error?.message || '外部 OAuth 服务不可用', diff --git a/server/openai-login.test.mjs b/server/openai-login.test.mjs index 7903d48..63d26f7 100644 --- a/server/openai-login.test.mjs +++ b/server/openai-login.test.mjs @@ -1248,6 +1248,35 @@ test('external provider failure falls back to a fresh browser PKCE flow with the assert.equal(providerExchanges, 0); }); +test('single-attempt Roxy may recover a disconnected link provider before its only browser login', async () => { + let browserCalls = 0; + let providerCalls = 0; + const phases = []; + const result = await loginFreeAccount({ + singleAttempt: true, allowLocalAuthorizationFallback: true, browserLogin: true, + email: 'fixture@example.test', password: 'fixture-password', workspaceMode: 'team', workspaceId: 'target-team', + authorizationProvider: async () => { providerCalls += 1; throw Object.assign(new Error('fixture reset'), { code: 'ECONNRESET' }); }, + callbackHandler: async () => { assert.fail('local callback cannot use the failed remote provider'); }, + browserOAuth: async (options) => { + browserCalls += 1; + assert.equal(options.workspaceMode, 'team'); + assert.equal(options.workspaceId, 'target-team'); + assert.ok(new URL(options.authUrl).searchParams.get('code_challenge')); + return { callbackUrl: `http://localhost:1455/auth/callback?code=fixture-local-code&state=${options.expectedState}` }; + }, + fetch: async (url) => { + assert.equal(new URL(url).pathname, '/oauth/token'); + return jsonResponse({ access_token: 'fixture-local-access', refresh_token: 'fixture-local-refresh' }); + }, + onProgress: (phase) => phases.push(phase), + }); + assert.equal(result.ok, true); + assert.equal(browserCalls, 1); + assert.equal(providerCalls, 1); + assert.equal(result.oauthFallback.code, 'ECONNRESET'); + assert.ok(phases.includes('oauth_local_fallback')); +}); + test('single-attempt batch login stops at provider failure without starting fallback authorization', async () => { let providerCalls = 0; let browserCalls = 0; diff --git a/server/refill-pipeline-integration.test.mjs b/server/refill-pipeline-integration.test.mjs index 0107dd6..eee1dd2 100644 --- a/server/refill-pipeline-integration.test.mjs +++ b/server/refill-pipeline-integration.test.mjs @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url'; import test from 'node:test'; const serverScript = fileURLToPath(new URL('./index.mjs', import.meta.url)); +const roxyModule = new URL('./roxy-browser.mjs', import.meta.url).href; const workspaceId = 'refill-pipeline-team'; const personalId = 'refill-pipeline-personal'; const motherId = 'refill-pipeline-mother'; @@ -58,6 +59,9 @@ async function fixture(t, { browserChallenge = false, teamBrowserChallenge = fal const freeToken = token(personalId, childEmail, 'free'); const teamToken = token(workspaceId, childEmail, 'self_serve_business_prolite'); const requests = []; + const roxyProfiles = new Map(); + const openRoxyProfiles = new Set(); + let roxySequence = 0; const authSessions = new Map(); const members = [{ id: 'fixture-owner-user', email: ownerEmail, role: 'account-owner', seat_type: 'default' }]; if (existingMemberCallback) members.push({ id: 'fixture-member-user', email: childEmail, role: 'standard-user', seat_type: 'default' }); @@ -101,6 +105,42 @@ async function fixture(t, { browserChallenge = false, teamBrowserChallenge = fal const claims = requestClaims(req); const entry = { method: req.method, path: url.pathname, identity: claims.sub || '', tokenAccountId: claims['https://api.openai.com/auth']?.chatgpt_account_id || '', accountId: req.headers['chatgpt-account-id'] || '', browserMode: req.headers['x-fixture-browser-mode'] || '' }; requests.push(entry); + if (url.pathname.startsWith('/browser/')) { + assert.equal(req.headers.token, 'fixture-private-roxy'); + let data = {}; + if (req.method === 'GET' && url.pathname === '/browser/list_v3') { + data = { rows: [...roxyProfiles.values()], total: roxyProfiles.size }; + } else if (req.method === 'POST' && url.pathname === '/browser/create') { + assert.equal(body.workspaceId, 'fixture-roxy-workspace'); + const profile = { dirId: `fixture-roxy-${++roxySequence}`, windowRemark: body.windowRemark, windowName: body.windowName }; + roxyProfiles.set(profile.dirId, profile); + data = { dirId: profile.dirId }; + } else if (req.method === 'GET' && url.pathname === '/browser/connection_info') { + const dirId = url.searchParams.get('dirIds'); + data = openRoxyProfiles.has(dirId) ? [{ dirId }] : []; + } else if (req.method === 'POST' && url.pathname === '/browser/open') { + assert.ok(roxyProfiles.has(body.dirId)); + assert.equal(openRoxyProfiles.has(body.dirId), false); + openRoxyProfiles.add(body.dirId); + data = { ws: `ws://127.0.0.1:${upstream.address().port}/devtools/browser/${body.dirId}` }; + } else if (req.method === 'POST' && url.pathname === '/browser/close') { + assert.equal(openRoxyProfiles.delete(body.dirId), true); + } else if (req.method === 'POST' && url.pathname === '/browser/delete') { + assert.equal(body.isSoftDelete, true); + for (const dirId of body.dirIds) { + assert.equal(openRoxyProfiles.has(dirId), false); + assert.equal(roxyProfiles.delete(dirId), true); + } + } else { + entry.unexpected = true; + res.writeHead(503, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ code: 1, msg: 'unexpected_fixture_roxy_request' })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ code: 0, data })); + return; + } const sessionState = String(req.headers.cookie || '').match(/(?:^|;\s*)fixture_oauth_state=([^;]+)/)?.[1] || ''; const session = authSessions.get(sessionState); let status = 200; @@ -209,11 +249,15 @@ async function fixture(t, { browserChallenge = false, teamBrowserChallenge = fal assert.equal(path.dirname(path.resolve(dataDir)), dataRoot); assert.match(path.basename(dataDir), /^refill-pipeline-integration-/); await rm(dataDir, { recursive: true, force: true }); + assert.equal(openRoxyProfiles.size, 0, 'every fixture Roxy session must close'); + const browserStarts = requests.filter((entry) => entry.path === '/mock-auth/oauth/authorize' && entry.browserMode).length; + assert.equal(requests.filter((entry) => entry.path === '/browser/open').length, browserStarts, 'each browser OAuth flow acquires a Roxy session'); + assert.equal(requests.filter((entry) => entry.path === '/browser/close').length, browserStarts, 'successful and challenged browser flows close their Roxy sessions'); }); await writeFile(path.join(dataDir, 'state.json'), JSON.stringify({ version: 1, settings: { autoRefill: false, promoteJoinedAccounts: false, delayedKickEnabled: false, concurrency: 2, joinMode: 'invite', inviteSeatType: 'default', - ...(roxyEnabled ? { integrations: { roxy: { enabled: true, baseUrl: 'http://127.0.0.1:50000', apiKey: 'fixture-private-roxy', workspaceId: 'fixture-roxy-workspace', profiles: [] } } } : {}), + ...(roxyEnabled ? { integrations: { roxy: { enabled: true, baseUrl: `http://127.0.0.1:${upstreamPort}`, apiKey: 'fixture-private-roxy', workspaceId: 'fixture-roxy-workspace', profiles: [] } } } : {}), }, mothers: [{ id: motherId, team: workspaceId, accountId: workspaceId, teamName: 'Refill Pipeline Fixture', email: ownerEmail, primaryOwnerEmail: ownerEmail, accessToken: ownerToken, defaultSeatType: 'default', members: [] }], children: [child], @@ -222,10 +266,12 @@ async function fixture(t, { browserChallenge = false, teamBrowserChallenge = fal const port = await unusedPort(); const callbackPort = teamBrowserChallenge ? await unusedPort() : null; const baseUrl = `http://127.0.0.1:${port}`; - // Only the UI driver is replaced. The normal callback exchange, identity - // checks, invitation, membership confirmation, and quota requests run intact. + // Replace only browser I/O. Roxy profile/session lifecycle, callback exchange, + // identity checks, invitation, membership confirmation, and quota run intact. const browserFixture = ` export async function completeBrowserOAuth(options) { + const session = await options.acquireSession({ timeoutMs: 1000 }); + try { const authorization = new URL(options.authUrl); if (authorization.origin !== 'https://auth.openai.com' || authorization.pathname !== '/oauth/authorize' || authorization.searchParams.get('state') !== options.expectedState @@ -245,11 +291,26 @@ async function fixture(t, { browserChallenge = false, teamBrowserChallenge = fal options.onProgress?.('browser_workspace', '正在选择目标工作空间'); const selected = await request('/api/accounts/workspace/select', { workspace_id: options.workspaceMode === 'team' ? options.workspaceId : ${JSON.stringify(personalId)} }); return { callbackUrl: (await selected.json()).continue_url }; + } finally { await session.close({ keepOpen: false }); } } `; const preload = ` import { registerHooks } from 'node:module'; - registerHooks({ load(url, context, nextLoad) { + registerHooks({ resolve(specifier, context, nextResolve) { + if (specifier === './roxy-browser.mjs') return { url: 'fixture:refill-roxy-session', shortCircuit: true }; + return nextResolve(specifier, context); + }, load(url, context, nextLoad) { + if (url === 'fixture:refill-roxy-session') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(` + export * from ${JSON.stringify(roxyModule)}; + import { openRoxyBrowserSession as actualOpen } from ${JSON.stringify(roxyModule)}; + export function openRoxyBrowserSession(options) { + return actualOpen(options, { connect: async () => ({ + newPage: async () => ({ close: async () => {} }), + createBrowserContext: async () => ({ newPage: async () => ({ close: async () => {} }), close: async () => {} }), + disconnect: async () => {}, + }) }); + } + `)} }; if (url.endsWith('/openai-browser-login.mjs')) return { format: 'module', shortCircuit: true, source: ${JSON.stringify(browserFixture)} }; if (url.endsWith('/openai-sentinel.mjs')) return { format: 'module', shortCircuit: true, source: 'export async function fetchSentinelToken() { return "fixture-sentinel-only"; }' }; return nextLoad(url, context); @@ -257,10 +318,9 @@ async function fixture(t, { browserChallenge = false, teamBrowserChallenge = fal const nativeFetch = globalThis.fetch; globalThis.fetch = async (input, options) => { const url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url); - if (url.port === '50000') throw new Error('isolated_test_roxy_api_disabled'); if (url.hostname === 'auth.openai.com') return nativeFetch(${JSON.stringify(`http://127.0.0.1:${upstreamPort}/mock-auth`)} + url.pathname + url.search, options); if (url.hostname === 'sentinel.openai.com') return nativeFetch(${JSON.stringify(`http://127.0.0.1:${upstreamPort}/mock-sentinel`)} + url.pathname + url.search, options); - if (url.hostname !== '127.0.0.1') throw new Error('isolated_test_external_network_disabled'); + if (url.hostname !== '127.0.0.1' || Number(url.port) !== ${upstreamPort}) throw new Error('isolated_test_external_network_disabled'); return nativeFetch(input, options); }; `; diff --git a/server/refill-team-verification-integration.test.mjs b/server/refill-team-verification-integration.test.mjs index 602ab7c..a5137d3 100644 --- a/server/refill-team-verification-integration.test.mjs +++ b/server/refill-team-verification-integration.test.mjs @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url'; import test from 'node:test'; const serverScript = fileURLToPath(new URL('./index.mjs', import.meta.url)); +const roxyModule = new URL('./roxy-browser.mjs', import.meta.url).href; const workspaceId = 'refill-browser-team-workspace'; const motherId = 'refill-browser-team-mother'; const childId = 'refill-browser-team-child'; @@ -40,6 +41,9 @@ async function unusedPort() { async function fixture(t, { teamAuthSessions = {}, teamLoginStates = {}, browserChallenge = true, noMfa = false } = {}) { const requests = []; + const roxyProfiles = new Map([['fixture-child-profile', { dirId: 'fixture-child-profile', windowRemark: `auto-team:child:${childId}` }]]); + const openRoxyProfiles = new Set(); + let roxySequence = 0; const grants = []; const browserSessions = new Map(); const freeToken = token(childEmail, childId, 'refill-browser-personal-workspace', 'free'); @@ -54,6 +58,43 @@ async function fixture(t, { teamAuthSessions = {}, teamLoginStates = {}, browser for await (const chunk of req) raw += chunk; const entry = { method: req.method, path: url.pathname, browserMode: req.headers['x-fixture-browser-mode'] || '' }; requests.push(entry); + if (url.pathname.startsWith('/browser/')) { + assert.equal(req.headers.token, 'fixture-private-roxy'); + const body = raw ? JSON.parse(raw) : null; + let data = {}; + if (req.method === 'GET' && url.pathname === '/browser/list_v3') { + data = { rows: [...roxyProfiles.values()], total: roxyProfiles.size }; + } else if (req.method === 'POST' && url.pathname === '/browser/create') { + assert.equal(body.workspaceId, 'fixture-roxy-workspace'); + const profile = { dirId: `fixture-roxy-${++roxySequence}`, windowRemark: body.windowRemark, windowName: body.windowName }; + roxyProfiles.set(profile.dirId, profile); + data = { dirId: profile.dirId }; + } else if (req.method === 'GET' && url.pathname === '/browser/connection_info') { + const dirId = url.searchParams.get('dirIds'); + data = openRoxyProfiles.has(dirId) ? [{ dirId }] : []; + } else if (req.method === 'POST' && url.pathname === '/browser/open') { + assert.ok(roxyProfiles.has(body.dirId)); + assert.equal(openRoxyProfiles.has(body.dirId), false); + openRoxyProfiles.add(body.dirId); + data = { ws: `ws://127.0.0.1:${upstream.address().port}/devtools/browser/${body.dirId}` }; + } else if (req.method === 'POST' && url.pathname === '/browser/close') { + assert.equal(openRoxyProfiles.delete(body.dirId), true); + } else if (req.method === 'POST' && url.pathname === '/browser/delete') { + assert.equal(body.isSoftDelete, true); + for (const dirId of body.dirIds) { + assert.equal(openRoxyProfiles.has(dirId), false); + assert.equal(roxyProfiles.delete(dirId), true); + } + } else { + entry.unexpected = true; + res.writeHead(503, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ code: 1, msg: 'unexpected_fixture_roxy_request' })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ code: 0, data })); + return; + } if (url.pathname === '/mock-auth/oauth/authorize') { entry.state = url.searchParams.get('state'); browserSessions.set(entry.state, { redirectUri: url.searchParams.get('redirect_uri') }); @@ -106,7 +147,7 @@ async function fixture(t, { teamAuthSessions = {}, teamLoginStates = {}, browser await writeFile(path.join(dataDir, 'state.json'), JSON.stringify({ version: 1, settings: { autoRefill: false, promoteJoinedAccounts: false, joinMode: 'invite', concurrency: 1, kickOnExhausted: false, - integrations: { roxy: { enabled: true, baseUrl: 'http://127.0.0.1:50000', apiKey: 'fixture-private-roxy', workspaceId: 'fixture-roxy-workspace', profiles: [{ childId, dirId: 'fixture-child-profile' }] } }, + integrations: { roxy: { enabled: true, baseUrl: `http://127.0.0.1:${upstreamPort}`, apiKey: 'fixture-private-roxy', workspaceId: 'fixture-roxy-workspace', profiles: [{ childId, dirId: 'fixture-child-profile' }] } }, }, mothers: [{ id: motherId, team: workspaceId, accountId: workspaceId, email: 'refill-owner@example.test', primaryOwnerEmail: 'refill-owner@example.test', rotationMode: 'fixed', accessToken: token('refill-owner@example.test', 'refill-owner-user', workspaceId, 'team') }], children: [{ @@ -119,8 +160,11 @@ async function fixture(t, { teamAuthSessions = {}, teamLoginStates = {}, browser }], history: [], })); + // Keep the real Roxy adapter and lifecycle; replace only browser I/O. const browserFixture = ` export async function completeBrowserOAuth(options) { + const session = await options.acquireSession({ timeoutMs: 1000 }); + try { const authorization = new URL(options.authUrl); if (authorization.origin !== 'https://auth.openai.com' || authorization.pathname !== '/oauth/authorize' || authorization.searchParams.get('state') !== options.expectedState @@ -135,20 +179,34 @@ async function fixture(t, { teamAuthSessions = {}, teamLoginStates = {}, browser if (${JSON.stringify(browserChallenge)}) throw Object.assign(new Error('登录需要完成官方安全验证'), { code: 'browser_verification_required', status: 202, browserRequired: true, needsInput: true }); const selected = await fetch('https://auth.openai.com/browser/workspace/select', { method: 'POST', headers, body: JSON.stringify({ state: options.expectedState, workspaceId: options.workspaceId }) }); return selected.json(); + } finally { await session.close({ keepOpen: false }); } } `; const preload = ` import { registerHooks } from 'node:module'; - registerHooks({ load(url, context, nextLoad) { + registerHooks({ resolve(specifier, context, nextResolve) { + if (specifier === './roxy-browser.mjs') return { url: 'fixture:refill-roxy-session', shortCircuit: true }; + return nextResolve(specifier, context); + }, load(url, context, nextLoad) { + if (url === 'fixture:refill-roxy-session') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(` + export * from ${JSON.stringify(roxyModule)}; + import { openRoxyBrowserSession as actualOpen } from ${JSON.stringify(roxyModule)}; + export function openRoxyBrowserSession(options) { + return actualOpen(options, { connect: async () => ({ + newPage: async () => ({ close: async () => {} }), + createBrowserContext: async () => ({ newPage: async () => ({ close: async () => {} }), close: async () => {} }), + disconnect: async () => {}, + }) }); + } + `)} }; if (url.endsWith('/openai-browser-login.mjs')) return { format: 'module', shortCircuit: true, source: ${JSON.stringify(browserFixture)} }; return nextLoad(url, context); } }); const nativeFetch = globalThis.fetch; globalThis.fetch = async (input, options) => { const url = new URL(typeof input === 'string' || input instanceof URL ? String(input) : input.url); - if (url.port === '50000') throw new Error('isolated_test_roxy_api_disabled'); if (url.hostname === 'auth.openai.com') return nativeFetch(${JSON.stringify(`http://127.0.0.1:${upstreamPort}/mock-auth`)} + url.pathname + url.search, options); - if (url.hostname !== '127.0.0.1') throw new Error('isolated_external_network_disabled'); + if (url.hostname !== '127.0.0.1' || Number(url.port) !== ${upstreamPort}) throw new Error('isolated_external_network_disabled'); return nativeFetch(input, options); }; `; @@ -166,6 +224,11 @@ async function fixture(t, { teamAuthSessions = {}, teamLoginStates = {}, browser upstream.closeAllConnections?.(); await new Promise((resolve) => upstream.close(resolve)); await rm(dataDir, { recursive: true, force: true }); + assert.equal(openRoxyProfiles.size, 0, 'every fixture Roxy session must close'); + const browserStarts = requests.filter((entry) => entry.path === '/mock-auth/oauth/authorize' && entry.browserMode).length; + assert.equal(requests.filter((entry) => entry.path === '/browser/open').length, browserStarts, 'each browser OAuth flow acquires a Roxy session'); + assert.equal(requests.filter((entry) => entry.path === '/browser/close').length, browserStarts, 'successful and challenged browser flows close their Roxy sessions'); + assert.equal(requests.some((entry) => entry.unexpected), false); }); const baseUrl = `http://127.0.0.1:${port}`; for (let attempt = 0; attempt < 100; attempt += 1) { diff --git a/server/roxy-browser.mjs b/server/roxy-browser.mjs index 0eb07d4..af6142d 100644 --- a/server/roxy-browser.mjs +++ b/server/roxy-browser.mjs @@ -28,11 +28,14 @@ const MESSAGES = { 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 密钥无效或没有访问权限', @@ -86,6 +89,12 @@ export function normalizeRoxyConfig(input = {}, previous = {}) { 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 }); @@ -101,8 +110,32 @@ export function normalizeRoxyConfig(input = {}, previous = {}) { 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; @@ -118,6 +151,10 @@ export function publicRoxyConfig(config = {}) { 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 } : {}), }; } @@ -216,6 +253,9 @@ export function createRoxyApiClient(config = {}, { fetchImpl = globalThis.fetch, 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); } @@ -276,16 +316,77 @@ export function createRoxyApiClient(config = {}, { fetchImpl = globalThis.fetch, 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) return { ...bound, source: 'bound' }; - if (normalized.profiles.length >= 1000) fail('roxy_profile_limit', 409); + 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(); @@ -306,6 +407,9 @@ export async function resolveRoxyAccountProfile(config, childId, { email = '', c 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)) { @@ -316,7 +420,65 @@ export async function resolveRoxyAccountProfile(config, childId, { email = '', c windowRemark: marker, }); if (normalized.profiles.some((entry) => entry.dirId === profile.dirId)) fail('roxy_profile_conflict', 409); - return { childId: id, dirId: profile.dirId, source: existing ? 'recovered' : 'created' }; + 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) { @@ -365,8 +527,8 @@ export function createRoxyCdpTransport(endpoint, { timeoutMs = 30000, WebSocketI }); } -async function connectRoxyBrowser({ browserWSEndpoint, ...options }) { - const transport = await createRoxyCdpTransport(browserWSEndpoint, { timeoutMs: options.protocolTimeout }); +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); } } @@ -401,13 +563,19 @@ function retainProfileRelay(leaseKey, relay, api, dirId) { retained.timer.unref?.(); } -export async function openRoxyBrowserSession({ config, childId, proxyEntry, localProxyUrl = '', timeoutMs = 30000 }, { +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 profile = normalized.profiles.find((entry) => entry.childId === childId); + 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); @@ -418,18 +586,33 @@ export async function openRoxyBrowserSession({ config, childId, proxyEntry, loca const api = client || createRoxyApiClient(normalized, { timeoutMs }); let browser; let page; + let context; let owned = false; - let closed = false; + let closePromise; let relay; - const close = async ({ keepOpen = false } = {}) => { - if (closed) return; - closed = true; - let retainWindow = keepOpen === true && normalized.headless === false && owned && browser && page; + let browserProxy; + const close = ({ keepOpen = false } = {}) => closePromise ||= (async () => { + let retainWindow = !pooled && keepOpen === true && normalized.headless === false && owned && browser && page; try { - if (!retainWindow) await page?.close().catch(() => {}); + 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); } + 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 { @@ -440,10 +623,13 @@ export async function openRoxyBrowserSession({ config, childId, proxyEntry, loca } } 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) { @@ -452,18 +638,52 @@ export async function openRoxyBrowserSession({ config, childId, proxyEntry, loca } // 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. - await api.updateProxy(normalized.workspaceId, profile.dirId, relay ? { ...proxyEntry, url: relay.url } : proxyEntry); + 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); - try { - browser = await connect({ browserWSEndpoint, defaultViewport: null, - protocolTimeout: Math.min(60000, Math.max(1000, Number(timeoutMs) || 30000)) }); - // A dedicated existing profile retains its normal login session; no incognito or fingerprint changes. - page = await browser.newPage(); - } catch { fail('roxy_connect_failed', 503); } - return { page, close, keepOpenOnFailure: normalized.headless === false }; + 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 }); diff --git a/server/roxy-browser.test.mjs b/server/roxy-browser.test.mjs index 2a06a22..d9e2203 100644 --- a/server/roxy-browser.test.mjs +++ b/server/roxy-browser.test.mjs @@ -4,7 +4,7 @@ import { createServer } from 'node:http'; import { DEFAULT_ROXY_CONFIG, RoxyBrowserError, normalizeRoxyApiBase, normalizeRoxyConfig, publicRoxyConfig, createRoxyApiClient, validateRoxyCdpEndpoint, createRoxyCdpTransport, openRoxyBrowserSession, - roxyProxyInfo, resolveRoxyAccountProfile, + roxyProxyInfo, resolveRoxyAccountProfile, resolveRoxyPoolProfile, deleteClosedRoxyProfile, } from './roxy-browser.mjs'; const config = { @@ -77,6 +77,38 @@ test('Roxy config defaults old installations to headed mode and preserves fixed assert.throws(() => normalizeRoxyConfig({ profiles: [{ ...profiles[0], proxyId: 'invalid/id' }] }, config), codeIs('roxy_config_invalid')); }); +test('Roxy cleanup preference is opt-in, survives partial patches and accepts only booleans', () => { + assert.equal(Object.hasOwn(normalizeRoxyConfig(config), 'deleteProfileAfterSuccess'), false); + assert.equal(Object.hasOwn(publicRoxyConfig(config), 'deleteProfileAfterSuccess'), false); + const enabled = normalizeRoxyConfig({ deleteProfileAfterSuccess: true }, config); + assert.equal(enabled.deleteProfileAfterSuccess, true); + assert.equal(publicRoxyConfig(enabled).deleteProfileAfterSuccess, true); + assert.equal(normalizeRoxyConfig({ concurrency: 2 }, enabled).deleteProfileAfterSuccess, true); + const disabled = normalizeRoxyConfig({ deleteProfileAfterSuccess: false }, enabled); + assert.equal(disabled.deleteProfileAfterSuccess, false); + assert.equal(publicRoxyConfig(disabled).deleteProfileAfterSuccess, false); + for (const deleteProfileAfterSuccess of ['true', 'false', 0, 1, null, [], {}]) { + assert.throws(() => normalizeRoxyConfig({ deleteProfileAfterSuccess }, config), codeIs('roxy_config_invalid')); + } +}); + +test('Roxy failure cleanup is an independent opt-in and preserves success cleanup on partial patches', () => { + assert.equal(Object.hasOwn(normalizeRoxyConfig(config), 'deleteProfileAfterFailure'), false); + assert.equal(Object.hasOwn(publicRoxyConfig(config), 'deleteProfileAfterFailure'), false); + const enabled = normalizeRoxyConfig({ deleteProfileAfterSuccess: true, deleteProfileAfterFailure: true }, config); + assert.equal(publicRoxyConfig(enabled).deleteProfileAfterFailure, true); + const patched = normalizeRoxyConfig({ concurrency: 2 }, enabled); + assert.equal(patched.deleteProfileAfterFailure, true); + assert.equal(patched.deleteProfileAfterSuccess, true); + const disabled = normalizeRoxyConfig({ deleteProfileAfterFailure: false }, enabled); + assert.equal(disabled.deleteProfileAfterFailure, false); + assert.equal(publicRoxyConfig(disabled).deleteProfileAfterFailure, false); + assert.equal(disabled.deleteProfileAfterSuccess, true); + for (const deleteProfileAfterFailure of ['true', 'false', 0, 1, null, [], {}]) { + assert.throws(() => normalizeRoxyConfig({ deleteProfileAfterFailure }, config), codeIs('roxy_config_invalid')); + } +}); + test('Roxy proxy conversion uses documented fields, credentials and supported protocols only', () => { assert.deepEqual(roxyProxyInfo({ url: 'http://127.0.0.1:7897' }), { proxyMethod: 'custom', proxyCategory: 'HTTP', ipType: 'IPV4', @@ -200,6 +232,8 @@ test('Roxy safe errors do not forward provider secrets or unsanitized provider t [json({ msg: config.apiKey }, { status: 429 }), 'roxy_api_rate_limited'], [json({ msg: config.apiKey }, { status: 500 }), 'roxy_api_failed'], [json({ code: 500, msg: `订阅已过期 ${config.apiKey}` }), 'roxy_entitlement_unavailable'], + [json({ code: 500, msg: `浏览器窗口数量已达到套餐上限 ${config.apiKey}` }), 'roxy_profile_capacity'], + [json({ code: 500, msg: `Profile limit reached ${config.apiKey}` }), 'roxy_profile_capacity'], [json({ code: 500, msg: config.apiKey }), 'roxy_api_failed'], [json({ code: '0', msg: config.apiKey }), 'roxy_response_invalid'], [new Response('not-json'), 'roxy_response_invalid'], @@ -227,6 +261,125 @@ test('Roxy profile creation uses the official body and returns only its validate await assert.rejects(() => invalid.createProfile('149202', { windowName: 'fixture', windowRemark: 'fixture' }), codeIs('roxy_response_invalid')); }); +test('Roxy soft deletion explicitly sends a single profile to Trash and redacts provider errors', async () => { + const calls = []; + const client = createRoxyApiClient(config, { fetchImpl: async (url, options) => { + calls.push({ path: new URL(url).pathname, method: options.method, headers: options.headers, body: JSON.parse(options.body) }); + return json({ code: 0, msg: 'success' }); + } }); + await client.softDeleteProfile('149202', 'profile_test'); + assert.deepEqual(calls, [{ path: '/browser/delete', method: 'POST', + headers: { token: config.apiKey, 'content-type': 'application/json' }, + body: { workspaceId: '149202', dirIds: ['profile_test'], isSoftDelete: true } }]); + await assert.rejects(() => client.softDeleteProfile('149202', ['one', 'two']), codeIs('roxy_config_invalid')); + assert.equal(calls.length, 1); + for (const fetchImpl of [ + async () => json({ code: 500, msg: config.apiKey }), + async () => json({ msg: config.apiKey }, { status: 403 }), + async () => { throw new Error(config.apiKey); }, + ]) { + const failing = createRoxyApiClient(config, { fetchImpl }); + await assert.rejects(() => failing.softDeleteProfile('149202', 'profile_test'), codeIs('roxy_profile_delete_failed')); + } +}); + +test('Roxy deletes only a closed tool profile after complete inventory and confirms its absence across all pages', async () => { + for (const windowRemark of ['auto-team:child:child_test', 'auto-team:pool:0', 'auto-team:pool:1', 'auto-team:pool:2']) { + const calls = []; + let rows = [{ dirId: 'user_one', windowRemark: '' }, { dirId: 'profile_test', windowRemark }, { dirId: 'user_two', windowRemark: '' }]; + const client = { + listProfiles: async (workspaceId, { pageIndex, pageSize }) => { + assert.equal(workspaceId, config.workspaceId); + assert.equal(pageSize, 100); + calls.push(['list', pageIndex]); + return { total: rows.length, rows: rows.slice(pageIndex - 1, pageIndex) }; + }, + connectionInfo: async (dirId) => { calls.push(['connections', dirId]); return []; }, + softDeleteProfile: async (workspaceId, dirId) => { + calls.push(['delete', workspaceId, dirId]); + rows = rows.filter((entry) => entry.dirId !== dirId); + }, + }; + assert.deepEqual(await deleteClosedRoxyProfile(config, 'profile_test', { client }), { deleted: true }); + assert.deepEqual(calls, [['list', 1], ['list', 2], ['list', 3], ['connections', 'profile_test'], + ['delete', config.workspaceId, 'profile_test'], ['list', 1], ['list', 2]]); + assert.deepEqual(config.profiles, [{ childId: 'child_test', dirId: 'profile_test' }], 'remote cleanup does not mutate local JSON bindings'); + } +}); + +test('Roxy deletion is idempotent for an already absent environment and refuses manual or open environments', async () => { + const client = { + listProfiles: async () => ({ total: 0, rows: [] }), + connectionInfo: async () => { assert.fail('unowned or absent environments must not be probed'); }, + softDeleteProfile: async () => { assert.fail('unowned, absent or busy environments must not be deleted'); }, + }; + assert.deepEqual(await deleteClosedRoxyProfile(config, 'profile_test', { client }), { deleted: false, absent: true }); + for (const windowRemark of ['', 'personal work', 'auto-team:pool:3', 'auto-team:child:', 'prefix auto-team:pool:0']) { + await assert.rejects(() => deleteClosedRoxyProfile(config, 'profile_test', { client: { + ...client, listProfiles: async () => ({ total: 1, rows: [{ dirId: 'profile_test', windowRemark }] }), + } }), codeIs('roxy_profile_conflict')); + } + await assert.rejects(() => deleteClosedRoxyProfile(config, 'profile_test', { client: { + ...client, + listProfiles: async () => ({ total: 1, rows: [{ dirId: 'profile_test', windowRemark: 'auto-team:pool:0' }] }), + connectionInfo: async () => [{ dirId: 'profile_test' }], + } }), codeIs('roxy_profile_busy')); +}); + +test('Roxy never deletes from incomplete inventory or reports deletion without confirming a complete final inventory', async () => { + const owned = { dirId: 'profile_test', windowRemark: 'auto-team:pool:0' }; + for (const scenario of ['incomplete_before', 'list_error', 'connection_error', 'incomplete_after', 'still_present', 'delete_error', 'verify_error']) { + let deleted = false; + const client = { + listProfiles: async () => { + if (scenario === 'list_error' || (deleted && scenario === 'verify_error')) throw new Error(config.apiKey); + const incomplete = scenario === 'incomplete_before' || (deleted && scenario === 'incomplete_after'); + return { total: incomplete ? 2 : 1, rows: [owned] }; + }, + connectionInfo: async () => { + if (scenario === 'connection_error') throw new Error(config.apiKey); + return []; + }, + softDeleteProfile: async () => { + deleted = true; + if (scenario === 'delete_error') throw new Error(config.apiKey); + }, + }; + await assert.rejects(() => deleteClosedRoxyProfile(config, 'profile_test', { client }), + codeIs(scenario === 'incomplete_before' ? 'roxy_profile_list_incomplete' : 'roxy_profile_delete_failed')); + assert.equal(deleted, !['incomplete_before', 'list_error', 'connection_error'].includes(scenario)); + } +}); + +test('Roxy deletion respects an active local browser lease and prevents opening while cleanup is pending', async () => { + const fake = fakeSession(); + const session = await openRoxyBrowserSession({ config, childId: 'child_test' }, fake); + try { + await assert.rejects(() => deleteClosedRoxyProfile(config, 'profile_test', { client: { + listProfiles: async () => { assert.fail('active local sessions must not be inspected for deletion'); }, + } }), codeIs('roxy_profile_busy')); + } finally { await session.close(); } + + let resolveDelete; + let reachedDelete; + let deleted = false; + const deleting = new Promise((resolve) => { reachedDelete = resolve; }); + const pending = new Promise((resolve) => { resolveDelete = resolve; }); + const client = { + listProfiles: async () => ({ total: deleted ? 0 : 1, + rows: deleted ? [] : [{ dirId: 'profile_test', windowRemark: 'auto-team:pool:0' }] }), + connectionInfo: async () => [], + softDeleteProfile: async () => { reachedDelete(); await pending; deleted = true; }, + }; + const cleanup = deleteClosedRoxyProfile(config, 'profile_test', { client }); + await deleting; + try { + await assert.rejects(() => openRoxyBrowserSession({ config, childId: 'child_test' }, fakeSession()), codeIs('roxy_profile_busy')); + } finally { resolveDelete(); } + assert.deepEqual(await cleanup, { deleted: true }); + assert.deepEqual(await deleteClosedRoxyProfile(config, 'profile_test', { client }), { deleted: false, absent: true }); +}); + test('Roxy automatic profile recovery matches a stable account marker across all pages and never reuses a name alone', async () => { const unbound = { ...config, profiles: [] }; const pages = []; @@ -246,9 +399,56 @@ test('Roxy automatic profile recovery matches a stable account marker across all assert.deepEqual(pages, [1, 2]); assert.equal(created, 0); assert.deepEqual(await resolveRoxyAccountProfile(config, 'child_test', { client }), { - ...config.profiles[0], source: 'bound', + childId: 'child_test', dirId: 'recovered_profile', source: 'recovered', }); - assert.deepEqual(pages, [1, 2], 'a stored binding is reused without searching or creating'); + assert.deepEqual(pages, [1, 2, 1, 2], 'a deleted stored binding is repaired only after a complete remote search'); + assert.equal(created, 0); +}); + +test('Roxy validates every page before retaining a manual account binding without a tool marker', async () => { + const pages = []; + const profiles = [ + { dirId: 'unrelated', windowRemark: '' }, + { dirId: 'profile_test', windowRemark: 'manually selected environment' }, + ]; + const client = { + listProfiles: async (_workspace, { pageIndex }) => { + pages.push(pageIndex); + return { total: profiles.length, rows: [profiles[pageIndex - 1]] }; + }, + createProfile: async () => { assert.fail('an existing manual binding must be retained'); }, + }; + const bound = { ...config, profiles: [{ ...config.profiles[0], proxyId: 'proxy_fixed' }] }; + assert.deepEqual(await resolveRoxyAccountProfile(bound, 'child_test', { client }), { + ...bound.profiles[0], source: 'bound', + }); + assert.deepEqual(pages, [1, 2]); + await assert.rejects(() => resolveRoxyAccountProfile(bound, 'child_test', { client: { + ...client, + listProfiles: async () => ({ total: 2, rows: [profiles[1]] }), + } }), codeIs('roxy_profile_list_incomplete')); +}); + +test('Roxy replaces deleted account bindings at the binding limit while preserving the fixed proxy', async () => { + const full = { ...config, profiles: Array.from({ length: 1000 }, (_, index) => ({ + childId: `child_${index}`, dirId: `profile_${index}`, ...(index === 0 ? { proxyId: 'proxy_fixed' } : {}), + })) }; + for (const recover of [true, false]) { + let created = 0; + const client = { + listProfiles: async () => ({ total: recover ? 1 : 0, rows: recover + ? [{ dirId: 'replacement', windowRemark: 'auto-team:child:child_0' }] : [] }), + createProfile: async (_workspace, body) => { + created += 1; + assert.equal(body.windowRemark, 'auto-team:child:child_0'); + return { dirId: 'replacement' }; + }, + }; + assert.deepEqual(await resolveRoxyAccountProfile(full, 'child_0', { client }), { + childId: 'child_0', dirId: 'replacement', proxyId: 'proxy_fixed', source: recover ? 'recovered' : 'created', + }); + assert.equal(created, recover ? 0 : 1); + } }); test('Roxy creates isolated account profiles and refuses ambiguous, incomplete or conflicting recovery', async () => { @@ -318,6 +518,7 @@ test('Roxy handles aborted requests, oversized response streams and malformed pr function fakeSession({ existing = [], openFailure, proxyFailure, connectFailure, ws = endpoint, cleanupFailure } = {}) { const calls = []; + let time = 0; const page = { async close() { calls.push('page-close'); } }; const client = { async connectionInfo(dirId) { calls.push(['connections', dirId]); return existing; }, @@ -331,7 +532,7 @@ function fakeSession({ existing = [], openFailure, proxyFailure, connectFailure, return { async newPage() { calls.push('new-page'); return page; }, async disconnect() { calls.push('disconnect'); }, async close() { assert.fail('An attached browser must never receive browser.close()'); } }; }; - return { calls, client, connect, page }; + return { calls, client, connect, page, clock: () => time, wait: async (ms) => { time += ms; } }; } test('Roxy opens a mapped normal profile, creates one page and closes only the owned window', async () => { @@ -344,7 +545,7 @@ test('Roxy opens a mapped normal profile, creates one page and closes only the o assert.equal(fake.calls[2][1].defaultViewport, null); await session.close(); await session.close(); - assert.deepEqual(fake.calls.slice(-3), ['page-close', 'disconnect', ['api-close', 'profile_test']]); + assert.deepEqual(fake.calls.slice(-4), ['page-close', 'disconnect', ['api-close', 'profile_test'], ['connections', 'profile_test']]); }); test('Roxy headed failure can leave the page open for inspection and releases the automation lease', async () => { @@ -372,7 +573,7 @@ test('Roxy headless sessions always clean up even when asked to keep the failed const session = await openRoxyBrowserSession({ config: { ...config, headless: true }, childId: 'child_test' }, fake); assert.equal(session.keepOpenOnFailure, false); await session.close({ keepOpen: true }); - assert.deepEqual(fake.calls.slice(-3), ['page-close', 'disconnect', ['api-close', 'profile_test']]); + assert.deepEqual(fake.calls.slice(-4), ['page-close', 'disconnect', ['api-close', 'profile_test'], ['connections', 'profile_test']]); }); test('Roxy pins the selected proxy before headless launch and fails closed when it cannot be applied', async () => { @@ -438,7 +639,7 @@ test('Roxy failure cleanup is bounded to owned windows and preserves safe error for (const [options, code] of [[{ connectFailure: new Error(config.apiKey) }, 'roxy_connect_failed'], [{ ws: 'ws://evil.test:50001/devtools/browser/test' }, 'roxy_cdp_invalid']]) { const fake = fakeSession(options); await assert.rejects(() => openRoxyBrowserSession({ config, childId: 'child_test' }, fake), codeIs(code)); - assert.deepEqual(fake.calls.at(-1), ['api-close', 'profile_test']); + assert.deepEqual(fake.calls.slice(-2), [['api-close', 'profile_test'], ['connections', 'profile_test']]); } const failedCleanup = fakeSession({ cleanupFailure: new Error(config.apiKey) }); const session = await openRoxyBrowserSession({ config, childId: 'child_test' }, failedCleanup); @@ -447,3 +648,384 @@ test('Roxy failure cleanup is bounded to owned windows and preserves safe error const retry = await openRoxyBrowserSession({ config, childId: 'child_test' }, after); await retry.close(); }); + +test('Roxy records an idle profile before failed startup but never claims an already-open profile', async () => { + const failure = new RoxyBrowserError('roxy_api_timeout'); + const idle = fakeSession({ openFailure: failure }); + const acquired = []; + await assert.rejects(() => openRoxyBrowserSession({ config, childId: 'child_test', + onProfileAcquired: (dirId) => { + acquired.push(dirId); + assert.deepEqual(idle.calls, [['connections', 'profile_test']]); + } }, idle), codeIs('roxy_api_timeout')); + assert.deepEqual(acquired, ['profile_test']); + const busy = fakeSession({ existing: [{ dirId: 'profile_test' }] }); + await assert.rejects(() => openRoxyBrowserSession({ config, childId: 'child_test', + onProfileAcquired: () => assert.fail('An already-open profile belongs to another operation') }, busy), codeIs('roxy_profile_busy')); + assert.equal(busy.calls.some((call) => Array.isArray(call) && ['api-close', 'open'].includes(call[0])), false); +}); + +test('Roxy startup waits for its websocket and page to become ready without opening another window', async () => { + const fake = fakeSession(); + let attempts = 0; + let discardedConnections = 0; + const connect = async (options) => { + attempts += 1; + assert.equal(options.browserWSEndpoint, endpoint); + if (attempts === 1) throw new Error('fixture: browser websocket not ready'); + if (attempts === 2) return { + newPage: async () => { throw new Error('fixture: browser target not ready'); }, + disconnect: async () => { discardedConnections += 1; }, + }; + return fake.connect(options); + }; + const session = await openRoxyBrowserSession({ config, childId: 'child_test' }, { ...fake, connect }); + assert.equal(session.page, fake.page); + assert.equal(attempts, 3); + assert.equal(discardedConnections, 1); + assert.equal(fake.calls.filter((call) => call[0] === 'open').length, 1); + assert.equal(fake.calls.some((call) => call[0] === 'api-close'), false); + await session.close(); +}); + +test('Roxy short websocket retries do not impose a five-second timeout on later OAuth navigation commands', async () => { + const fake = fakeSession(); + const optionsSeen = []; + const client = { ...fake.client, open: async (...args) => { + await fake.wait(12000); + return fake.client.open(...args); + } }; + const session = await openRoxyBrowserSession({ config, childId: 'child_test', timeoutMs: 120000 }, { + ...fake, client, connect: async (options) => { + optionsSeen.push(options); + if (optionsSeen.length === 1) throw new Error('fixture websocket still starting'); + return fake.connect(options); + }, + }); + assert.deepEqual(optionsSeen.map(({ transportTimeout }) => transportTimeout), [5000, 5000]); + assert.deepEqual(optionsSeen.map(({ protocolTimeout }) => protocolTimeout), [108000, 107500]); + assert.ok(optionsSeen.at(-1).protocolTimeout > 60000, 'Page.navigate must retain the remaining OAuth budget'); + await session.close(); + + const bounded = fakeSession(); + const longer = await openRoxyBrowserSession({ config, childId: 'child_test', timeoutMs: 999999 }, bounded); + assert.equal(bounded.calls.find((call) => call[0] === 'connect')[1].protocolTimeout, 180000); + await longer.close(); +}); + +test('Roxy close shares one pending cleanup and holds the lease until its window has exited', async () => { + const fake = fakeSession(); + let waiting; + let reachedWait; + const cleanupWaiting = new Promise((resolve) => { reachedWait = resolve; }); + let closing = false; + let stillOpen = true; + const client = { ...fake.client, + close: async (dirId) => { closing = true; return fake.client.close(dirId); }, + connectionInfo: async () => closing && stillOpen ? [{ dirId: 'profile_test' }] : [], + }; + const session = await openRoxyBrowserSession({ config, childId: 'child_test' }, { + ...fake, client, wait: async () => { reachedWait(); await new Promise((resolve) => { waiting = resolve; }); }, + }); + const first = session.close(); + const second = session.close(); + assert.equal(first, second); + await cleanupWaiting; + const occupied = fakeSession(); + await assert.rejects(() => openRoxyBrowserSession({ config, childId: 'child_test' }, occupied), codeIs('roxy_profile_busy')); + assert.deepEqual(occupied.calls, []); + stillOpen = false; + waiting(); + await Promise.all([first, second]); + assert.equal(fake.calls.filter((call) => call[0] === 'api-close').length, 1); + const next = await openRoxyBrowserSession({ config, childId: 'child_test' }, fakeSession()); + await next.close(); +}); + +test('Roxy reports acknowledged closes that never release the window instead of reporting cleanup success', async () => { + const fake = fakeSession(); + let closing = false; + const session = await openRoxyBrowserSession({ config, childId: 'child_test' }, { ...fake, client: { + ...fake.client, + close: async () => { closing = true; }, + connectionInfo: async () => closing ? [{ dirId: 'profile_test' }] : [], + } }); + await assert.rejects(session.close(), codeIs('roxy_cleanup_failed')); + assert.equal(fake.clock(), 10000); + await assert.rejects(session.close(), codeIs('roxy_cleanup_failed')); + const busy = fakeSession({ existing: [{ dirId: 'profile_test' }] }); + await assert.rejects(() => openRoxyBrowserSession({ config, childId: 'child_test' }, busy), codeIs('roxy_profile_busy')); + assert.equal(busy.calls.some((call) => call[0] === 'api-close'), false); +}); + +test('Roxy pooled configuration validates three independent slots and preserves mappings across mode switches', () => { + const pooled = normalizeRoxyConfig({ profileMode: 'pooled', poolProfiles: [{ slot: 0, dirId: 'pool_a' }] }, config); + assert.equal(pooled.poolSize, 3); + assert.deepEqual(publicRoxyConfig(pooled).poolProfiles, [{ slot: 0, dirId: 'pool_a' }]); + const dedicated = normalizeRoxyConfig({ profileMode: 'dedicated' }, pooled); + assert.deepEqual(dedicated.poolProfiles, pooled.poolProfiles); + assert.equal(dedicated.profileMode, 'dedicated'); + const smaller = normalizeRoxyConfig({ poolSize: 1, poolProfiles: [{ slot: 0, dirId: 'pool_a' }, { slot: 2, dirId: 'pool_c' }] }, pooled); + assert.equal(smaller.poolSize, 1); + assert.equal(smaller.poolProfiles.length, 2, 'shrinking the active pool preserves idle slot mappings'); + for (const patch of [ + { profileMode: 'shared' }, { poolSize: 4 }, { poolSize: 0 }, { poolSize: '3' }, + { profileMode: null }, { poolSize: null }, { poolProfiles: null }, + { poolProfiles: [{ slot: 3, dirId: 'invalid' }] }, { poolProfiles: [{ slot: 0, dirId: 'bad/id' }] }, + ]) assert.throws(() => normalizeRoxyConfig(patch, config), codeIs('roxy_config_invalid')); + for (const poolProfiles of [ + [{ slot: 0, dirId: 'a' }, { slot: 0, dirId: 'b' }], + [{ slot: 0, dirId: 'a' }, { slot: 1, dirId: 'a' }], + ]) assert.throws(() => normalizeRoxyConfig({ poolProfiles }, config), codeIs('roxy_profile_conflict')); +}); + +test('Roxy pool recovers deleted bindings and reuses only idle tool profiles without exceeding three slots', async () => { + const profiles = [ + { dirId: 'user_profile', windowRemark: 'personal work' }, + { dirId: 'occupied', windowRemark: 'auto-team:child:occupied_child' }, + { dirId: 'slot_other', windowRemark: 'auto-team:child:reserved_child' }, + { dirId: 'tool_old', windowRemark: 'auto-team:child:previous_child' }, + ]; + const pooled = { ...config, profileMode: 'pooled', poolSize: 3, poolProfiles: [ + { slot: 0, dirId: 'deleted_profile' }, { slot: 1, dirId: 'slot_other' }, + ] }; + const listedPages = []; + const checked = []; + let created = 0; + const client = { + listProfiles: async (_workspace, { pageIndex }) => { + listedPages.push(pageIndex); + return { total: profiles.length, rows: profiles.slice((pageIndex - 1) * 2, pageIndex * 2) }; + }, + connectionInfo: async (dirId) => { checked.push(dirId); return dirId === 'occupied' ? [{ dirId }] : []; }, + createProfile: async () => { created += 1; return { dirId: 'created' }; }, + }; + assert.deepEqual(await resolveRoxyPoolProfile(pooled, 0, { client }), { slot: 0, dirId: 'tool_old', source: 'reused' }); + assert.deepEqual(listedPages, [1, 2]); + assert.deepEqual(checked, ['occupied', 'tool_old']); + assert.equal(created, 0); + await assert.rejects(() => resolveRoxyPoolProfile(pooled, 3, { client }), codeIs('roxy_config_invalid')); +}); + +test('Roxy pool binds or recovers exact slot markers and never takes over a user window', async () => { + const pooled = { ...config, profileMode: 'pooled', poolSize: 3, poolProfiles: [{ slot: 0, dirId: 'pool_a' }] }; + const client = { + listProfiles: async () => ({ total: 1, rows: [{ dirId: 'pool_a', windowRemark: 'auto-team:pool:0' }] }), + connectionInfo: async () => [], + createProfile: async () => { assert.fail('existing pool profile must be recovered'); }, + }; + assert.deepEqual(await resolveRoxyPoolProfile(pooled, 0, { client }), { slot: 0, dirId: 'pool_a', source: 'bound' }); + assert.deepEqual(await resolveRoxyPoolProfile({ ...pooled, poolProfiles: [] }, 0, { client }), { slot: 0, dirId: 'pool_a', source: 'recovered' }); + await assert.rejects(() => resolveRoxyPoolProfile(pooled, 0, { client: { + ...client, connectionInfo: async () => [{ dirId: 'pool_a' }], + } }), codeIs('roxy_profile_busy')); + await assert.rejects(() => resolveRoxyPoolProfile(pooled, 0, { client: { + ...client, listProfiles: async () => ({ total: 1, rows: [{ dirId: 'pool_a', windowRemark: 'unrelated user environment' }] }), + } }), codeIs('roxy_profile_conflict')); + await assert.rejects(() => resolveRoxyPoolProfile({ ...pooled, poolProfiles: [] }, 0, { client: { + ...client, listProfiles: async () => ({ total: 1, rows: [] }), + } }), codeIs('roxy_profile_list_incomplete')); +}); + +test('Roxy pool creates only a missing slot with a stable marker and rejects duplicate directory responses', async () => { + const pooled = { ...config, profileMode: 'pooled', poolSize: 3, poolProfiles: [{ slot: 0, dirId: 'reserved' }] }; + const client = { + listProfiles: async () => ({ total: 1, rows: [{ dirId: 'reserved', windowRemark: 'auto-team:pool:0' }] }), + createProfile: async (workspace, body) => { + assert.equal(workspace, config.workspaceId); + assert.deepEqual(body, { windowName: 'AutoTeam 授权环境 2', windowRemark: 'auto-team:pool:1' }); + return { dirId: 'new_slot' }; + }, + }; + assert.deepEqual(await resolveRoxyPoolProfile(pooled, 1, { client }), { slot: 1, dirId: 'new_slot', source: 'created' }); + await assert.rejects(() => resolveRoxyPoolProfile(pooled, 1, { client: { + ...client, createProfile: async () => ({ dirId: 'reserved' }), + } }), codeIs('roxy_profile_conflict')); +}); + +test('Roxy pool reuses idle orphaned pool markers while preserving other slot reservations', async () => { + const pooled = { ...config, profileMode: 'pooled', poolSize: 3, poolProfiles: [ + { slot: 0, dirId: 'deleted' }, { slot: 1, dirId: 'reserved' }, + ] }; + const checked = []; + const rows = [ + { dirId: 'user_profile', windowRemark: 'personal work' }, + { dirId: 'reserved', windowRemark: 'auto-team:pool:1' }, + { dirId: 'orphaned', windowRemark: 'auto-team:pool:2' }, + ]; + const client = { + listProfiles: async () => ({ total: rows.length, rows }), + connectionInfo: async (dirId) => { checked.push(dirId); return []; }, + createProfile: async () => { assert.fail('reuse the idle tool environment before creating'); }, + }; + assert.deepEqual(await resolveRoxyPoolProfile(pooled, 0, { client }), { slot: 0, dirId: 'orphaned', source: 'reused' }); + assert.deepEqual(checked, ['orphaned'], 'user environments and reserved tool environments are not probed or reused'); +}); + +test('Roxy pool ignores an old slot marker after its environment was reserved by another slot', async () => { + const pooled = { ...config, profileMode: 'pooled', poolSize: 3, poolProfiles: [ + { slot: 0, dirId: 'migrated' }, { slot: 1, dirId: 'reserved' }, + ] }; + const rows = [ + { dirId: 'migrated', windowRemark: 'auto-team:pool:2' }, + { dirId: 'reserved', windowRemark: 'auto-team:pool:1' }, + ]; + const client = { + listProfiles: async () => ({ total: rows.length, rows }), + connectionInfo: async () => { assert.fail('reserved environments must not be probed'); }, + createProfile: async (_workspace, body) => { + assert.equal(body.windowRemark, 'auto-team:pool:2'); + return { dirId: 'new_slot' }; + }, + }; + assert.deepEqual(await resolveRoxyPoolProfile(pooled, 2, { client }), { slot: 2, dirId: 'new_slot', source: 'created' }); +}); + +test('Roxy pool reclaims idle inactive slots after shrinking while preserving active slot reservations', async () => { + const rows = [ + { dirId: 'slot_one', windowRemark: 'auto-team:pool:1' }, + { dirId: 'slot_two', windowRemark: 'auto-team:pool:2' }, + ]; + for (const [poolSize, busy, expected, checkedIds] of [ + [1, '', 'slot_one', ['slot_one']], + [1, 'slot_one', 'slot_two', ['slot_one', 'slot_two']], + [2, '', 'slot_two', ['slot_two']], + ]) { + const pooled = { ...config, profileMode: 'pooled', poolSize, poolProfiles: [ + { slot: 0, dirId: 'deleted' }, { slot: 1, dirId: 'slot_one' }, { slot: 2, dirId: 'slot_two' }, + ] }; + const checked = []; + const client = { + listProfiles: async () => ({ total: rows.length, rows }), + connectionInfo: async (dirId) => { checked.push(dirId); return dirId === busy ? [{ dirId }] : []; }, + createProfile: async () => { assert.fail('inactive idle tool environments must be reused after shrinking'); }, + }; + assert.deepEqual(await resolveRoxyPoolProfile(pooled, 0, { client }), { slot: 0, dirId: expected, source: 'reused' }); + assert.deepEqual(checked, checkedIds); + assert.equal(pooled.poolProfiles.length, 3, 'resolution leaves persistence of replacement mappings to its caller'); + } +}); + +test('Roxy pool does not create beyond its size when existing tool environments are busy', async () => { + for (const poolSize of [1, 2, 3]) { + const pooled = { ...config, profileMode: 'pooled', poolSize, poolProfiles: [{ slot: 0, dirId: 'deleted' }] }; + const rows = Array.from({ length: poolSize }, (_, index) => ({ + dirId: `busy_${index}`, windowRemark: index === 2 ? 'auto-team:pool:2' : `auto-team:child:old_${index}`, + })); + const checked = []; + const client = { + listProfiles: async () => ({ total: rows.length, rows }), + connectionInfo: async (dirId) => { checked.push(dirId); return [{ dirId }]; }, + createProfile: async () => { assert.fail('busy environments still consume the configured pool capacity'); }, + }; + await assert.rejects(() => resolveRoxyPoolProfile(pooled, 0, { client }), codeIs('roxy_profile_busy')); + assert.deepEqual(checked, rows.map(({ dirId }) => dirId)); + } +}); + +test('Roxy pool can fill remaining capacity while another tool environment is busy', async () => { + const pooled = { ...config, profileMode: 'pooled', poolSize: 2, poolProfiles: [] }; + const rows = [ + { dirId: 'user_profile', windowRemark: 'personal work' }, + { dirId: 'busy', windowRemark: 'auto-team:child:old' }, + ]; + const client = { + listProfiles: async () => ({ total: rows.length, rows }), + connectionInfo: async (dirId) => { assert.equal(dirId, 'busy'); return [{ dirId }]; }, + createProfile: async () => ({ dirId: 'new_slot' }), + }; + assert.deepEqual(await resolveRoxyPoolProfile(pooled, 0, { client }), { slot: 0, dirId: 'new_slot', source: 'created' }); +}); + +test('Roxy pooled accounts use fresh browser contexts and always release their window even after failure', async () => { + const pooled = { ...config, profileMode: 'pooled', poolSize: 3, poolProfiles: [{ slot: 0, dirId: 'pool_a' }], profiles: [] }; + let createdContexts = 0; + let closedContexts = 0; + const contextIds = []; + for (const childId of ['account_a', 'account_b']) { + const fake = fakeSession(); + const session = await openRoxyBrowserSession({ config: pooled, childId, profile: { dirId: 'pool_a' } }, { + ...fake, connect: async () => ({ + createBrowserContext: async () => { + const id = ++createdContexts; + return { newPage: async () => ({ contextId: id }), close: async () => { closedContexts += 1; } }; + }, + newPage: async () => { assert.fail('pooled login must not use the persistent profile context'); }, + disconnect: async () => {}, + }), + }); + assert.equal(session.keepOpenOnFailure, false); + contextIds.push(session.page.contextId); + await session.close({ keepOpen: true }); + assert.equal(fake.calls.filter((call) => call[0] === 'api-close' && call[1] === 'pool_a').length, 1); + } + assert.deepEqual(contextIds, [1, 2]); + assert.equal(closedContexts, 2); + await assert.rejects(() => openRoxyBrowserSession({ config: pooled, childId: 'a', profile: { dirId: 'unrelated' } }, fakeSession()), codeIs('roxy_profile_missing')); +}); + +function fakePooledSession() { + const fake = fakeSession(); + const authenticated = []; + const page = { authenticate: async (credentials) => { authenticated.push(credentials); } }; + const context = { newPage: async () => page, close: async () => { fake.calls.push('context-close'); } }; + return { ...fake, page, authenticated, connect: async (options) => ({ + ...await fake.connect(options), createBrowserContext: async () => context, + }) }; +} + +const pooledProxyConfig = { ...config, profileMode: 'pooled', useProxyPool: true, + poolSize: 3, poolProfiles: [{ slot: 0, dirId: 'pool_a' }], profiles: [] }; + +test('Roxy isolated HTTP proxy login authenticates before returning its page and decodes proxy credentials', async () => { + for (const url of ['http://fixture%40user:p%3Ass@proxy.example.test:8080', 'https://fixture%40user:p%3Ass@proxy.example.test:8080']) { + const fake = fakePooledSession(); + const session = await openRoxyBrowserSession({ config: pooledProxyConfig, childId: 'a', + profile: { dirId: 'pool_a', proxyId: 'proxy_a' }, proxyEntry: { id: 'proxy_a', url }, + }, fake); + assert.equal(session.page, fake.page); + assert.deepEqual(fake.authenticated, [{ username: 'fixture@user', password: 'p:ss' }]); + assert.equal(Object.keys(session).some((key) => /credential|proxy|password/i.test(key)), false); + await session.close(); + } +}); + +test('Roxy isolated chained proxy login uses relay credentials instead of the upstream account credentials', async () => { + const fake = fakePooledSession(); + let relayClosed = false; + const relayUrl = 'http://chain:relay%3Asecret@127.0.0.1:51000'; + const session = await openRoxyBrowserSession({ config: pooledProxyConfig, childId: 'a', + profile: { dirId: 'pool_a', proxyId: 'proxy_a' }, localProxyUrl: 'http://127.0.0.1:7897', + proxyEntry: { id: 'proxy_a', url: 'socks5h://upstream-user:upstream-secret@upstream.example.test:1080' }, + }, { ...fake, createRelay: async () => ({ url: relayUrl, close: async () => { relayClosed = true; } }) }); + assert.deepEqual(fake.authenticated, [{ username: 'chain', password: 'relay:secret' }]); + assert.equal(fake.calls.find((call) => call[0] === 'proxy')[3].url, relayUrl); + await session.close(); + assert.equal(relayClosed, true); +}); + +test('Roxy does not send SOCKS credentials as HTTP authentication or authenticate a credential-free proxy', async () => { + for (const url of ['http://proxy.example.test:8080', 'socks5h://socks-user:socks-secret@proxy.example.test:1080']) { + const fake = fakePooledSession(); + const session = await openRoxyBrowserSession({ config: pooledProxyConfig, childId: 'a', + profile: { dirId: 'pool_a' }, proxyEntry: { url }, + }, fake); + assert.deepEqual(fake.authenticated, []); + await session.close(); + } +}); + +test('Roxy proxy-auth initialization failure is redacted and releases only the owned context, window and relay', async () => { + const fake = fakePooledSession(); + fake.page.authenticate = async () => { throw new Error('fixture-proxy-password-must-never-escape'); }; + let relayClosed = false; + await assert.rejects(() => openRoxyBrowserSession({ config: pooledProxyConfig, childId: 'a', + profile: { dirId: 'pool_a' }, localProxyUrl: 'http://127.0.0.1:7897', + proxyEntry: { url: 'socks5h://upstream-user:upstream-secret@upstream.example.test:1080' }, + }, { ...fake, createRelay: async () => ({ url: 'http://chain:fixture-proxy-password-must-never-escape@127.0.0.1:51000', + close: async () => { relayClosed = true; } }) }), (error) => codeIs('roxy_proxy_auth_failed')(error) + && !error.message.includes('fixture-proxy-password-must-never-escape')); + assert.equal(fake.calls.filter((call) => call === 'context-close').length, 1); + assert.equal(fake.calls.filter((call) => call[0] === 'api-close' && call[1] === 'pool_a').length, 1); + assert.equal(relayClosed, true); +}); diff --git a/server/roxy-integration.test.mjs b/server/roxy-integration.test.mjs index 1933469..50a7bc5 100644 --- a/server/roxy-integration.test.mjs +++ b/server/roxy-integration.test.mjs @@ -12,6 +12,7 @@ import { openRoxyBrowserSession } from './roxy-browser.mjs'; const serverScript = fileURLToPath(new URL('./index.mjs', import.meta.url)); const roxyModule = new URL('./roxy-browser.mjs', import.meta.url).href; +const loginModule = new URL('./openai-login.mjs', import.meta.url).href; const proxyChainModule = new URL('./proxy-chain.mjs', import.meta.url).href; const puppeteerModule = import.meta.resolve('puppeteer-core'); const dataKey = 'isolated-roxy-state-encryption-key'; @@ -48,7 +49,8 @@ async function unusedPort() { async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = false, occupiedProfile = false, sentinelChallenge = false, priorFields = {}, browserVerification = false, passwordFailure = null, proxyFlow = false, holdBrowser = false, - accountId = 'child_alpha', createFailure = null, chainFlow = false, concurrentAccounts = null, failBrowserFor = [], cleanupFailureFor = [], connectFailureFor = [], holdQuotaFor = [], holdOpenFor = [], timeoutBeforeSessionFor = [] } = {}) { + accountId = 'child_alpha', createFailure = null, chainFlow = false, concurrentAccounts = null, failBrowserFor = [], cleanupFailureFor = [], connectFailureFor = [], holdQuotaFor = [], holdOpenFor = [], timeoutBeforeSessionFor = [], profileCapacity = Infinity, remoteProfiles = null, browserFailureSequences = {}, + deleteFailure = false, deleteStillListed = false, holdDeleteFor = [], tokenFields = {}, openFailureFor = [], unexpectedLoginErrorFor = [] } = {}) { const workspaceId = 'target_team_workspace'; const allChildren = concurrentAccounts || children; const account = allChildren.find((child) => child.id === accountId); @@ -56,21 +58,30 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f const mothers = teamFlow ? [{ id: 'fixture_mother', email: 'owner@example.test', accountId: workspaceId, accessToken: oauthToken({ id: 'owner', email: 'owner@example.test' }, workspaceId, 'team').access_token }] : []; const requests = []; - const profiles = [{ dirId: 'roxy_profile_a', windowName: 'Fixture profile', windowSortNum: 1 }]; + const profiles = remoteProfiles ? structuredClone(remoteProfiles) : [{ dirId: 'roxy_profile_a', windowName: 'Fixture profile', windowSortNum: 1, + ...(Number.isFinite(profileCapacity) ? { windowRemark: 'auto-team:child:child_alpha' } : {}) }]; let releaseBrowser; const heldBrowsers = new Map(); const heldQuotas = new Map(); const heldOpens = new Map(); + const heldDeletes = new Map(); const openedProfiles = new Set(); + const deletedProfiles = new Set(); const removedMembers = new Set(); let browserPeak = 0; + let profilePeak = profiles.length; + let nextProfileId = profiles.length; const upstream = createServer(async (req, res) => { const url = new URL(req.url, 'http://127.0.0.1'); if (proxyFlow && ['/api/oauth/oauth2/token', '/oauth/token'].includes(url.pathname)) url.pathname = '/fixture/token'; const chunks = []; for await (const chunk of req) chunks.push(chunk); - requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), method: req.method, - token: req.headers.token, proxyAuth: req.headers['proxy-authorization'], body: Buffer.concat(chunks).toString() }); + const tokenPayload = String(req.headers.authorization || '').split('.')[1]; + const recorded = { path: url.pathname, query: Object.fromEntries(url.searchParams), method: req.method, + token: req.headers.token, proxyAuth: req.headers['proxy-authorization'], body: Buffer.concat(chunks).toString(), + subject: tokenPayload ? JSON.parse(Buffer.from(tokenPayload, 'base64url')).sub : undefined, + ...(url.pathname === '/browser/connection_info' ? { openedProfiles: [...openedProfiles] } : {}) }; + requests.push(recorded); if (url.pathname === '/fixture/browser-held') { await new Promise((resolve) => { releaseBrowser = resolve; @@ -86,6 +97,14 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f } if (url.pathname === '/browser/open') { const dirId = JSON.parse(Buffer.concat(chunks).toString()).dirId; + if (openFailureFor.includes(dirId)) { + res.writeHead(200, { 'content-type': 'application/json' }); + return res.end(JSON.stringify({ code: -1, msg: 'fixture browser startup failed' })); + } + if (deletedProfiles.has(dirId)) { + res.writeHead(409, { 'content-type': 'application/json' }); + return res.end(JSON.stringify({ code: -1, msg: 'fixture environment was deleted' })); + } openedProfiles.add(dirId); browserPeak = Math.max(browserPeak, openedProfiles.size); if (holdOpenFor.includes(dirId)) await new Promise((resolve) => heldOpens.set(dirId, resolve)); @@ -98,6 +117,30 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f return res.end(JSON.stringify({ code: -1, msg: 'fixture close failed' })); } } + if (url.pathname === '/browser/delete') { + const { workspaceId: requestedWorkspace, dirIds, isSoftDelete } = JSON.parse(recorded.body); + recorded.openedProfiles = [...openedProfiles]; + const stored = (await saved()).state; + recorded.persistedJson = stored.children.map((child) => ({ id: child.id, + free: Boolean(child.accessToken && child.refreshToken), + team: Boolean(child.workspaceTokens?.[workspaceId]?.accessToken && child.workspaceTokens?.[workspaceId]?.refreshToken) })); + if (Array.isArray(dirIds) && dirIds.some((dirId) => holdDeleteFor.includes(dirId))) { + await new Promise((resolve) => heldDeletes.set(dirIds[0], resolve)); + } + res.writeHead(200, { 'content-type': 'application/json' }); + if (requestedWorkspace !== 'roxy_workspace_a' || !Array.isArray(dirIds) || dirIds.length !== 1 || isSoftDelete !== true) { + return res.end(JSON.stringify({ code: -1, msg: 'unexpected fixture delete contract' })); + } + if (deleteFailure) return res.end(JSON.stringify({ code: -1, msg: 'fixture delete failed' })); + if (!deleteStillListed) { + for (const dirId of dirIds) { + const index = profiles.findIndex((profile) => profile.dirId === dirId); + if (index >= 0) profiles.splice(index, 1); + deletedProfiles.add(dirId); + } + } + return res.end(JSON.stringify({ code: 0, data: null })); + } if (req.method === 'DELETE' && url.pathname.startsWith(`/backend-api/accounts/${workspaceId}/users/`)) { removedMembers.add(decodeURIComponent(url.pathname.split('/').pop())); res.writeHead(200, { 'content-type': 'application/json' }); @@ -106,9 +149,11 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f if (url.pathname === '/browser/create') { res.writeHead(200, { 'content-type': 'application/json' }); if (createFailure) return res.end(JSON.stringify(createFailure)); + if (profiles.length >= profileCapacity) return res.end(JSON.stringify({ code: -1, msg: '窗口数量已达到上限' })); const body = JSON.parse(Buffer.concat(chunks).toString()); - const created = { dirId: `created_profile_${profiles.length}`, windowName: body.windowName, windowRemark: body.windowRemark }; + const created = { dirId: `created_profile_${nextProfileId++}`, windowName: body.windowName, windowRemark: body.windowRemark }; profiles.push(created); + profilePeak = Math.max(profilePeak, profiles.length); return res.end(JSON.stringify({ code: 0, data: { dirId: created.dirId } })); } if (browserFlow && url.pathname === '/oauth/authorize') { @@ -140,16 +185,16 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f if (apiFailure) return res.end(JSON.stringify({ code: apiFailure.code ?? -1, msg: `private-upstream-error ${secret} http://private-user:private-password@remote.example.test`, data: null })); const recognized = ['/health', '/browser/workspace', '/browser/list_v3', '/browser/connection_info', - '/browser/open', '/browser/close', '/browser/mdf', '/fixture/browser-options', '/fixture/browser-launch', '/fixture/browser-connect', '/fixture/token', '/fixture/chain-create', '/fixture/chain-close', + '/browser/open', '/browser/close', '/browser/mdf', '/fixture/browser-options', '/fixture/browser-launch', '/fixture/browser-connect', '/fixture/context-create', '/fixture/context-close', '/fixture/context-auth', '/fixture/token', '/fixture/chain-create', '/fixture/chain-close', `/backend-api/accounts/${workspaceId}/users`, '/backend-api/wham/usage'].includes(url.pathname); const data = url.pathname === '/health' ? { running: true } : url.pathname === '/browser/workspace' ? { total: 1, rows: [{ id: 'roxy_workspace_a', workspaceName: 'Fixture team' }] } : url.pathname === '/browser/list_v3' ? { total: profiles.length, rows: profiles } : url.pathname === '/browser/connection_info' ? (occupiedProfile ? [{ dirId: 'roxy_profile_a' }] : [...openedProfiles].map((dirId) => ({ dirId }))) : url.pathname === '/browser/open' ? { ws: `ws://127.0.0.1:${serverPort}/devtools/browser/fixture` } - : url.pathname === '/fixture/token' ? oauthToken(concurrentAccounts + : url.pathname === '/fixture/token' ? { ...oauthToken(concurrentAccounts ? allChildren.find((child) => new URLSearchParams(Buffer.concat(chunks).toString()).get('code') === `fixture-automatic-code-${child.email}`) || account : account, - teamFlow ? workspaceId : undefined, teamFlow ? 'team' : 'free') + teamFlow ? workspaceId : undefined, teamFlow ? 'team' : 'free'), ...tokenFields } : url.pathname === `/backend-api/accounts/${workspaceId}/users` ? { total: 1 + (concurrentAccounts || [account]).filter((child) => !removedMembers.has(child.id)).length, items: [ { id: 'owner', email: 'owner@example.test', role: 'account-owner', seat_type: 'default' }, ...(concurrentAccounts || [account]).filter((child) => !removedMembers.has(child.id)).map((child) => ({ id: child.id, email: child.email, role: 'standard-user', seat_type: 'default' })), @@ -183,11 +228,21 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f ${browserFlow ? ` import { registerHooks } from 'node:module'; registerHooks({ resolve(specifier, context, nextResolve) { + if (${unexpectedLoginErrorFor.length > 0} && specifier === './openai-login.mjs') return { url: 'fixture:unexpected-login-error', shortCircuit: true }; if (specifier === './openai-browser-login.mjs') return { url: 'fixture:roxy-browser-flow', shortCircuit: true }; if (specifier === './roxy-browser.mjs') return { url: 'fixture:roxy-session', shortCircuit: true }; if (${chainFlow} && specifier === './proxy-chain.mjs') return { url: 'fixture:proxy-chain', shortCircuit: true }; return nextResolve(specifier, context); }, load(url, context, nextLoad) { + if (url === 'fixture:unexpected-login-error') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(` + export * from ${JSON.stringify(loginModule)}; + import { loginFreeAccount as actualLogin } from ${JSON.stringify(loginModule)}; + export async function loginFreeAccount(options) { + const result = await actualLogin(options); + if (${JSON.stringify(unexpectedLoginErrorFor)}.includes(options.email)) throw new Error('private-unexpected-login-error'); + return result; + } + `)} }; if (url === 'fixture:proxy-chain') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(` export * from ${JSON.stringify(proxyChainModule)}; export async function createProxyChainRelay(options) { @@ -202,15 +257,26 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f if (url === 'fixture:roxy-session') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(` export * from ${JSON.stringify(roxyModule)}; import { openRoxyBrowserSession as actualOpen } from ${JSON.stringify(roxyModule)}; + let contextSequence = 0; export function openRoxyBrowserSession(options) { return actualOpen(options, { connect: async () => { if (${JSON.stringify(connectFailureFor)}.includes(options.childId)) throw new Error('fixture connection failed'); return { - newPage: async () => ({ close: async () => {} }), disconnect: async () => {}, + newPage: async () => ({ close: async () => {}, authenticate: async () => {} }), disconnect: async () => {}, + createBrowserContext: async () => { + const context = { contextId: ++contextSequence, childId: options.childId, dirId: options.profile?.dirId }; + await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/context-create`)}, { method: 'POST', body: JSON.stringify(context) }); + return { newPage: async () => ({ close: async () => {}, authenticate: async ({ username }) => { + await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/context-auth`)}, { method: 'POST', body: JSON.stringify({ ...context, username }) }); + } }), close: async () => { + await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/context-close`)}, { method: 'POST', body: JSON.stringify(context) }); + } }; + }, }; } }); } `)} }; if (url === 'fixture:roxy-browser-flow') return { format: 'module', shortCircuit: true, source: ${JSON.stringify(` + const browserAttempts = new Map(); export async function completeBrowserOAuth(options) { if (typeof options.acquireSession !== 'function') throw Object.assign(new Error('fixture Chrome disabled'), { code: 'fixture_chrome_disabled', status: 409 }); await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/browser-options`)}, { @@ -226,8 +292,17 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f throw Object.assign(new Error('fixture timeout while opening'), { code: 'browser_oauth_timeout', status: 408, browserRequired: true }); } const session = await acquiring; + let authorizationError; try { if (${holdBrowser}) await fetch(${JSON.stringify(`http://127.0.0.1:${roxyPort}/fixture/browser-held`)} + '?email=' + encodeURIComponent(options.email)); + const attempt = browserAttempts.get(options.email) || 0; + browserAttempts.set(options.email, attempt + 1); + const injected = ${JSON.stringify(browserFailureSequences)}[options.email]?.[attempt]; + if (injected) throw Object.assign(new Error('fixture browser authorization failure'), { + code: typeof injected === 'string' ? injected : injected.code, + status: typeof injected === 'string' ? 502 : injected.status, + browserRequired: true, needsInput: true, + }); if (${browserVerification} || ${JSON.stringify(failBrowserFor)}.includes(options.email)) throw Object.assign(new Error('fixture official verification required'), { code: 'browser_verification_required', status: 403, browserRequired: true, needsInput: true, }); @@ -235,7 +310,18 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f callback.searchParams.set('state', options.expectedState); callback.searchParams.set('code', ${concurrentAccounts !== null} ? 'fixture-automatic-code-' + options.email : 'fixture-automatic-code'); return { callbackUrl: callback.toString() }; - } finally { await session.close({ keepOpen: session.keepOpenOnFailure === true && ${JSON.stringify(failBrowserFor)}.includes(options.email) }); } + } catch (error) { + authorizationError = error; + throw error; + } finally { + try { await session.close({ keepOpen: session.keepOpenOnFailure === true && ${JSON.stringify(failBrowserFor)}.includes(options.email) }); } + catch (error) { + // Match the real browser failure path: an unsuccessful close + // must not replace the official login error already captured. + if (!authorizationError) throw error; + options.onProgress('roxy_cleanup_failed'); + } + } } `)} }; return nextLoad(url, context); @@ -292,6 +378,7 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f for (const release of heldBrowsers.values()) release(); for (const release of heldQuotas.values()) release(); for (const release of heldOpens.values()) release(); + for (const release of heldDeletes.values()) release(); if (app.exitCode === null && app.signalCode === null) { const stopped = new Promise((resolve) => app.once('exit', resolve)); app.kill(); @@ -351,11 +438,11 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f assert.equal(started.status, 202, JSON.stringify(started.data)); return waitJob(started.data.job.id); } - async function addProxies({ enabled = true } = {}) { - const added = await request('/api/proxy/entries', { method: 'POST', body: { values: ['alpha', 'beta', 'gamma'].map( + async function addProxies({ enabled = true, names = ['alpha', 'beta', 'gamma'] } = {}) { + const added = await request('/api/proxy/entries', { method: 'POST', body: { values: names.map( (name) => `http://fixture-proxy-${name}:private-proxy-password@127.0.0.1:${roxyPort}`) } }); assert.equal(added.status, 200, JSON.stringify(added.data)); - assert.equal(added.data.added.length, 3); + assert.equal(added.data.added.length, names.length); const updated = await request('/api/proxy', { method: 'PATCH', body: { enabled, strategy: 'round_robin', maxRetries: 2 } }); assert.equal(updated.status, 200); return added.data.added; @@ -368,7 +455,8 @@ async function fixture(t, { apiFailure = null, browserFlow = false, teamFlow = f throw new Error('isolated Roxy browser did not reach the held authorization stage'); } return { request, patch, config, saved, requests, runJob, waitJob, addProxies, waitBrowser, - heldBrowsers, heldQuotas, heldOpens, openedProfiles, browserPeak: () => browserPeak, + heldBrowsers, heldQuotas, heldOpens, heldDeletes, openedProfiles, deletedProfiles, + remoteProfiles: profiles, browserPeak: () => browserPeak, profilePeak: () => profilePeak, releaseAccount: (email) => { heldBrowsers.get(email)?.(); heldBrowsers.delete(email); }, releaseBrowser: () => releaseBrowser?.(), workspaceId, output: () => output }; } @@ -388,6 +476,8 @@ async function waitForFixture(condition, description) { assert.fail(description); } async function configureParallel(app, accounts, concurrency) { + app.remoteProfiles.push(...accounts.map((child) => ({ dirId: `profile_${child.id}`, + windowName: `Fixture ${child.id}`, windowRemark: `auto-team:child:${child.id}` }))); const result = await app.patch({ ...app.config, concurrency, profiles: accounts.map((child) => ({ childId: child.id, dirId: `profile_${child.id}` })) }); assert.equal(result.status, 200, JSON.stringify(result.data)); @@ -400,6 +490,925 @@ async function startTeamJob(app, accounts) { return result.data.job; } +function assertConfirmedClosures(requests) { + for (const [index, request] of requests.entries()) { + if (request.path !== '/browser/close') continue; + const { dirId } = JSON.parse(request.body); + const nextOpen = requests.findIndex((entry, next) => next > index && entry.path === '/browser/open' + && JSON.parse(entry.body).dirId === dirId); + const afterClose = requests.slice(index + 1, nextOpen < 0 ? undefined : nextOpen); + assert.ok(afterClose.some((entry) => entry.path === '/browser/connection_info' && entry.query.dirIds === dirId + && !entry.openedProfiles.includes(dirId)), `closure of ${dirId} must be confirmed before another account reuses it`); + } +} + +for (const concurrency of [1, 3]) test(`nine accounts reuse at most three pooled environments at concurrency ${concurrency} with isolated sessions`, { timeout: 20000 }, async (t) => { + const accounts = Array.from({ length: 9 }, (_, i) => ({ id: i ? `pool_child_${i}` : 'child_alpha', email: `pool-${i}@example.test`, password: `private-pool-${i}` })); + const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: accounts, profileCapacity: 3, + failBrowserFor: [accounts[1].email] }); + const patched = await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency }); + assert.equal(patched.status, 200, JSON.stringify(patched.data)); + const job = await app.waitJob((await startTeamJob(app, accounts)).id); + assert.equal(job.succeeded, 8, JSON.stringify(job)); + assert.equal(job.needsAttention, 1); + assert.equal(job.completed, 9); + const opened = app.requests.filter((request) => request.path === '/browser/open'); + assert.equal(opened.length, 9, 'each account runs once'); + assert.ok(new Set(opened.map((request) => JSON.parse(request.body).dirId)).size <= 3); + assert.ok(app.requests.filter((request) => request.path === '/browser/create').length <= 2, 'existing owned environment is reused'); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-create').length, 9); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 9); + assert.equal(app.openedProfiles.size, 0); + assert.ok(app.browserPeak() <= concurrency); + assert.ok(job.logs.some((entry) => entry.message.includes('隔离登录会话'))); + const saved = (await app.saved()).state; + for (const account of accounts.filter((item) => item !== accounts[1])) { + assert.equal(saved.children.find((item) => item.id === account.id).workspaceTokens[app.workspaceId].accountId, app.workspaceId); + } + assertNoSecrets(job); +}); + +for (const concurrency of [1, 3]) test(`nine legacy dedicated bindings recover a missing pool slot using three existing environments at concurrency ${concurrency}`, { timeout: 20000 }, async (t) => { + const accounts = Array.from({ length: 9 }, (_, index) => ({ id: index ? `legacy_child_${index}` : 'child_alpha', + email: `legacy-${index}@example.test`, password: `private-legacy-${index}` })); + const remoteProfiles = accounts.slice(0, 3).map((child) => ({ dirId: `legacy_profile_${child.id}`, + windowName: `AutoTeam ${child.email}`, windowRemark: `auto-team:child:${child.id}` })); + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, holdBrowser: true, + concurrentAccounts: accounts, profileCapacity: 3, remoteProfiles }); + const proxies = await app.addProxies(); + const legacyBindings = accounts.map((child, index) => ({ childId: child.id, dirId: `legacy_profile_${child.id}`, + proxyId: proxies[(index + 1) % proxies.length].id })); + const configured = await app.patch({ ...app.config, profileMode: 'dedicated', useProxyPool: true, + profiles: legacyBindings, poolProfiles: [{ slot: 0, dirId: 'deleted_pool_profile' }] }); + assert.equal(configured.status, 200, JSON.stringify(configured.data)); + assert.equal((await app.patch({ profileMode: 'pooled', poolSize: 3, concurrency })).status, 200); + const started = await startTeamJob(app, accounts); + assert.equal(started.concurrency, concurrency); + await waitForFixture(() => app.heldBrowsers.size === concurrency, 'all configured workers must acquire existing environments'); + for (const child of accounts) { + await waitForFixture(() => app.heldBrowsers.has(child.email), `legacy account ${child.id} must reach its isolated login`); + app.releaseAccount(child.email); + } + const job = await app.waitJob(started.id); + assert.equal(job.succeeded, 9, JSON.stringify(job)); + assert.equal(job.completed, 9); + assert.equal(job.needsAttention, 0); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0, + 'stale local bindings must not cause creation beyond the three existing remote environments'); + const opened = app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body)); + const validIds = new Set(remoteProfiles.map((profile) => profile.dirId)); + assert.equal(opened.length, 9); + assert.ok(opened.every((request) => validIds.has(request.dirId) && request.forceOpen === false)); + assert.equal(new Set(opened.map((request) => request.dirId)).size, concurrency); + assert.equal(app.browserPeak(), concurrency); + assert.equal(app.openedProfiles.size, 0); + assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 9); + assertConfirmedClosures(app.requests); + + const contexts = app.requests.filter((request) => request.path === '/fixture/context-create').map((request) => JSON.parse(request.body)); + const closedContexts = app.requests.filter((request) => request.path === '/fixture/context-close').map((request) => JSON.parse(request.body)); + assert.equal(contexts.length, 9); + assert.equal(new Set(contexts.map((context) => context.contextId)).size, 9, 'each account must receive a fresh browser context'); + assert.deepEqual(new Set(contexts.map((context) => context.childId)), new Set(accounts.map((child) => child.id))); + assert.ok(contexts.every((context) => validIds.has(context.dirId))); + assert.deepEqual(closedContexts.toSorted((a, b) => a.contextId - b.contextId), contexts.toSorted((a, b) => a.contextId - b.contextId)); + const authContexts = app.requests.filter((request) => request.path === '/fixture/context-auth').map((request) => JSON.parse(request.body)); + assert.equal(authContexts.length, 9); + const saved = (await app.saved()).state; + assert.deepEqual(saved.settings.integrations.roxy.profiles, legacyBindings); + assert.ok(saved.settings.integrations.roxy.poolProfiles.every((profile) => validIds.has(profile.dirId))); + for (const [index, child] of accounts.entries()) { + const proxyIndex = (index + 1) % proxies.length; + const username = `fixture-proxy-${['alpha', 'beta', 'gamma'][proxyIndex]}`; + const expectedAuth = `Basic ${Buffer.from(`${username}:private-proxy-password`).toString('base64')}`; + const stored = saved.children.find((entry) => entry.id === child.id); + assert.equal(stored.roxyProxyId, legacyBindings[index].proxyId); + assert.equal(stored.workspaceTokens[app.workspaceId].accountId, app.workspaceId); + assert.equal(authContexts.find((context) => context.childId === child.id)?.username, username); + const tokenRequests = app.requests.filter((request) => request.path === '/fixture/token' + && new URLSearchParams(request.body).get('code') === `fixture-automatic-code-${child.email}`); + assert.equal(tokenRequests.length, 1); + assert.equal(tokenRequests[0].proxyAuth, expectedAuth); + const quotas = app.requests.filter((request) => request.path === '/backend-api/wham/usage' && request.subject === child.id); + assert.equal(quotas.length, 1); + assert.equal(quotas[0].proxyAuth, expectedAuth, 'quota must retain the account proxy after its browser is closed'); + } + assertNoSecrets(job); + assertNoSecrets(app.output()); +}); + +test('an occupied first pooled slot falls through to an existing idle slot without opening or closing the occupied window', { timeout: 20000 }, async (t) => { + const remoteProfiles = ['a', 'b', 'c'].map((name, slot) => ({ dirId: `roxy_profile_${name}`, + windowName: `Fixture pool ${slot}`, windowRemark: `auto-team:pool:${slot}` })); + const app = await fixture(t, { browserFlow: true, teamFlow: true, occupiedProfile: true, profileCapacity: 3, remoteProfiles }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 1, + poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + assert.deepEqual(app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId), ['roxy_profile_b']); + assert.deepEqual(app.requests.filter((request) => request.path === '/browser/close').map((request) => JSON.parse(request.body).dirId), ['roxy_profile_b']); + assertConfirmedClosures(app.requests); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-create').length, 1); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 1); + assert.equal(app.browserPeak(), 1); + assert.equal(app.openedProfiles.size, 0); + assertNoSecrets(job); +}); + +test('shrinking a pool from three slots to one reuses an existing higher-slot environment after slot zero disappears', { timeout: 20000 }, async (t) => { + const dirId = 'retained_pool_profile'; + const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 1, + remoteProfiles: [{ dirId, windowName: 'Fixture previous pool slot', windowRemark: 'auto-team:pool:1' }] }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 3, + poolProfiles: [{ slot: 0, dirId: 'deleted_pool_profile' }, { slot: 1, dirId }] })).status, 200); + assert.equal((await app.patch({ poolSize: 1 })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.concurrency, 1); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + assert.deepEqual(app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId), [dirId]); + assert.deepEqual((await app.saved()).state.settings.integrations.roxy.poolProfiles, [{ slot: 0, dirId }], + 'moving the retained environment to slot zero must remove its obsolete higher-slot binding'); + assert.equal((await app.patch({ headless: true })).status, 200, 'the recovered pool configuration must remain valid on later saves'); + assertConfirmedClosures(app.requests); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-create').length, 1); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 1); + assert.equal(app.openedProfiles.size, 0); + assertNoSecrets(job); +}); + +test('pooled configuration caps batch concurrency at the environment count and survives partial saves', async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 1, concurrency: 3 })).status, 200); + const first = await app.runJob({ scope: 'team', motherId: 'fixture_mother', ids: ['child_alpha'] }); + assert.equal(first.concurrency, 1); + assert.equal(first.succeeded, 1); + const before = (await app.saved()).state.settings.integrations.roxy; + assert.equal((await app.patch({ concurrency: 2 })).status, 200); + const after = (await app.saved()).state.settings.integrations.roxy; + assert.deepEqual(after.poolProfiles, before.poolProfiles); + assert.equal(after.profileMode, 'pooled'); + assert.equal(after.poolSize, 1); +}); + +test('pooled authorization preserves account proxy routing across new leases, cached RT refresh and quota checks', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, profileCapacity: 3, + priorFields: { refreshToken: 'private-fixture-rt-child_alpha' } }); + const proxies = await app.addProxies(); + const expectedProxy = proxies[1]; + const expectedAuth = `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`; + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, useProxyPool: true, + profiles: [{ ...app.config.profiles[0], proxyId: expectedProxy.id }] })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + let stored = (await app.saved()).state; + assert.equal(stored.children.find((child) => child.id === 'child_alpha').roxyProxyId, expectedProxy.id); + assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, [{ slot: 0, dirId: 'roxy_profile_a' }]); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1); + assert.equal(JSON.parse(app.requests.find((request) => request.path === '/browser/mdf').body).proxyInfo.proxyUserName, 'fixture-proxy-beta'); + assert.ok(app.requests.some((request) => request.path === '/fixture/token' && request.proxyAuth === expectedAuth)); + const automaticQuotas = app.requests.filter((request) => request.path === '/backend-api/wham/usage'); + assert.equal(automaticQuotas.length, 1); + assert.equal(automaticQuotas[0].proxyAuth, expectedAuth, 'the initial automatic quota request must retain the account proxy after OAuth releases its browser lease'); + + // Keep the saved account proxy but rebuild its pooled lease. The outer + // authorization network snapshot predates this newly allocated pool slot. + assert.equal((await app.patch({ poolProfiles: [] })).status, 200); + const beforeNewLease = app.requests.length; + const reacquired = await app.request('/api/children/child_alpha/team-auth', { method: 'POST', + body: { motherId: 'fixture_mother', force: true } }); + assert.equal(reacquired.status, 200, JSON.stringify(reacquired.data)); + assert.equal(reacquired.data.ok, true); + const leasedRequests = app.requests.slice(beforeNewLease); + assert.equal(leasedRequests.filter((request) => request.path === '/browser/open').length, 1); + assert.ok(leasedRequests.some((request) => request.path === '/fixture/token' && request.proxyAuth === expectedAuth)); + stored = (await app.saved()).state; + assert.equal(stored.children.find((child) => child.id === 'child_alpha').roxyProxyId, expectedProxy.id); + assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, [{ slot: 0, dirId: 'roxy_profile_a' }]); + + const beforeCachedRequests = app.requests.length; + const refreshed = await app.request('/api/children/child_alpha/acquire', { method: 'POST', body: { mode: 'refresh-at' } }); + assert.equal(refreshed.status, 200, JSON.stringify(refreshed.data)); + assert.equal(refreshed.data.source, 'refresh_token'); + for (let attempt = 0; attempt < 3; attempt += 1) { + const quota = await app.request('/api/children/child_alpha/team-quota', { method: 'POST', + body: { motherId: 'fixture_mother', workspaceId: app.workspaceId } }); + assert.equal(quota.status, 200, JSON.stringify(quota.data)); + assert.equal(quota.data.quota5h, 75); + assert.equal(quota.data.quota7d, 90); + } + const cachedRequests = app.requests.slice(beforeCachedRequests); + assert.ok(!cachedRequests.some((request) => request.path.startsWith('/browser/')), 'cached refresh and quota checks must not acquire a Roxy environment'); + const refreshRequest = cachedRequests.find((request) => request.path === '/fixture/token'); + assert.equal(new URLSearchParams(refreshRequest?.body).get('grant_type'), 'refresh_token'); + const routedRequests = cachedRequests.filter((request) => request.path === '/fixture/token' || request.path === '/backend-api/wham/usage'); + assert.equal(routedRequests.length, 4); + assert.ok(routedRequests.every((request) => request.proxyAuth === expectedAuth), 'round-robin must not replace the saved account proxy after its browser lease ends'); + assert.equal(app.openedProfiles.size, 0); + assertNoSecrets(job); + assertNoSecrets(reacquired.data); + assertNoSecrets(refreshed.data); + assertNoSecrets(app.output()); +}); + +for (const profileMode of ['pooled', 'dedicated']) for (const scope of ['free', 'team']) { + test(`${profileMode} ${scope} success deletes only its closed saved environment and retains the account proxy`, { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: scope === 'team', profileCapacity: 3 }); + const proxies = await app.addProxies(); + assert.equal((await app.patch({ ...app.config, profileMode, poolSize: 3, useProxyPool: true, deleteProfileAfterSuccess: true, + profiles: [{ ...app.config.profiles[0], proxyId: proxies[1].id }, { childId: 'child_beta', dirId: 'untouched_beta_profile', proxyId: proxies[2].id }], + poolProfiles: [{ slot: 0, dirId: 'roxy_profile_a' }] })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], ...(scope === 'team' ? { scope, motherId: 'fixture_mother' } : {}) }); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + assert.equal(job.results[0].roxyCleanup?.deleted, true, 'successful JSON must report released environment capacity'); + const deleted = app.requests.filter((request) => request.path === '/browser/delete'); + assert.equal(deleted.length, 1); + assert.equal(deleted[0].method, 'POST'); + assert.deepEqual(JSON.parse(deleted[0].body), { workspaceId: app.config.workspaceId, dirIds: ['roxy_profile_a'], isSoftDelete: true }); + assert.equal(deleted[0].persistedJson.find((child) => child.id === 'child_alpha')[scope], true, + 'complete JSON must already be on disk when Roxy receives deletion'); + assert.deepEqual(deleted[0].openedProfiles, []); + const deletionIndex = app.requests.indexOf(deleted[0]); + const closedIndex = app.requests.findIndex((request) => request.path === '/browser/close'); + assert.ok(closedIndex >= 0 && closedIndex < deletionIndex); + assert.ok(app.requests.slice(closedIndex + 1, deletionIndex).some((request) => request.path === '/browser/connection_info' + && request.query.dirIds === 'roxy_profile_a' && !request.openedProfiles.includes('roxy_profile_a'))); + const stored = (await app.saved()).state; + assert.deepEqual(stored.settings.integrations.roxy.profiles, + [{ childId: 'child_beta', dirId: 'untouched_beta_profile', proxyId: proxies[2].id }]); + assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, []); + assert.equal(stored.children.find((child) => child.id === 'child_alpha').roxyProxyId, proxies[1].id); + assert.equal(app.remoteProfiles.length, 0); + assert.equal(app.openedProfiles.size, 0); + const beforeCached = app.requests.length; + const cached = await app.request(scope === 'team' ? '/api/children/child_alpha/team-quota' : '/api/children/child_alpha/acquire', { + method: 'POST', body: scope === 'team' ? { motherId: 'fixture_mother', workspaceId: app.workspaceId } : { mode: 'refresh-at' } }); + assert.equal(cached.status, 200, JSON.stringify(cached.data)); + const routed = app.requests.slice(beforeCached).filter((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path)); + assert.equal(routed.length, 1); + assert.equal(routed[0].proxyAuth, `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`); + assert.ok(!app.requests.slice(beforeCached).some((request) => request.path.startsWith('/browser/'))); + assertNoSecrets(job); + assertNoSecrets(app.output()); + }); +} + +for (const deletionFailure of ['deleteFailure', 'deleteStillListed']) { + test(`${deletionFailure} retains saved JSON and reports a cleanup warning without another authorization`, { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, [deletionFailure]: true }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, deleteProfileAfterSuccess: true })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + assert.equal(job.results[0].status, 'ready'); + assert.equal(job.results[0].roxyCleanup?.deleted, false); + assert.ok(job.logs.some((entry) => entry.status === 'warning'), 'environment release failure must remain visible as a warning'); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 1); + assert.equal(app.remoteProfiles.length, 1); + assert.equal(app.openedProfiles.size, 0); + const saved = (await app.saved()).state; + assert.ok(saved.children.find((child) => child.id === 'child_alpha').workspaceTokens[app.workspaceId].refreshToken); + assert.deepEqual(saved.settings.integrations.roxy.poolProfiles, [{ slot: 0, dirId: 'roxy_profile_a' }]); + const beforeSkip = app.requests.length; + const repeated = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother', retryAccount: true }); + assert.equal(repeated.succeeded, 1, JSON.stringify(repeated)); + assert.equal(repeated.results[0].reused, true); + assert.deepEqual(app.requests.slice(beforeSkip), [], 'saved credentials must skip both reauthorization and unrelated cleanup'); + assertNoSecrets(job); + assertNoSecrets(repeated); + }); +} + +for (const scenario of ['incomplete-json', 'verification', 'close-failure', 'manual-profile']) { + test(`successful-environment deletion is suppressed for ${scenario}`, { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, + ...(scenario === 'manual-profile' ? {} : { profileCapacity: 3 }), + ...(scenario === 'incomplete-json' ? { tokenFields: { refresh_token: '' } } : {}), + ...(scenario === 'verification' ? { browserVerification: true } : {}), + ...(scenario === 'close-failure' ? { cleanupFailureFor: ['roxy_profile_a'] } : {}) }); + assert.equal((await app.patch({ ...app.config, profileMode: scenario === 'manual-profile' ? 'dedicated' : 'pooled', + poolSize: 3, deleteProfileAfterSuccess: true })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, scenario === 'manual-profile' ? 1 : 0, JSON.stringify(job)); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 0); + assert.equal(app.remoteProfiles.length, 1); + assert.equal((await app.saved()).state.settings.integrations.roxy.profiles[0].dirId, 'roxy_profile_a'); + assertNoSecrets(job); + }); +} + +for (const failure of [ + { name: 'rejected credentials', code: 'browser_oauth_credentials_rejected', status: 401, profileMode: 'pooled', scope: 'team' }, + { name: 'official verification', code: 'browser_verification_required', status: 403, profileMode: 'dedicated', scope: 'team' }, + { name: 'OAuth timeout', code: 'browser_oauth_timeout', status: 408, profileMode: 'pooled', scope: 'free' }, + { name: 'incomplete JSON', profileMode: 'pooled', scope: 'team' }, +]) test(`failed-environment release recycles ${failure.name} without changing the failed result`, { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: failure.scope === 'team', profileCapacity: 3, + ...(failure.code ? { browserFailureSequences: { [children[0].email]: [failure] } } : { tokenFields: { refresh_token: '' } }) }); + assert.equal((await app.patch({ ...app.config, profileMode: failure.profileMode, poolSize: 3, + deleteProfileAfterFailure: true })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], ...(failure.scope === 'team' ? { scope: 'team', motherId: 'fixture_mother' } : {}) }); + assert.equal(job.succeeded, 0, JSON.stringify(job)); + assert.ok(['failed', 'needs_attention'].includes(job.results[0].status)); + if (failure.code) assert.equal(job.results[0].code, failure.code); + assert.equal(job.results[0].roxyCleanup?.deleted, true); + assert.ok(job.logs.some((entry) => entry.message.includes('回收站') && entry.message.includes('失败')), + 'release must be visible in the running log without claiming the authorization succeeded'); + const deleted = app.requests.filter((request) => request.path === '/browser/delete'); + assert.equal(deleted.length, 1); + assert.deepEqual(JSON.parse(deleted[0].body), { workspaceId: app.config.workspaceId, dirIds: ['roxy_profile_a'], isSoftDelete: true }); + assert.deepEqual(deleted[0].openedProfiles, []); + assert.ok(app.requests.findIndex((request) => request.path === '/browser/close') < app.requests.indexOf(deleted[0])); + assert.equal(app.openedProfiles.size, 0); + assert.equal(app.remoteProfiles.length, 0); + const saved = (await app.saved()).state; + assert.equal(saved.children.length, children.length, 'releasing browser capacity must preserve local accounts'); + assert.deepEqual(saved.settings.integrations.roxy.profiles, []); + assert.deepEqual(saved.settings.integrations.roxy.poolProfiles || [], []); + assertNoSecrets(job); + assertNoSecrets(app.output()); +}); + +test('failed-environment release closes a headed dedicated verification window even when the browser asks to keep it open', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, failBrowserFor: [children[0].email] }); + assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', headless: false, deleteProfileAfterFailure: true })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.results[0].code, 'browser_verification_required'); + assert.equal(job.results[0].roxyCleanup?.deleted, true); + assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1); + assert.equal(app.openedProfiles.size, 0); + assert.equal(app.remoteProfiles.length, 0); + assertNoSecrets(job); +}); + +test('failed-environment release reclaims a newly created environment when Roxy refuses to open it', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, + accountId: 'child_beta', openFailureFor: ['created_profile_1'] }); + assert.equal((await app.patch({ ...app.config, deleteProfileAfterFailure: true })).status, 200); + const job = await app.runJob({ ids: ['child_beta'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.results[0].code, 'roxy_api_failed'); + assert.equal(job.results[0].roxyCleanup?.deleted, true); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 1); + assert.deepEqual(app.requests.filter((request) => request.path === '/browser/delete').map((request) => JSON.parse(request.body).dirIds), [['created_profile_1']]); + assert.deepEqual(app.remoteProfiles.map((profile) => profile.dirId), ['roxy_profile_a']); + assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, app.config.profiles); + assert.equal(app.openedProfiles.size, 0); + assertNoSecrets(job); +}); + +test('failed-environment release reclaims an existing owned dedicated environment when Roxy refuses to open it', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, openFailureFor: ['roxy_profile_a'] }); + assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', deleteProfileAfterFailure: true })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.results[0].code, 'roxy_api_failed'); + assert.equal(job.results[0].roxyCleanup?.deleted, true); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + const deleted = app.requests.filter((request) => request.path === '/browser/delete'); + assert.equal(deleted.length, 1); + assert.deepEqual(JSON.parse(deleted[0].body).dirIds, ['roxy_profile_a']); + assert.deepEqual(deleted[0].openedProfiles, []); + assert.equal(app.remoteProfiles.length, 0); + assert.deepEqual((await app.saved()).state.settings.integrations.roxy.profiles, []); + assertNoSecrets(job); +}); + +test('failed-environment release survives an unexpected post-browser exception and reports cleanup on the failed result', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, + unexpectedLoginErrorFor: [children[0].email] }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', deleteProfileAfterFailure: true })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, 0, JSON.stringify(job)); + assert.equal(job.results[0].status, 'failed'); + assert.equal(job.results[0].roxyCleanup?.deleted, true, 'an unexpected exception must not lose the successful capacity release report'); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 1); + assert.equal(app.remoteProfiles.length, 0); + assert.equal(app.openedProfiles.size, 0); + assert.equal((await app.saved()).state.children[0].workspaceTokens?.[app.workspaceId], undefined); + assert.doesNotMatch(JSON.stringify(job), /private-unexpected-login-error/); + assertNoSecrets(job); +}); + +for (const deletionFailure of ['deleteFailure', 'deleteStillListed']) { + test(`failed-environment ${deletionFailure} preserves the login error and leaves the saved binding available for recovery`, { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, [deletionFailure]: true, + browserFailureSequences: { [children[0].email]: [{ code: 'browser_oauth_credentials_rejected', status: 401 }] } }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', deleteProfileAfterFailure: true })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, 0); + assert.equal(job.results[0].code, 'browser_oauth_credentials_rejected'); + assert.equal(job.results[0].roxyCleanup?.deleted, false); + assert.ok(job.logs.some((entry) => entry.status === 'warning')); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 1); + assert.equal(app.remoteProfiles.length, 1); + assert.equal(app.openedProfiles.size, 0); + assert.equal((await app.saved()).state.settings.integrations.roxy.profiles[0].dirId, 'roxy_profile_a'); + assertNoSecrets(job); + }); +} + +for (const scenario of ['close-failure', 'manual-profile']) { + test(`failed-environment release refuses to delete a ${scenario}`, { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, + ...(scenario === 'close-failure' ? { profileCapacity: 3, cleanupFailureFor: ['roxy_profile_a'] } : {}), + browserFailureSequences: { [children[0].email]: [{ code: 'browser_oauth_credentials_rejected', status: 401 }] } }); + assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', deleteProfileAfterFailure: true })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, 0); + assert.equal(job.results[0].code, 'browser_oauth_credentials_rejected'); + assert.equal(job.results[0].roxyCleanup?.deleted, false); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 0); + assert.equal(app.remoteProfiles.length, 1); + assert.equal((await app.saved()).state.settings.integrations.roxy.profiles[0].dirId, 'roxy_profile_a'); + assertNoSecrets(job); + }); +} + +test('failed-environment release waits through the three-proxy budget and logs allow retrying only the failed account in a new environment', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, concurrentAccounts: children, profileCapacity: 3, + browserFailureSequences: { [children[0].email]: Array(3).fill('browser_oauth_proxy_connection_failed') } }); + const proxies = await app.addProxies(); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 1, useProxyPool: true, + deleteProfileAfterSuccess: true, deleteProfileAfterFailure: true, + profiles: [{ ...app.config.profiles[0], proxyId: proxies[0].id }] })).status, 200); + const initial = await app.waitJob((await startTeamJob(app, children)).id); + assert.equal(initial.succeeded, 1, JSON.stringify(initial)); + const failed = initial.results.find((result) => result.id === children[0].id); + assert.equal(failed.code, 'browser_oauth_proxy_connection_failed'); + assert.equal(failed.roxyCleanup?.deleted, true); + const firstDelete = app.requests.findIndex((request) => request.path === '/browser/delete'); + const priorOpens = app.requests.slice(0, firstDelete).filter((request) => request.path === '/browser/open'); + assert.equal(priorOpens.length, 3, 'internal proxy retries must finish before deleting their shared environment'); + assert.ok(priorOpens.every((request) => JSON.parse(request.body).dirId === 'roxy_profile_a')); + assert.equal(app.requests.slice(0, firstDelete).filter((request) => request.path === '/browser/create').length, 0); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 2); + assert.equal(app.remoteProfiles.length, 0); + const beforeRetry = app.requests.length; + const retried = await app.runJob({ ids: [children[0].id], scope: 'team', motherId: 'fixture_mother', + retryFailed: true, retryJobId: initial.id }); + assert.equal(retried.succeeded, 1, JSON.stringify(retried)); + assert.deepEqual(retried.results.map((result) => result.id), [children[0].id]); + assert.equal(retried.results[0].roxyCleanup?.deleted, true); + const retryRequests = app.requests.slice(beforeRetry); + assert.equal(retryRequests.filter((request) => request.path === '/browser/create').length, 1); + const retryOpen = retryRequests.filter((request) => request.path === '/browser/open'); + assert.equal(retryOpen.length, 1); + assert.notEqual(JSON.parse(retryOpen[0].body).dirId, 'roxy_profile_a'); + assert.equal(app.remoteProfiles.length, 0); + assert.equal(app.openedProfiles.size, 0); + const beforeSkip = app.requests.length; + const skipped = await app.runJob({ ids: [children[1].id], scope: 'team', motherId: 'fixture_mother', retryAccount: true }); + assert.equal(skipped.results[0].reused, true); + assert.deepEqual(app.requests.slice(beforeSkip), []); + assertNoSecrets(initial); + assertNoSecrets(retried); +}); + +test('failed-environment release holds the serial queue until deletion is confirmed before the next account starts', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: children, profileCapacity: 3, + holdDeleteFor: ['roxy_profile_a'], browserFailureSequences: { [children[0].email]: ['browser_oauth_timeout'] } }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 1, + deleteProfileAfterFailure: true, deleteProfileAfterSuccess: true })).status, 200); + const started = await startTeamJob(app, children); + await waitForFixture(() => app.heldDeletes.has('roxy_profile_a'), 'failed account deletion should remain in flight'); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + assert.equal((await app.request(`/api/children/auto-json/${started.id}`)).data.job.status, 'running'); + app.heldDeletes.get('roxy_profile_a')(); + const job = await app.waitJob(started.id); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + assert.equal(job.results.find((result) => result.id === children[0].id).roxyCleanup?.deleted, true); + const opens = app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId); + assert.equal(opens.length, 2); + assert.notEqual(opens[0], opens[1]); + assert.equal(app.browserPeak(), 1); + assert.equal(app.remoteProfiles.length, 0); + assertNoSecrets(job); +}); + +test('failed-environment release reclaims a closed concurrent account even while another owned window cannot close', { timeout: 20000 }, async (t) => { + const remoteProfiles = children.map((child, slot) => ({ dirId: `failure_profile_${slot}`, + windowName: `Fixture failed ${child.id}`, windowRemark: `auto-team:pool:${slot}` })); + const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: children, holdBrowser: true, + profileCapacity: 3, remoteProfiles, cleanupFailureFor: [remoteProfiles[0].dirId], + browserFailureSequences: Object.fromEntries(children.map((child) => [child.email, + [{ code: 'browser_oauth_credentials_rejected', status: 401 }]])) }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 2, + deleteProfileAfterFailure: true, profiles: [], + poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200); + const started = await startTeamJob(app, children); + await waitForFixture(() => app.heldBrowsers.size === 2, 'both accounts must acquire their windows before either fails'); + app.releaseAccount(children[0].email); + let firstResult; + for (let attempt = 0; attempt < 100; attempt += 1) { + firstResult = (await app.request(`/api/children/auto-json/${started.id}`)).data.job.results.find((result) => result.id === children[0].id); + if (firstResult) break; + await delay(20); + } + assert.equal(firstResult?.code, 'browser_oauth_credentials_rejected'); + assert.equal(firstResult.roxyCleanup?.deleted, false); + assert.equal(app.openedProfiles.has(remoteProfiles[0].dirId), true); + app.releaseAccount(children[1].email); + const job = await app.waitJob(started.id); + const secondResult = job.results.find((result) => result.id === children[1].id); + assert.equal(secondResult.code, 'browser_oauth_credentials_rejected'); + assert.equal(secondResult.roxyCleanup?.deleted, true, 'an unrelated close failure must not block reclaiming this already closed environment'); + assert.deepEqual(app.requests.filter((request) => request.path === '/browser/delete').map((request) => JSON.parse(request.body).dirIds), [[remoteProfiles[1].dirId]]); + assert.deepEqual(app.remoteProfiles.map((profile) => profile.dirId), [remoteProfiles[0].dirId]); + assert.deepEqual([...app.openedProfiles], [remoteProfiles[0].dirId]); + assertNoSecrets(job); +}); + +test('failed-environment release waits for a late browser startup and confirmed closure before removing its environment', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, profileCapacity: 3, + holdOpenFor: ['roxy_profile_a'], timeoutBeforeSessionFor: [children[0].email] }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', deleteProfileAfterFailure: true })).status, 200); + const started = await startTeamJob(app, [children[0]]); + await waitForFixture(() => app.heldOpens.has('roxy_profile_a'), 'Roxy open should remain pending after OAuth timeout'); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 0); + app.heldOpens.get('roxy_profile_a')(); + const job = await app.waitJob(started.id); + assert.equal(job.results[0].code, 'browser_oauth_timeout'); + assert.equal(job.results[0].roxyCleanup?.deleted, true); + const deletion = app.requests.find((request) => request.path === '/browser/delete'); + assert.deepEqual(deletion.openedProfiles, []); + assert.ok(app.requests.findIndex((request) => request.path === '/browser/close') < app.requests.indexOf(deletion)); + assert.equal(app.openedProfiles.size, 0); + assert.equal(app.remoteProfiles.length, 0); + assertNoSecrets(job); +}); + +test('deleting saved environments holds all three leases until removal and the next account never reopens a deleted directory', { timeout: 20000 }, async (t) => { + const accounts = parallelChildren.slice(0, 4); + const remoteProfiles = accounts.slice(0, 3).map((child, slot) => ({ dirId: `delete_profile_${slot}`, + windowName: `Fixture delete ${slot}`, windowRemark: `auto-team:pool:${slot}` })); + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, holdBrowser: true, + concurrentAccounts: accounts, profileCapacity: 3, remoteProfiles, holdDeleteFor: remoteProfiles.map((profile) => profile.dirId) }); + const proxies = await app.addProxies(); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 3, + useProxyPool: true, deleteProfileAfterSuccess: true, + profiles: accounts.map((child, slot) => ({ childId: child.id, dirId: `delete_profile_${slot}`, proxyId: proxies[slot % 3].id })), + poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200); + const started = await startTeamJob(app, accounts); + await waitForFixture(() => app.heldBrowsers.size === 3, 'the first three accounts must occupy separate environments'); + for (const child of accounts.slice(0, 3)) app.releaseAccount(child.email); + await waitForFixture(() => app.heldDeletes.size === 1 + && app.requests.filter((request) => request.path === '/browser/close').length === 3, + 'successful accounts must retain their leases while deletion is pending under the provisioning lock'); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 3); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + for (const request of app.requests.filter((request) => request.path === '/browser/delete')) { + assert.deepEqual(request.openedProfiles.filter((dirId) => JSON.parse(request.body).dirIds.includes(dirId)), []); + assert.ok(request.persistedJson.some((child) => child.team)); + } + for (let completed = 0; completed < 3; completed += 1) { + await waitForFixture(() => app.heldDeletes.size > 0, 'each owned environment should reach serialized deletion'); + const [dirId, release] = app.heldDeletes.entries().next().value; + release(); + app.heldDeletes.delete(dirId); + } + await waitForFixture(() => app.heldBrowsers.has(accounts[3].email), 'the fourth account must create a replacement only after deletion completes'); + const opened = app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId); + assert.equal(opened.length, 4); + assert.ok(!remoteProfiles.some((profile) => profile.dirId === opened[3])); + app.releaseAccount(accounts[3].email); + const job = await app.waitJob(started.id); + assert.equal(job.succeeded, 4, JSON.stringify(job)); + assert.ok(job.results.every((result) => result.roxyCleanup?.deleted === true)); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 4); + assert.equal(app.remoteProfiles.length, 0); + assert.equal(app.browserPeak(), 3); + assert.equal(app.profilePeak(), 3, 'saved Roxy environments must never exceed the three-slot capacity'); + assert.equal(app.openedProfiles.size, 0); + assertConfirmedClosures(app.requests); + const stored = (await app.saved()).state; + assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, []); + assert.ok(stored.settings.integrations.roxy.profiles.every((profile) => !app.deletedProfiles.has(profile.dirId))); + for (const [index, child] of accounts.entries()) { + assert.equal(stored.children.find((entry) => entry.id === child.id).roxyProxyId, proxies[index % 3].id); + } + assertNoSecrets(job); +}); + +test('six accounts with saved Team JSON skip environment deletion even when release-after-success is enabled', { timeout: 20000 }, async (t) => { + const workspaceId = 'target_team_workspace'; + const accounts = Array.from({ length: 6 }, (_, index) => ({ id: index ? `saved_child_${index}` : 'child_alpha', + email: `saved-${index}@example.test`, password: `private-saved-${index}` })).map((child) => ({ ...child, + workspaceTokens: { [workspaceId]: { accessToken: oauthToken(child, workspaceId, 'team').access_token, + refreshToken: `private-fixture-rt-${child.id}` } } })); + const remoteProfiles = Array.from({ length: 3 }, (_, slot) => ({ dirId: `other_profile_${slot}`, + windowName: `Unrelated saved profile ${slot}`, windowRemark: `auto-team:pool:${slot}` })); + const app = await fixture(t, { browserFlow: true, teamFlow: true, concurrentAccounts: accounts, profileCapacity: 3, remoteProfiles }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, deleteProfileAfterSuccess: true, + poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200); + const job = await app.waitJob((await startTeamJob(app, accounts)).id); + assert.equal(job.succeeded, 6, JSON.stringify(job)); + assert.ok(job.results.every((result) => result.reused === true)); + assert.deepEqual(app.requests, [], 'cached successful JSON must not inspect, open, close or delete any environment'); + assert.deepEqual(app.remoteProfiles, remoteProfiles); + assertNoSecrets(job); +}); + +test('saved-environment cleanup rejects unauthenticated, disabled and malformed requests without contacting Roxy', { timeout: 20000 }, async (t) => { + const app = await fixture(t); + const route = '/api/integrations/roxy/cleanup-successful'; + assert.equal((await app.request(route, { method: 'POST', body: { childIds: ['child_alpha'] }, authenticated: false })).status, 401); + assert.equal((await app.request(route, { method: 'POST', body: { childIds: ['child_alpha'] } })).status, 409); + assert.equal((await app.patch({ ...app.config, deleteProfileAfterSuccess: true })).status, 200); + for (const body of [{}, { childIds: [] }, { childIds: 'child_alpha' }, { childIds: null }, { childIds: Array(1001).fill('child_alpha') }]) { + const rejected = await app.request(route, { method: 'POST', body }); + assert.equal(rejected.status, 400, JSON.stringify(rejected.data)); + assert.equal(rejected.data.code, 'child_ids_required'); + } + assert.deepEqual(app.requests, []); +}); + +test('saved-environment cleanup deletes only selected owned profiles with complete JSON and keeps other bindings and proxies', { timeout: 20000 }, async (t) => { + const accounts = parallelChildren.slice(0, 3).map((child, index) => ({ ...child, + accessToken: oauthToken(child).access_token, + ...(index !== 1 ? { refreshToken: `private-fixture-rt-${child.id}` } : {}) })); + const remoteProfiles = accounts.map((child, index) => ({ dirId: `saved_cleanup_${index}`, windowName: `Fixture saved ${index}`, + ...(index !== 2 ? { windowRemark: `auto-team:child:${child.id}` } : {}) })); + const app = await fixture(t, { concurrentAccounts: accounts, remoteProfiles }); + const proxies = await app.addProxies(); + assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', useProxyPool: true, deleteProfileAfterSuccess: true, + profiles: accounts.map((child, index) => ({ childId: child.id, dirId: remoteProfiles[index].dirId, proxyId: proxies[index].id })), + poolProfiles: [{ slot: 0, dirId: remoteProfiles[0].dirId }] })).status, 200); + const result = await app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', + body: { childIds: [...accounts.map((child) => child.id), 'missing_child'] } }); + assert.equal(result.status, 200, JSON.stringify(result.data)); + assert.equal(result.data.deleted, 1); + assert.equal(result.data.results.find((entry) => entry.childId === accounts[0].id)?.deleted, true); + assert.ok(!result.data.results.some((entry) => entry.childId === accounts[1].id), 'a missing refresh token cannot qualify for environment deletion'); + assert.equal(result.data.results.find((entry) => entry.childId === accounts[2].id)?.deleted, false, 'an explicitly mapped manual environment must be preserved'); + assert.deepEqual(app.requests.filter((request) => request.path === '/browser/delete').map((request) => JSON.parse(request.body).dirIds), [[remoteProfiles[0].dirId]]); + assert.ok(!app.requests.some((request) => ['/browser/open', '/browser/close', '/fixture/token'].includes(request.path))); + assert.deepEqual(app.remoteProfiles.map((profile) => profile.dirId), remoteProfiles.slice(1).map((profile) => profile.dirId)); + const stored = (await app.saved()).state; + assert.deepEqual(stored.settings.integrations.roxy.poolProfiles, []); + assert.deepEqual(stored.settings.integrations.roxy.profiles.map((profile) => profile.childId), accounts.slice(1).map((child) => child.id)); + assert.equal(stored.children.find((child) => child.id === accounts[0].id).roxyProxyId, proxies[0].id); + for (const child of accounts.filter((_, index) => index !== 1)) { + assert.equal(stored.children.find((entry) => entry.id === child.id).refreshToken, child.refreshToken); + } + assertNoSecrets(result.data); +}); + +test('saved-environment cleanup rejects another cleanup while deletion is pending', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { profileCapacity: 3, holdDeleteFor: ['roxy_profile_a'], + priorFields: { accessToken: oauthToken(children[0]).access_token, refreshToken: 'private-fixture-rt-child_alpha' } }); + assert.equal((await app.patch({ ...app.config, deleteProfileAfterSuccess: true })).status, 200); + const body = { childIds: ['child_alpha'] }; + const first = app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', body }); + void first.catch(() => {}); + await waitForFixture(() => app.heldDeletes.has('roxy_profile_a'), 'the first cleanup should hold the profile deletion operation'); + const second = await app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', body }); + assert.equal(second.status, 409); + assert.equal(second.data.code, 'roxy_profile_busy'); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 1); + app.heldDeletes.get('roxy_profile_a')(); + assert.equal((await first).data.deleted, 1); + assert.equal(app.remoteProfiles.length, 0); +}); + +test('saved-environment cleanup rejects requests while an account authorization holds a browser lease', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, teamFlow: true, holdBrowser: true, profileCapacity: 3 }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, deleteProfileAfterSuccess: true })).status, 200); + const started = await startTeamJob(app, [children[0]]); + await app.waitBrowser(); + const beforeCleanup = app.requests.length; + const rejected = await app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', body: { childIds: ['child_alpha'] } }); + assert.equal(rejected.status, 409); + assert.equal(rejected.data.code, 'roxy_profile_busy'); + assert.deepEqual(app.requests.slice(beforeCleanup), []); + app.releaseAccount(children[0].email); + assert.equal((await app.waitJob(started.id)).succeeded, 1); +}); + +test('saved-environment cleanup ignores credentials belonging to a different account', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { profileCapacity: 3, + priorFields: { accessToken: oauthToken(children[1]).access_token, refreshToken: 'private-fixture-rt-child_beta' } }); + assert.equal((await app.patch({ ...app.config, deleteProfileAfterSuccess: true })).status, 200); + const result = await app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', body: { childIds: ['child_alpha'] } }); + assert.equal(result.status, 200); + assert.equal(result.data.deleted, 0); + assert.deepEqual(result.data.results, []); + assert.deepEqual(app.requests, []); + assert.equal(app.remoteProfiles.length, 1); +}); + +test('a new authorization waits for the whole historical cleanup batch before leasing either selected environment', { timeout: 20000 }, async (t) => { + const accounts = parallelChildren.slice(0, 3).map((child, index) => ({ ...child, + ...(index < 2 ? { accessToken: oauthToken(child).access_token, refreshToken: `private-fixture-rt-${child.id}` } : {}) })); + const remoteProfiles = accounts.slice(0, 2).map((child, index) => ({ dirId: `historical_profile_${index}`, + windowName: `Historical ${index}`, windowRemark: `auto-team:child:${child.id}` })); + const app = await fixture(t, { browserFlow: true, teamFlow: true, holdBrowser: true, concurrentAccounts: accounts, + remoteProfiles, profileCapacity: 3, holdDeleteFor: remoteProfiles.map((profile) => profile.dirId) }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 3, deleteProfileAfterSuccess: true, + profiles: accounts.slice(0, 2).map((child, index) => ({ childId: child.id, dirId: remoteProfiles[index].dirId })) })).status, 200); + const cleanup = app.request('/api/integrations/roxy/cleanup-successful', { method: 'POST', + body: { childIds: accounts.slice(0, 2).map((child) => child.id) } }); + void cleanup.catch(() => {}); + await waitForFixture(() => app.heldDeletes.has(remoteProfiles[0].dirId), 'historical cleanup must start its first deletion'); + const started = await startTeamJob(app, [accounts[2]]); + let queued = false; + for (let attempt = 0; attempt < 100; attempt += 1) { + const current = await app.request(`/api/children/auto-json/${started.id}`); + queued = current.data.job.logs.some((entry) => entry.message.includes('等待空闲 Roxy')); + if (queued) break; + await delay(10); + } + assert.equal(queued, true, 'the new account should reach the environment queue while cleanup is held'); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 0); + app.heldDeletes.get(remoteProfiles[0].dirId)(); + await waitForFixture(() => app.heldDeletes.has(remoteProfiles[1].dirId), 'the cleanup batch must continue to its second selected environment'); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 0, + 'completion of one historical deletion must not let a new authorization lease the next cleanup candidate'); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + app.heldDeletes.get(remoteProfiles[1].dirId)(); + const released = await cleanup; + assert.equal(released.status, 200); + assert.equal(released.data.deleted, 2); + await waitForFixture(() => app.heldBrowsers.has(accounts[2].email), 'the new account should start after the complete cleanup batch finishes'); + const opened = app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId); + assert.equal(opened.length, 1); + assert.ok(!remoteProfiles.some((profile) => profile.dirId === opened[0])); + app.releaseAccount(accounts[2].email); + const job = await app.waitJob(started.id); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + assert.equal(app.requests.filter((request) => request.path === '/browser/delete').length, 3); + assert.equal(app.remoteProfiles.length, 0); + assertNoSecrets(job); + assertNoSecrets(released.data); +}); + +for (const profileMode of ['pooled', 'dedicated']) test(`${profileMode} proxy failover closes the first attempt, reserves both proxies and persists the successful route`, { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, holdBrowser: true, profileCapacity: 3, + browserFailureSequences: { [children[0].email]: ['browser_oauth_proxy_connection_failed'] } }); + const proxies = await app.addProxies(); + assert.equal((await app.patch({ ...app.config, profileMode, poolSize: 3, useProxyPool: true, + profiles: [{ ...app.config.profiles[0], proxyId: proxies[0].id }] })).status, 200); + const started = await startTeamJob(app, [children[0]]); + await app.waitBrowser(); + app.releaseAccount(children[0].email); + await waitForFixture(() => app.requests.filter((request) => request.path === '/browser/mdf').length === 2 + && app.heldBrowsers.has(children[0].email), 'the second proxy must receive a fresh browser attempt after closure'); + for (const proxy of proxies.slice(0, 2)) { + const deletion = await app.request(`/api/proxy/entries/${proxy.id}`, { method: 'DELETE' }); + assert.equal(deletion.status, 409, 'all proxies used by the in-flight authorization must remain reserved'); + assert.equal(deletion.data.code || deletion.data.message, 'proxy_in_use'); + } + app.releaseAccount(children[0].email); + const job = await app.waitJob(started.id); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + assert.equal(job.results[0].quotaChecked, true); + const updates = app.requests.filter((request) => request.path === '/browser/mdf').map((request) => JSON.parse(request.body)); + assert.deepEqual(updates.map((request) => request.proxyInfo.proxyUserName), ['fixture-proxy-alpha', 'fixture-proxy-beta']); + assert.deepEqual(updates.map((request) => request.dirId), ['roxy_profile_a', 'roxy_profile_a']); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 2); + assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 2); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + assertConfirmedClosures(app.requests); + const contexts = app.requests.filter((request) => request.path === '/fixture/context-create').map((request) => JSON.parse(request.body)); + assert.equal(contexts.length, profileMode === 'pooled' ? 2 : 0); + assert.equal(new Set(contexts.map((context) => context.contextId)).size, contexts.length); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, contexts.length); + assert.equal(app.browserPeak(), 1); + assert.equal(app.openedProfiles.size, 0); + const stored = (await app.saved()).state; + assert.equal(profileMode === 'pooled' ? stored.children.find((child) => child.id === 'child_alpha').roxyProxyId + : stored.settings.integrations.roxy.profiles.find((profile) => profile.childId === 'child_alpha').proxyId, proxies[1].id); + const expectedAuth = `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`; + const routed = app.requests.filter((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path)); + assert.equal(routed.length, 2); + assert.ok(routed.every((request) => request.proxyAuth === expectedAuth), 'token exchange and quota must use the successful browser proxy'); + const beforeQuota = app.requests.length; + const quota = await app.request('/api/children/child_alpha/team-quota', { method: 'POST', + body: { motherId: 'fixture_mother', workspaceId: app.workspaceId } }); + assert.equal(quota.status, 200, JSON.stringify(quota.data)); + assert.ok(app.requests.slice(beforeQuota).filter((request) => request.path === '/backend-api/wham/usage') + .every((request) => request.proxyAuth === expectedAuth)); + assert.ok(!app.requests.slice(beforeQuota).some((request) => request.path.startsWith('/browser/'))); + for (const proxy of proxies.slice(0, 2)) { + assert.equal((await app.request(`/api/proxy/entries/${proxy.id}`, { method: 'DELETE' })).status, 200, + 'the authorization must release both proxy reservations when it completes'); + } + assertNoSecrets(job); + assertNoSecrets(app.output()); +}); + +test('proxy failover tries at most three distinct proxies per account and lets the next account complete with its own budget', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, concurrentAccounts: children, profileCapacity: 3, + browserFailureSequences: { [children[0].email]: Array(4).fill('browser_oauth_proxy_connection_failed'), + [children[1].email]: ['browser_oauth_proxy_connection_failed'] } }); + const proxies = await app.addProxies({ names: ['alpha', 'beta', 'gamma', 'delta'] }); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 1, useProxyPool: true, + profiles: children.map((child, index) => ({ childId: child.id, dirId: index ? 'old_beta_profile' : 'roxy_profile_a', proxyId: proxies[0].id })) })).status, 200); + const job = await app.waitJob((await startTeamJob(app, children)).id); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + assert.equal(job.completed, 2); + assert.equal(job.results.find((result) => result.id === children[0].id).code, 'browser_oauth_proxy_connection_failed'); + assert.equal(job.results.find((result) => result.id === children[1].id).quotaChecked, true); + const contexts = app.requests.filter((request) => request.path === '/fixture/context-auth').map((request) => JSON.parse(request.body)); + assert.deepEqual(contexts.filter((context) => context.childId === children[0].id).map((context) => context.username), + ['fixture-proxy-alpha', 'fixture-proxy-beta', 'fixture-proxy-gamma']); + assert.deepEqual(contexts.filter((context) => context.childId === children[1].id).map((context) => context.username), + ['fixture-proxy-alpha', 'fixture-proxy-beta']); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 5); + assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 5); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 5); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + assertConfirmedClosures(app.requests); + assert.equal(app.browserPeak(), 1); + assert.equal(app.openedProfiles.size, 0); + const expectedAuth = `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`; + const routed = app.requests.filter((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path)); + assert.equal(routed.length, 2); + assert.ok(routed.every((request) => request.proxyAuth === expectedAuth)); + assertNoSecrets(job); + assertNoSecrets(app.output()); +}); + +for (const failure of [ + { code: 'browser_oauth_failed', status: 403 }, + { code: 'browser_oauth_verification_required', status: 403 }, + { code: 'browser_oauth_credentials_rejected', status: 401 }, +]) test(`proxy failover does not change routes after ${failure.code} HTTP ${failure.status}`, { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, profileCapacity: 3, + browserFailureSequences: { [children[0].email]: [failure] } }); + const proxies = await app.addProxies(); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, useProxyPool: true, + profiles: [{ ...app.config.profiles[0], proxyId: proxies[0].id }] })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, 0); + assert.equal(job.results[0].code, failure.code); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1); + assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1); + assert.equal(app.requests.filter((request) => request.path === '/browser/mdf').length, 1); + assert.equal((await app.saved()).state.children.find((child) => child.id === 'child_alpha').roxyProxyId, proxies[0].id); + assert.ok(!app.requests.some((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path))); + assertConfirmedClosures(app.requests); + assert.equal(app.openedProfiles.size, 0); + assertNoSecrets(job); +}); + +test('proxy failover never starts another attempt when the failed browser cannot be confirmed closed', { timeout: 20000 }, async (t) => { + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, profileCapacity: 3, + cleanupFailureFor: ['roxy_profile_a'], browserFailureSequences: { [children[0].email]: ['browser_oauth_proxy_connection_failed'] } }); + const proxies = await app.addProxies(); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, useProxyPool: true, + profiles: [{ ...app.config.profiles[0], proxyId: proxies[0].id }] })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, 0); + assert.equal(job.results[0].code, 'roxy_cleanup_failed'); + assert.equal(app.requests.filter((request) => request.path === '/browser/open').length, 1); + assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1); + assert.equal(app.requests.filter((request) => request.path === '/browser/mdf').length, 1); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 1); + assert.ok(!app.requests.some((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path))); + assert.equal(app.browserPeak(), 1); + assertNoSecrets(job); + assertNoSecrets(app.output()); +}); + +test('three concurrent proxy failovers reuse their existing pool slots with fresh sessions and never open a fourth environment', { timeout: 20000 }, async (t) => { + const accounts = parallelChildren.slice(0, 3); + const remoteProfiles = accounts.map((child, slot) => ({ dirId: `retry_profile_${slot}`, + windowName: `Fixture retry ${slot}`, windowRemark: `auto-team:pool:${slot}` })); + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, holdBrowser: true, + profileCapacity: 3, remoteProfiles, concurrentAccounts: accounts, + browserFailureSequences: Object.fromEntries(accounts.map((child) => [child.email, ['browser_oauth_proxy_connection_failed']])) }); + const proxies = await app.addProxies(); + assert.equal((await app.patch({ ...app.config, profileMode: 'pooled', poolSize: 3, concurrency: 3, useProxyPool: true, + profiles: accounts.map((child, slot) => ({ childId: child.id, dirId: `old_retry_${slot}`, proxyId: proxies[slot].id })), + poolProfiles: remoteProfiles.map((profile, slot) => ({ slot, dirId: profile.dirId })) })).status, 200); + const started = await startTeamJob(app, accounts); + await waitForFixture(() => app.heldBrowsers.size === 3, 'all three initial attempts should hold a pool slot'); + for (const child of accounts) app.releaseAccount(child.email); + await waitForFixture(() => app.requests.filter((request) => request.path === '/browser/open').length === 6 + && app.heldBrowsers.size === 3, 'all three proxy failovers should reuse the occupied worker slots'); + assert.equal(app.browserPeak(), 3); + for (const child of accounts) app.releaseAccount(child.email); + const job = await app.waitJob(started.id); + assert.equal(job.succeeded, 3, JSON.stringify(job)); + const contexts = app.requests.filter((request) => request.path === '/fixture/context-create').map((request) => JSON.parse(request.body)); + assert.equal(contexts.length, 6); + assert.equal(new Set(contexts.map((context) => context.contextId)).size, 6); + assert.equal(new Set(contexts.map((context) => context.dirId)).size, 3); + for (const child of accounts) { + const attempts = contexts.filter((context) => context.childId === child.id); + assert.equal(attempts.length, 2); + assert.equal(attempts[0].dirId, attempts[1].dirId, 'proxy failover must preserve its current pool slot'); + } + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 6); + assert.equal(app.requests.filter((request) => request.path === '/fixture/context-close').length, 6); + assertConfirmedClosures(app.requests); + assert.equal(app.openedProfiles.size, 0); + assertNoSecrets(job); + assertNoSecrets(app.output()); +}); + test('Roxy concurrency partial API saves persist without changing secrets, profiles or protocol concurrency', async (t) => { const app = await fixture(t); assert.equal((await app.request('/api/state')).data.settings.integrations.roxy.concurrency, 1); @@ -608,7 +1617,7 @@ test('Roxy retains an account-specific chained relay with a headed failed window async close() { opened = false; }, }; const dependencies = { client: api, connect: async () => ({ - newPage: async () => ({ close: async () => {} }), disconnect: async () => {}, + newPage: async () => ({ close: async () => {}, authenticate: async () => {} }), disconnect: async () => {}, }), createRelay: async (options) => { assert.deepEqual(options, { localProxyUrl: 'http://127.0.0.1:7897', upstreamProxyUrl: proxyEntry.url, timeoutMs: 30000 }); const relay = { url: `http://chain:private-relay-${relays.length}@127.0.0.1:${51000 + relays.length}`, @@ -1007,6 +2016,37 @@ test('automatic Team JSON selects the target workspace through Roxy and saves on assertNoSecrets(job); }); +test('a deleted dedicated binding recovers the existing account environment and retains its fixed proxy', { timeout: 20000 }, async (t) => { + const dirId = 'recovered_profile_alpha'; + const app = await fixture(t, { browserFlow: true, proxyFlow: true, teamFlow: true, profileCapacity: 1, + remoteProfiles: [{ dirId, windowName: 'AutoTeam alpha', windowRemark: 'auto-team:child:child_alpha' }] }); + const proxies = await app.addProxies(); + const expectedProxy = proxies[1]; + assert.equal((await app.patch({ ...app.config, profileMode: 'dedicated', useProxyPool: true, + profiles: [{ childId: 'child_alpha', dirId: 'deleted_dedicated_profile', proxyId: expectedProxy.id }] })).status, 200); + const job = await app.runJob({ ids: ['child_alpha'], scope: 'team', motherId: 'fixture_mother' }); + assert.equal(job.succeeded, 1, JSON.stringify(job)); + assert.equal(job.results[0].quotaChecked, true); + assert.equal(app.requests.filter((request) => request.path === '/browser/create').length, 0); + const saved = (await app.saved()).state; + assert.deepEqual(saved.settings.integrations.roxy.profiles, [{ childId: 'child_alpha', dirId, proxyId: expectedProxy.id }]); + assert.deepEqual(app.requests.filter((request) => request.path === '/browser/open').map((request) => JSON.parse(request.body).dirId), [dirId]); + const updated = app.requests.filter((request) => request.path === '/browser/mdf').map((request) => JSON.parse(request.body)); + assert.equal(updated.length, 1); + assert.equal(updated[0].dirId, dirId); + assert.equal(updated[0].proxyInfo.proxyUserName, 'fixture-proxy-beta'); + assert.equal(saved.children.find((child) => child.id === 'child_alpha').workspaceTokens[app.workspaceId].accountId, app.workspaceId); + const expectedAuth = `Basic ${Buffer.from('fixture-proxy-beta:private-proxy-password').toString('base64')}`; + const routed = app.requests.filter((request) => ['/fixture/token', '/backend-api/wham/usage'].includes(request.path)); + assert.equal(routed.length, 2); + assert.ok(routed.every((request) => request.proxyAuth === expectedAuth)); + assert.equal(app.requests.filter((request) => request.path === '/browser/close').length, 1); + assertConfirmedClosures(app.requests); + assert.equal(app.openedProfiles.size, 0); + assertNoSecrets(job); + assertNoSecrets(app.output()); +}); + test('an unmapped account creates its own Roxy profile without using the previous account window', { timeout: 20000 }, async (t) => { const app = await fixture(t, { browserFlow: true, accountId: 'child_beta' }); assert.equal((await app.patch(app.config)).status, 200); diff --git a/server/scoped-member-kick-integration.test.mjs b/server/scoped-member-kick-integration.test.mjs index 9c117be..a0739b6 100644 --- a/server/scoped-member-kick-integration.test.mjs +++ b/server/scoped-member-kick-integration.test.mjs @@ -144,6 +144,45 @@ test('legacy unscoped removal still selects current Team and retains a different assert.equal(child.workspaceHistory[1].status, 'active'); }); +test('removing the final Team member link enables local deletion while another active Team blocks it', async (t) => { + const removedTeams = new Set(); + const app = await fixture(t, { onRequest: (entry) => { + if (entry.method === 'DELETE') { + removedTeams.add(entry.workspaceId); + return { payload: { success: true } }; + } + if (entry.method === 'GET' && /\/users$/.test(entry.path)) { + const items = defaultMembers(entry.workspaceId) + .filter((item) => !removedTeams.has(entry.workspaceId) || item.email !== email); + return { payload: { items, total: items.length, offset: 0 } }; + } + return null; + } }); + + const first = await app.request(); + assert.equal(first.status, 200); + assert.equal(first.data.localDeletion.eligible, false); + assert.equal(first.data.localDeletion.reason, 'child_has_active_team_memberships'); + const blocked = await app.request('/api/children/member', { method: 'DELETE' }); + assert.equal(blocked.status, 409); + assert.equal(blocked.data.message, 'child_has_active_team_memberships'); + assert.equal((await app.saved()).children.length, 1); + + const last = await app.request(endpoint, { body: { ...scope, motherId: 'mother-a', workspaceId: 'team-a' } }); + assert.equal(last.status, 200); + assert.equal(last.data.localDeletion.eligible, true); + assert.equal(last.data.localDeletion.reason, 'departed_account'); + const state = (await app.request('/api/state', { method: 'GET' })).data; + assert.equal(state.children[0].localDeletion.eligible, true); + const removed = await app.request('/api/children/member', { method: 'DELETE' }); + assert.equal(removed.status, 200, JSON.stringify(removed.data)); + assert.equal((await app.saved()).children.length, 0); + assert.deepEqual(app.requests.filter((entry) => entry.method === 'DELETE').map((entry) => entry.path), [ + '/backend-api/accounts/team-b/users/member-team-b', + '/backend-api/accounts/team-a/users/member-team-a', + ], 'local deletion must not make another remote member removal'); +}); + test('invalid explicit scope, absent member and pending seat reservations reject before remote removal', async (t) => { const app = await fixture(t); for (const body of [null, [], { motherId: 'mother-b' }, { workspaceId: 'team-b' }, { motherId: [], workspaceId: 'team-b' }]) { @@ -165,6 +204,25 @@ test('invalid explicit scope, absent member and pending seat reservations reject }); }); +test('captured email guards queued removal against account changes before any remote request', async (t) => { + const app = await fixture(t); + for (const expectedEmail of [null, '', ' ', 1, [], {}]) { + const result = await app.request(endpoint, { body: { ...scope, expectedEmail } }); + assert.equal(result.status, 400); + assert.equal(result.data.code, 'team_kick_identity_required'); + } + const mismatched = await app.request(endpoint, { body: { ...scope, expectedEmail: 'old-member@example.test' } }); + assert.equal(mismatched.status, 409); + assert.equal(mismatched.data.code, 'child_identity_changed_before_kick'); + assert.equal(app.requests.length, 0, 'invalid or changed identity must not call upstream'); + assert.equal((await app.saved()).children[0].workspaceHistory[1].status, 'active'); + + const matched = await app.request(endpoint, { body: { ...scope, expectedEmail: ` ${email.toUpperCase()} ` } }); + assert.equal(matched.status, 200); + assert.equal(matched.data.ok, true); + assert.equal(app.requests.filter((entry) => entry.method === 'DELETE').length, 1); +}); + test('global stale member ID never selects another email, and malformed member pages fail closed', async (t) => { for (const [name, payload] of [ ['wrong-email', { items: [{ id: 'user-owner', email: 'owner@example.test', role: 'account-owner' }, { id: 'unrelated-team-b-member', email: 'not-this-child@example.test' }], total: 2 }], diff --git a/server/team-member-removal-batch.test.mjs b/server/team-member-removal-batch.test.mjs new file mode 100644 index 0000000..27452ca --- /dev/null +++ b/server/team-member-removal-batch.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { teamMemberRemovalCandidates, runTeamMemberRemovalBatch } from '../shared/team-member-removal-batch.mjs'; + +const first = { id: 'mother-a', accountId: 'workspace-a', teamName: 'Team A', email: 'owner-a@fixture.test', rotationMode: 'fixed' }; +const second = { id: 'mother-b', accountId: 'workspace-b', teamName: 'Team B', email: 'owner-b@fixture.test' }; +const member = { id: 'child-a', email: 'member@fixture.test', joinedTeams: [{ team: first.accountId, status: 'active' }] }; +const ids = (accounts, mothers = [first, second], motherId = first.id) => teamMemberRemovalCandidates(accounts, mothers, motherId).map((candidate) => candidate.childId); + +test('member removal requires a concrete existing Team and never turns overview or invalid selection into all Teams', () => { + for (const target of ['', ' ', 'unknown', null, {}, 1]) { + assert.deepEqual(teamMemberRemovalCandidates([member], [first, second], target), []); + } + assert.deepEqual(teamMemberRemovalCandidates([member], [first, second]), []); + assert.deepEqual(teamMemberRemovalCandidates([member], null, first.id), []); + assert.deepEqual(teamMemberRemovalCandidates(null, [first], first.id), []); + assert.deepEqual(teamMemberRemovalCandidates([member], [{ ...first, accountId: '' }], first.id), []); + assert.deepEqual(teamMemberRemovalCandidates([member], [first, { ...first, accountId: second.accountId }], first.id), [], 'ambiguous target ids must not authorize two workspaces'); +}); + +test('multi-Team accounts produce only the exact selected workspace without requiring JSON', () => { + const both = { ...member, joinedTeams: [...member.joinedTeams, { team: second.accountId, status: 'active' }] }; + assert.deepEqual(teamMemberRemovalCandidates([both], [first, second], second.id), [{ + childId: member.id, email: member.email, motherId: second.id, + workspaceId: second.accountId, teamName: second.teamName, + }]); + assert.deepEqual(ids([member], [first, second], second.id), []); + assert.deepEqual(ids([both]), [member.id]); +}); + +test('fixed primary owners stay protected while active banned members remain removable', () => { + const primary = { ...member, id: 'primary', email: first.email }; + const banned = { ...member, id: 'banned', status: 'banned' }; + const bannedByFlag = { ...member, id: 'banned-flag', banStatus: 'banned' }; + assert.deepEqual(ids([primary, banned, bannedByFlag]), ['banned', 'banned-flag']); + assert.deepEqual(ids([primary], [{ ...first, rotationMode: 'rotating' }]), ['primary']); + const designated = { ...first, primaryOwnerEmail: member.email }; + assert.deepEqual(ids([member], [designated]), [], 'explicit primaryOwnerEmail takes precedence'); +}); + +test('explicit non-active membership overrides stale remote lists and old saved JSON', () => { + const staleMother = { ...first, members: [{ email: member.email }] }; + for (const status of ['kicked', 'cooldown', 'removed', 'inactive', 'invited']) { + const removed = { ...member, team: first.accountId, + joinedTeams: [{ team: first.accountId, status }], + teamAuthorizations: [{ motherId: first.id, workspaceId: first.accountId, ready: true }], + }; + assert.deepEqual(ids([removed], [staleMother]), [], status); + } + for (const status of ['kicked', 'cooldown']) { + assert.deepEqual(ids([{ ...member, joinedTeams: [], team: first.accountId, status }], [staleMother]), [], status); + } + const removedCurrent = { ...member, workspaceHistory: member.joinedTeams, + joinedTeams: [{ team: first.accountId, status: 'kicked' }] }; + assert.deepEqual(ids([removedCurrent], [staleMother]), [], 'the current membership supersedes old active history'); +}); + +test('remote-only active members can be removed but invitations and deactivated remote entries cannot', () => { + const remoteOnly = { id: member.id, email: member.email }; + assert.deepEqual(ids([remoteOnly], [{ ...first, members: [{ email: member.email.toUpperCase() }] }]), [member.id]); + for (const deactivated of [{ deactivated_time: '2026-10-01' }, { deactivatedTime: '2026-10-01' }]) { + assert.deepEqual(ids([remoteOnly], [{ ...first, members: [{ email: member.email, ...deactivated }] }]), []); + } + assert.deepEqual(ids([{ ...remoteOnly, teamInvitations: [{ motherId: first.id, workspaceId: first.accountId, status: 'invited' }] }]), []); + assert.deepEqual(ids([{ ...remoteOnly, team: second.accountId, status: 'kicked' }], [{ ...first, members: [{ email: member.email }] }]), [member.id], 'removal from a different Team must not hide the target membership'); +}); + +test('member identities are validated and repeated records are deduplicated per child and workspace', () => { + const malformed = [null, {}, ...['', ' ', null, {}, 'child with spaces'].map((id) => ({ ...member, id })), + ...['', 'invalid', 'member @fixture.test', 'member@@fixture.test', null, {}].map((email) => ({ ...member, email }))]; + assert.deepEqual(ids([...malformed, member, { ...member }], [first, { ...first }]), [member.id]); + const original = structuredClone(member); + const trimmed = { ...member, email: ` ${member.email} ` }; + assert.equal(teamMemberRemovalCandidates([trimmed], [first], first.id)[0].email, member.email); + assert.deepEqual(member, original, 'selection does not mutate source account records'); +}); + +const target = (childId) => ({ childId, motherId: first.id, workspaceId: first.accountId, email: `${childId}@fixture.test`, teamName: first.teamName }); + +test('batch removal attempts only the confirmed selection once and runs sequentially', async () => { + const calls = []; + const progress = []; + let active = 0; + const result = await runTeamMemberRemovalBatch([target('selected-b'), target('selected-a'), target('selected-b')], async (item) => { + assert.equal(active, 0, 'no overlapping membership mutations'); + active += 1; + calls.push(item.childId); + await Promise.resolve(); + active -= 1; + return { ok: true, account: { localDeletion: { eligible: true } } }; + }, (value) => progress.push(value)); + assert.deepEqual(calls, ['selected-b', 'selected-a']); + assert.deepEqual(progress.map(({ completed, succeeded, failed }) => [completed, succeeded, failed]), [[1, 1, 0], [2, 2, 0]]); + assert.equal(progress[0].results.length, 1, 'later completion cannot mutate earlier progress snapshots'); + assert.deepEqual({ total: result.total, completed: result.completed, succeeded: result.succeeded, failed: result.failed }, { total: 2, completed: 2, succeeded: 2, failed: 0 }); + const empty = await runTeamMemberRemovalBatch([], () => assert.fail('empty selection must never run')); + assert.deepEqual(empty, { total: 0, completed: 0, succeeded: 0, failed: 0, results: [] }); +}); + +test('consecutive removal failures do not retry or prevent later selected accounts from completing', async () => { + const calls = []; + const result = await runTeamMemberRemovalBatch(['throws', 'rejected', 'last'].map(target), async ({ childId }) => { + calls.push(childId); + if (childId === 'throws') throw new Error('连接失败'); + if (childId === 'rejected') return { ok: false, message: '服务器拒绝移除', account: { localDeletion: { eligible: true } } }; + return { ok: true, account: { localDeletion: { eligible: true } } }; + }, () => { throw new Error('presentation failure'); }); + assert.deepEqual(calls, ['throws', 'rejected', 'last']); + assert.deepEqual(result.results.map(({ ok, error, canDelete }) => [ok, error, canDelete]), [ + [false, '连接失败', false], [false, '服务器拒绝移除', false], [true, '', true], + ]); + assert.equal(result.succeeded, 1); + assert.equal(result.failed, 2); + assert.equal(result.completed, 3); +}); + +test('successful target-Team removal unlocks deletion only when the server confirms no other memberships remain', async () => { + const result = await runTeamMemberRemovalBatch(['last-team', 'other-team', 'no-deletion-status'].map(target), async ({ childId }) => ({ + ok: true, + account: childId === 'no-deletion-status' ? {} : { localDeletion: { eligible: childId === 'last-team', reason: childId === 'other-team' ? '仍在其他 Team' : '' } }, + })); + assert.equal(result.succeeded, 3); + assert.deepEqual(result.results.map(({ childId, canDelete }) => [childId, canDelete]), [ + ['last-team', true], ['other-team', false], ['no-deletion-status', false], + ]); +}); diff --git a/server/team-quota-batch.test.mjs b/server/team-quota-batch.test.mjs new file mode 100644 index 0000000..f5e0b51 --- /dev/null +++ b/server/team-quota-batch.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { teamQuotaBatchTargets } from '../shared/team-quota-batch.mjs'; + +const first = { id: 'mother-first', accountId: 'workspace-first', name: 'First Team' }; +const second = { id: 'mother-second', accountId: 'workspace-second', name: 'Second Team' }; +const mothers = [first, second]; +function savedAccount(id, teams = mothers) { + return { id, email: `${id}@example.test`, status: 'ready', + joinedTeams: teams.map(mother => ({ team: mother.accountId, status: 'active' })), + teamAuthorizations: teams.map(mother => ({ motherId: mother.id, workspaceId: mother.accountId, + ready: true, hasAccessToken: true, hasRefreshToken: true, expiresAt: '2099-01-01T00:00:00Z' })) }; +} + +test('overview refreshes every saved account and Team; explicit scope never substitutes another Team', () => { + const accounts = [savedAccount('a'), savedAccount('b', [second])]; + const overview = teamQuotaBatchTargets(accounts, mothers); + assert.deepEqual(overview.map(t => [t.childId, t.motherId, t.workspaceId]), [ + ['a', first.id, first.accountId], ['a', second.id, second.accountId], ['b', second.id, second.accountId], + ]); + assert.equal(overview[0].email, 'a@example.test'); + assert.equal(overview[0].teamName, 'First Team'); + assert.deepEqual(teamQuotaBatchTargets(accounts, mothers, first.id), [overview[0]]); + assert.deepEqual(teamQuotaBatchTargets(accounts, mothers, 'missing-mother'), []); +}); + +test('expired saved JSON remains refreshable without starting authorization', () => { + const account = savedAccount('expired', [first]); + Object.assign(account.teamAuthorizations[0], { ready: false, expiresAt: '2020-01-01T00:00:00Z' }); + assert.equal(teamQuotaBatchTargets([account], mothers).length, 1); +}); + +test('missing or incomplete Team JSON and unrelated pool credentials do not enter the batch', () => { + for (const patch of [{ hasAccessToken: false }, { hasRefreshToken: false }, { motherId: 'other-mother' }, { workspaceId: 'other-workspace' }]) { + const account = savedAccount('partial', [first]); + Object.assign(account.teamAuthorizations[0], patch); + assert.deepEqual(teamQuotaBatchTargets([account], mothers), []); + } + const account = { ...savedAccount('pool', [first]), teamAuthorizations: [], + credentialsStatus: { hasAccessToken: true, hasRefreshToken: true } }; + assert.deepEqual(teamQuotaBatchTargets([account], mothers), []); +}); + +test('removed members stay excluded even when the remote member cache is stale', () => { + for (const status of ['kicked', 'cooldown', 'invited']) { + const account = savedAccount('removed', [first]); + account.joinedTeams[0].status = status; + const cachedMother = { ...first, members: [{ email: account.email }] }; + assert.deepEqual(teamQuotaBatchTargets([account], [cachedMother]), []); + } + const absent = { ...savedAccount('absent', [first]), joinedTeams: [] }; + assert.deepEqual(teamQuotaBatchTargets([absent], [{ ...first, members: [{ email: absent.email }] }]), []); +}); + +test('bans exclude accounts but removal from one Team does not block another active Team', () => { + for (const patch of [{ status: 'banned' }, { banStatus: 'banned' }]) { + assert.deepEqual(teamQuotaBatchTargets([{ ...savedAccount('banned'), ...patch }], mothers), []); + } + const account = savedAccount('mixed'); + account.joinedTeams[0].status = 'kicked'; + assert.deepEqual(teamQuotaBatchTargets([account], mothers).map(t => t.workspaceId), [second.accountId]); +}); + +test('duplicate rows or mother records for one workspace trigger only one quota query', () => { + const alias = { ...first, id: 'mother-alias' }; + const account = savedAccount('duplicate', [first, alias]); + assert.equal(teamQuotaBatchTargets([account, account], [first, alias]).length, 1); + assert.equal(teamQuotaBatchTargets([account], [first, alias], alias.id)[0].motherId, alias.id); + assert.deepEqual(teamQuotaBatchTargets(), []); +}); diff --git a/server/team-workflow-ui.test.mjs b/server/team-workflow-ui.test.mjs index 81bdbc2..0f7c6c8 100644 --- a/server/team-workflow-ui.test.mjs +++ b/server/team-workflow-ui.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { isTeamJoinCandidate, teamAuthorizationState, teamAutoJsonState, teamAccountWorkflows, teamInviteCandidateState, teamMemberRemovalState } from '../shared/team-workflow-ui.mjs'; +import { isTeamJoinCandidate, teamAuthorizationState, teamAutoJsonState, teamAccountWorkflows, teamInviteCandidateState, teamMemberRemovalState, teamAutoJsonCandidates, teamAutoJsonRowTarget } from '../shared/team-workflow-ui.mjs'; const teamId = 'fixture-workspace'; const mother = { accountId: teamId, email: 'owner@fixture.test' }; @@ -243,3 +243,35 @@ test('Free credentials and unassociated Team metadata cannot create ready author assert.equal(workflows[0].authorization, null, 'another Team authorization cannot replace invited Team authorization'); assert.equal(workflows[1].related, false, 'credentials alone cannot prove current membership'); }); + +test('automatic batch count excludes successful JSON and keeps only pending target-Team accounts', () => { + const pending = { ...account, id: 'pending', teamInvitations: [invitation] }; + const complete = { ...account, id: 'complete', joinedTeams: [activeMembership], teamAuthorizations: [authorization] }; + const other = { ...pending, id: 'other', teamInvitations: [{ ...invitation, motherId: 'other-mother', workspaceId: 'other-team' }] }; + const banned = { ...pending, id: 'banned', banStatus: 'banned' }; + const accounts = [complete, pending, other, banned, { ...pending }]; + assert.deepEqual(teamAutoJsonCandidates(accounts, invitationMother).map((item) => item.id), ['pending']); + assert.deepEqual(teamAutoJsonCandidates(accounts, null), []); +}); + +test('single-row automatic selection never widens an empty, ready, missing or explicit account id to the batch', () => { + const first = { ...account, id: 'first', teamInvitations: [invitation] }; + const second = { ...account, id: 'second', joinedTeams: [activeMembership], teamAuthorizations: [authorization] }; + const third = { ...first, id: 'third' }; + const accounts = [first, second, third]; + assert.deepEqual(teamAutoJsonCandidates(accounts, invitationMother, ['third']).map((item) => item.id), ['third']); + for (const ids of [[], ['missing'], ['second']]) assert.deepEqual(teamAutoJsonCandidates(accounts, invitationMother, ids), []); + assert.deepEqual(teamAutoJsonCandidates(accounts, invitationMother, ['third', 'third', 'second']).map((item) => item.id), ['third']); +}); + +test('row target follows its sole association in overview and never substitutes an explicitly chosen Team', () => { + const secondMother = { id: 'mother-second', accountId: 'workspace-second' }; + const invited = { ...account, teamInvitations: [invitation] }; + const mothers = [secondMother, invitationMother]; + assert.equal(teamAutoJsonRowTarget(invited, mothers).mother.id, invitationMother.id); + assert.equal(teamAutoJsonRowTarget(invited, mothers, secondMother.id), null); + assert.equal(teamAutoJsonRowTarget(invited, mothers, 'deleted-mother'), null); + const both = { ...invited, teamInvitations: [invitation, { motherId: secondMother.id, workspaceId: secondMother.accountId, status: 'invited' }] }; + assert.equal(teamAutoJsonRowTarget(both, mothers), null); + assert.equal(teamAutoJsonRowTarget(both, mothers, secondMother.id).mother.id, secondMother.id); +});