fix: improve Roxy cleanup and account operations
Update Roxy session cleanup, bounded proxy retries and Team authorization selection. Persist mailbox export usage and strengthen scoped member removal. Update isolated Roxy fixtures; all 808 tests pass.
This commit is contained in:
19 files changed
+3650
-164
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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';
|
||||
|
||||
+472
-59
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
+257
-21
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+10
-2
@@ -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 服务不可用',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+243
-23
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+1055
-15
File diff suppressed because it is too large.
Load diff
@@ -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 }],
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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(), []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user