Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 49 additions & 31 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1782,9 +1782,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
}

while (true) {
let accountCount = accountManager.getAccountCount();
const attempted = new Set<number>();
let restartAccountTraversalWithFallback = false;
let accountCount = accountManager.getAccountCount();
const attempted = new Set<number>();
let restartAccountTraversalWithFallback = false;
let restartAccountTraversalAfterWorkspaceDeactivation = false;

while (attempted.size < Math.max(1, accountCount)) {
Comment on lines +1785 to 1790

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation/formatting in this hunk looks inconsistent (notably the while (attempted.size ...) line appearing flush-left relative to surrounding control flow). Even if it compiles, this makes it difficult to visually validate block structure and increases the risk of brace/control-flow mistakes. Running the project formatter (or adjusting indentation to match surrounding style) would make the traversal/restart logic much safer to review and maintain.

Copilot uses AI. Check for mistakes.
const selectionExplainability = accountManager.getSelectionExplainability(
Expand Down Expand Up @@ -2082,10 +2083,10 @@ while (attempted.size < Math.max(1, accountCount)) {
});

const workspaceDeactivated = isDeactivatedWorkspaceError(errorBody, response.status);
if (workspaceDeactivated) {
const accountLabel = formatAccountLabel(account, account.index);
accountManager.refundToken(account, modelFamily, model);
accountManager.recordFailure(account, modelFamily, model);
if (workspaceDeactivated) {
const accountLabel = formatAccountLabel(account, account.index);
accountManager.refundToken(account, modelFamily, model);
accountManager.recordFailure(account, modelFamily, model);
account.lastSwitchReason = "rotation";
runtimeMetrics.failedRequests++;
runtimeMetrics.accountRotations++;
Expand Down Expand Up @@ -2113,27 +2114,38 @@ while (attempted.size < Math.max(1, accountCount)) {
);
}

if (accountManager.removeAccount(account)) {
accountManager.saveToDiskDebounced();
attempted.clear();
accountCount = accountManager.getAccountCount();
await showToast(
`Workspace deactivated. Removed ${accountLabel} from rotation and switching accounts.`,
"warning",
{ duration: toastDurationMs },
// Keep deactivated workspace cleanup aligned with the existing
// refresh-token removal path. saveToDiskDebounced reuses the
// storage temp-file + EPERM/EBUSY retry path covered in
// test/storage.test.ts, and surfaced persistence failures stay
// sanitized by the redaction checks in test/login-runner.test.ts.
const removedCount = accountManager.removeAccountsWithSameRefreshToken(account);
if (removedCount > 0) {
accountManager.saveToDiskDebounced();
restartAccountTraversalAfterWorkspaceDeactivation = true;
const removalMessage = removedCount > 1
? `Workspace deactivated. Removed ${removedCount} related entries from rotation and switching accounts.`
: `Workspace deactivated. Removed ${accountLabel} from rotation and switching accounts.`;
await showToast(
removalMessage,
"warning",
{ duration: toastDurationMs },
);
break;
}

logWarn(
`[${PLUGIN_NAME}] Expected grouped account removal after workspace deactivation, but removed ${removedCount}.`,
);
accountManager.markAccountCoolingDown(
account,
ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS,
"auth-failure",
);
accountManager.saveToDiskDebounced();
break;
}

accountManager.markAccountCoolingDown(
account,
ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS,
"auth-failure",
);
accountManager.saveToDiskDebounced();
break;
}

const unsupportedModelInfo = getUnsupportedCodexModelInfo(errorBody);
const hasRemainingAccounts = attempted.size < Math.max(1, accountCount);

Expand Down Expand Up @@ -2432,14 +2444,20 @@ while (attempted.size < Math.max(1, accountCount)) {
runtimeMetrics.lastErrorCategory = null;
return successResponse;
}
if (restartAccountTraversalWithFallback) {
break;
}
}
if (restartAccountTraversalWithFallback) {
break;
}
if (restartAccountTraversalAfterWorkspaceDeactivation) {
break;
}
}

if (restartAccountTraversalWithFallback) {
continue;
}
if (restartAccountTraversalWithFallback) {
continue;
}
if (restartAccountTraversalAfterWorkspaceDeactivation) {
continue;
}
Comment on lines +2447 to +2460

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The restart handling is duplicated in two places (a break gate and a continue gate) for both flags. This duplication is easy to accidentally diverge over time (e.g., adding a new restart reason but only updating one site). Consider consolidating into a single shouldRestartTraversal (or a single enum-like restartReason) and handling it once, so there’s only one authoritative place controlling the restart decision.

Copilot uses AI. Check for mistakes.

const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
const count = accountManager.getAccountCount();
Expand Down
147 changes: 137 additions & 10 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3089,7 +3089,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => {
expect(response.status).toBe(200);
});

