diff --git a/src/lib/proxyEgress.ts b/src/lib/proxyEgress.ts index c67243f60d..aa2f3a4ea1 100644 --- a/src/lib/proxyEgress.ts +++ b/src/lib/proxyEgress.ts @@ -316,3 +316,73 @@ export async function validateProxyPool(deps?: { return report; } + +export interface DistributionPlan { + assignments: Array<{ connectionId: string; account: string; proxyId: string }>; + unassigned: Array<{ connectionId: string; account: string }>; + sharingRisk: boolean; + note: string; +} + +/** + * PURE: plan a 1-proxy-per-connection assignment so no two accounts of the same + * rotation group share an egress IP (the codex anomaly trigger). Default is + * strict 1:1 — extras are left UNASSIGNED (better unrouted than sharing an IP). + * allowSharing=true round-robins instead, flagging sharingRisk. + */ +export function planProxyDistribution( + connections: Array<{ id: string; account?: string }>, + liveProxyIds: string[], + opts: { allowSharing?: boolean } = {} +): DistributionPlan { + const assignments: DistributionPlan["assignments"] = []; + const unassigned: DistributionPlan["unassigned"] = []; + let sharingRisk = false; + + connections.forEach((c, i) => { + const account = c.account || c.id.slice(0, 8); + if (liveProxyIds.length === 0) { + unassigned.push({ connectionId: c.id, account }); + return; + } + if (opts.allowSharing) { + assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i % liveProxyIds.length] }); + } else if (i < liveProxyIds.length) { + assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i] }); + } else { + unassigned.push({ connectionId: c.id, account }); + } + }); + + if (opts.allowSharing && liveProxyIds.length < connections.length) sharingRisk = true; + + const note = + liveProxyIds.length === 0 + ? "No live proxies available — add working proxies before distributing." + : liveProxyIds.length < connections.length && !opts.allowSharing + ? `Only ${liveProxyIds.length} live proxies for ${connections.length} accounts — ${unassigned.length} left unassigned (avoid shared-IP anomaly).` + : "1 distinct proxy per account."; + + return { assignments, unassigned, sharingRisk, note }; +} + +/** + * Apply a distribution plan: assign each proxy to its connection (account scope). + */ +export async function applyProxyDistribution( + plan: DistributionPlan, + deps?: { assign?: (connectionId: string, proxyId: string) => Promise } +): Promise<{ applied: number }> { + const assign = + deps?.assign ?? + (async (connectionId: string, proxyId: string) => { + const { assignProxyToScope } = await import("./db/proxies"); + await assignProxyToScope("account", connectionId, proxyId); + }); + let applied = 0; + for (const a of plan.assignments) { + await assign(a.connectionId, a.proxyId); + applied++; + } + return { applied }; +} diff --git a/tests/unit/proxy-egress-visibility.test.ts b/tests/unit/proxy-egress-visibility.test.ts index 7ed83550fa..86d11905e0 100644 --- a/tests/unit/proxy-egress-visibility.test.ts +++ b/tests/unit/proxy-egress-visibility.test.ts @@ -24,7 +24,7 @@ const { _setEgressProbeForTests: (fn: any) => void; clearEgressCache: () => void; }; -const { validateProxyPool } = egress as any; +const { validateProxyPool, planProxyDistribution, applyProxyDistribution } = egress as any; test("resolveEgressIp returns the probed IP and caches by proxy URL", async () => { clearEgressCache(); @@ -133,3 +133,58 @@ test("validateProxyPool marks live proxies active and dead proxies error", async _setEgressProbeForTests(null); }); + +test("planProxyDistribution: strict 1:1, extras left unassigned (no shared IP)", () => { + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }, { id: "c3", account: "a3" }], + ["p1", "p2"] + ); + assert.equal(plan.assignments.length, 2); + assert.deepEqual(plan.assignments.map((a: any) => a.proxyId), ["p1", "p2"]); + assert.equal(plan.unassigned.length, 1, "c3 has no proxy → unassigned, not sharing"); + assert.equal(plan.unassigned[0].connectionId, "c3"); + assert.equal(plan.sharingRisk, false); +}); + +test("planProxyDistribution: enough proxies → 1 distinct per account", () => { + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }], + ["p1", "p2", "p3"] + ); + assert.equal(plan.assignments.length, 2); + assert.equal(plan.unassigned.length, 0); + assert.match(plan.note, /1 distinct proxy/); +}); + +test("planProxyDistribution: allowSharing round-robins and flags sharingRisk", () => { + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }, { id: "c3", account: "a3" }], + ["p1", "p2"], + { allowSharing: true } + ); + assert.equal(plan.assignments.length, 3); + assert.deepEqual(plan.assignments.map((a: any) => a.proxyId), ["p1", "p2", "p1"]); + assert.equal(plan.sharingRisk, true); +}); + +test("planProxyDistribution: no live proxies → all unassigned with guidance", () => { + const plan = planProxyDistribution([{ id: "c1", account: "a1" }], []); + assert.equal(plan.assignments.length, 0); + assert.equal(plan.unassigned.length, 1); + assert.match(plan.note, /No live proxies/); +}); + +test("applyProxyDistribution assigns each proxy to its connection", async () => { + const calls: Array<[string, string]> = []; + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }], + ["p1", "p2"] + ); + const res = await applyProxyDistribution(plan, { + assign: async (connectionId: string, proxyId: string) => { + calls.push([connectionId, proxyId]); + }, + }); + assert.equal(res.applied, 2); + assert.deepEqual(calls, [["c1", "p1"], ["c2", "p2"]]); +});