it("removes only the deactivated workspace and fails over to a healthy sibling workspace", async () => {
it("removes all entries sharing the deactivated refresh token and fails over to a healthy account", async () => {
const fetchHelpers = await import("../lib/request/fetch-helpers.js");
const storageModule = await import("../lib/storage.js");
const accountsModule = await import("../lib/accounts.js");
Expand All @@ -3104,17 +3104,26 @@ describe("OpenAIOAuthPlugin fetch handler", () => {
email: "same@example.com",
refreshToken: "shared-refresh",
};
const liveWorkspace = {
const duplicateWorkspace = {
index: 1,
accountId: "org-dead-duplicate",
organizationId: "org-dead-duplicate",
accountIdSource: "org",
accountLabel: "Duplicate dead workspace",
email: "same@example.com",
refreshToken: "shared-refresh",
};
const healthyFallback = {
index: 2,
accountId: "org-live",
organizationId: "org-live",
accountIdSource: "org",
accountLabel: "Live workspace",
email: "same@example.com",
refreshToken: "shared-refresh",
email: "live@example.com",
refreshToken: "healthy-refresh",
};

const accounts = [deadWorkspace, liveWorkspace];
const accounts = [deadWorkspace, duplicateWorkspace, healthyFallback];
const removeAccount = vi.fn((target: typeof deadWorkspace) => {
const idx = accounts.findIndex((account) => account.accountId === target.accountId);
if (idx < 0) return false;
Expand All @@ -3124,7 +3133,15 @@ describe("OpenAIOAuthPlugin fetch handler", () => {
});
return true;
});
const removeAccountsWithSameRefreshToken = vi.fn(() => 0);
const removeAccountsWithSameRefreshToken = vi.fn((target: typeof deadWorkspace) => {
const nextAccounts = accounts.filter((account) => account.refreshToken !== target.refreshToken);
const removedCount = accounts.length - nextAccounts.length;
accounts.splice(0, accounts.length, ...nextAccounts);
accounts.forEach((account, index) => {
account.index = index;
});
return removedCount;
});

const customManager = {
getAccountCount: () => accounts.length,
Expand Down Expand Up @@ -3187,7 +3204,10 @@ describe("OpenAIOAuthPlugin fetch handler", () => {
const headers = new Headers(init?.headers);
const accessToken = headers.get("x-test-access-token");
if (accessToken === "access-org-dead") {
return new Response(JSON.stringify({ error: { code: "deactivated_workspace", message: "workspace dead" } }), {
return new Response(JSON.stringify({
error: { code: "deactivated_workspace", message: "workspace dead" },
detail: { code: "deactivated_workspace", message: "workspace dead" },
}), {
Comment on lines +3207 to +3210

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make at least one 402 fixture detail.code-only to avoid false positives.

At Line 3207 and Line 4290, the mocked payload includes both error.code and detail.code. That can still pass if detail.code parsing breaks, because the legacy field remains available. For this PR objective, at least one path should only provide detail.code.

Suggested fixture adjustment
-return new Response(JSON.stringify({
-  error: { code: "deactivated_workspace", message: "workspace dead" },
-  detail: { code: "deactivated_workspace", message: "workspace dead" },
-}), {
+return new Response(JSON.stringify({
+  detail: { code: "deactivated_workspace", message: "workspace dead" },
+}), {
   status: 402,
 });

Also applies to: 4290-4293

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/index.test.ts` around lines 3207 - 3210, Update the 402 test fixture so
that one mocked Response only contains detail.code (and omits error.code) to
ensure parsing relies on detail; locate the Response JSON object created in the
test (the object with keys "error" and "detail" returned via new
Response(JSON.stringify({...})) in test/index.test.ts) and remove or null out
the top-level error.code for at least one of the 402 cases (keep detail.code
present) so the test verifies handling when only detail.code is provided.

status: 402,
});
}
Expand All @@ -3202,8 +3222,8 @@ describe("OpenAIOAuthPlugin fetch handler", () => {

expect(response.status).toBe(200);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
expect(removeAccount).toHaveBeenCalledTimes(1);
expect(removeAccountsWithSameRefreshToken).not.toHaveBeenCalled();
expect(removeAccount).not.toHaveBeenCalled();
expect(removeAccountsWithSameRefreshToken).toHaveBeenCalledTimes(1);
expect(accounts.map((account) => account.accountId)).toEqual(["org-live"]);
expect(vi.mocked(storageModule.withFlaggedAccountStorageTransaction)).toHaveBeenCalledTimes(1);
expect(mockFlaggedStorage.accounts).toEqual(
Expand All @@ -3218,6 +3238,110 @@ describe("OpenAIOAuthPlugin fetch handler", () => {
);
});

it("cools down the deactivated workspace when grouped removal returns zero", async () => {
const fetchHelpers = await import("../lib/request/fetch-helpers.js");
const storageModule = await import("../lib/storage.js");
const accountsModule = await import("../lib/accounts.js");
const { AccountManager } = accountsModule;

const deadWorkspace = {
index: 0,
accountId: "org-dead",
organizationId: "org-dead",
accountIdSource: "org",
accountLabel: "Dead workspace",
email: "same@example.com",
refreshToken: "shared-refresh",
};

const markAccountCoolingDown = vi.fn();
const saveToDiskDebounced = vi.fn();
const removeAccountsWithSameRefreshToken = vi.fn(() => 0);
const customManager = {
getAccountCount: () => 1,
getCurrentOrNextForFamilyHybrid: () => deadWorkspace,
getSelectionExplainability: () => [
{
index: 0,
enabled: true,
isCurrentForFamily: true,
eligible: true,
reasons: ["eligible"],
healthScore: 100,
tokensAvailable: 50,
lastUsed: Date.now(),
},
],
toAuthDetails: () => ({
type: "oauth" as const,
access: "access-org-dead",
refresh: deadWorkspace.refreshToken,
expires: Date.now() + 60_000,
}),
hasRefreshToken: () => true,
saveToDiskDebounced,
updateFromAuth: vi.fn(),
clearAuthFailures: vi.fn(),
incrementAuthFailures: vi.fn(() => 1),
markAccountCoolingDown,
markRateLimitedWithReason: vi.fn(),
recordRateLimit: vi.fn(),
consumeToken: vi.fn(() => true),
refundToken: vi.fn(),
markSwitched: vi.fn(),
removeAccount: vi.fn(() => false),
removeAccountsWithSameRefreshToken,
recordFailure: vi.fn(),
recordSuccess: vi.fn(),
getMinWaitTimeForFamily: vi.fn(() => 0),
shouldShowAccountToast: vi.fn(() => false),
markToastShown: vi.fn(),
setActiveIndex: vi.fn(() => deadWorkspace),
getAccountsSnapshot: vi.fn(() => [deadWorkspace]),
};
vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce(customManager as never);
vi.mocked(accountsModule.extractAccountId).mockReturnValue("org-dead");
vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation(
(_init, _accountId, accessToken) =>
new Headers({ "x-test-access-token": String(accessToken) }),
);
vi.mocked(fetchHelpers.handleErrorResponse).mockImplementation(async (response) => {
const errorBody = await response.clone().json().catch(() => ({}));
return { response, rateLimit: undefined, errorBody };
});

globalThis.fetch = vi.fn(async () =>
new Response(JSON.stringify({
error: { code: "deactivated_workspace", message: "workspace dead" },
detail: { code: "deactivated_workspace", message: "workspace dead" },
}), {
status: 402,
}),
);

const { sdk } = await setupPlugin();
const response = await sdk.fetch!("https://api.openai.com/v1/chat", {
method: "POST",
body: JSON.stringify({ model: "gpt-5.1" }),
});
const body = await response.json();

expect(response.status).toBe(503);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(removeAccountsWithSameRefreshToken).toHaveBeenCalledTimes(1);
expect(markAccountCoolingDown).toHaveBeenCalledWith(
deadWorkspace,
expect.any(Number),
"auth-failure",
);
expect(saveToDiskDebounced).toHaveBeenCalledTimes(1);
expect(body).toEqual({
error: {
message: "All 1 account(s) failed (server errors or auth issues). Check account health with `codex-health`.",
},
});
});

it("handles empty body in request", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ content: "test" }), { status: 200 }),
Expand Down Expand Up @@ -4267,7 +4391,10 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => {
const headers = new Headers(init?.headers);
const accessToken = headers.get("x-test-access-token");
if (accessToken === "access-dead") {
return new Response(JSON.stringify({ error: { code: "deactivated_workspace", message: "workspace dead" } }), {
return new Response(JSON.stringify({
error: { code: "deactivated_workspace", message: "workspace dead" },
detail: { code: "deactivated_workspace", message: "workspace dead" },
}), {
status: 402,
headers: { "content-type": "application/json" },
});
Expand Down