Skip to content
Closed
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
355 changes: 329 additions & 26 deletions index.ts

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions lib/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,16 +385,16 @@ export class AccountManager {

account.lastUsed = nowMs();
account.lastSwitchReason = "rotation";
void this.syncCodexCliActiveSelectionForIndex(account.index);
return account;
}

async syncCodexCliActiveSelectionForIndex(index: number): Promise<void> {
if (!Number.isFinite(index)) return;
if (index < 0 || index >= this.accounts.length) return;
async syncCodexCliActiveSelectionForIndex(index: number): Promise<boolean> {
if (!Number.isFinite(index)) return false;
if (index < 0 || index >= this.accounts.length) return false;
const account = this.accounts[index];
if (!account) return;
await setCodexCliActiveSelection({
if (!account) return false;
if (account.enabled === false) return false;
return setCodexCliActiveSelection({
accountId: account.accountId,
email: account.email,
accessToken: account.access,
Expand Down
103 changes: 100 additions & 3 deletions lib/codex-manager/settings-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ type SettingsHubAction =
type ExperimentalSettingsAction =
| { type: "sync" }
| { type: "backup" }
| { type: "open-rotation-quota" }
| { type: "toggle-direct-cli-injection" }
| { type: "toggle-session-affinity" }
| { type: "toggle-preemptive-quota" }
| { type: "toggle-pool-retry" }
| { type: "toggle-refresh-guardian" }
| { type: "decrease-refresh-interval" }
| { type: "increase-refresh-interval" }
Expand Down Expand Up @@ -1071,6 +1076,10 @@ function backendSettingsSnapshot(
config: PluginConfig,
): Record<string, unknown> {
const snapshot: Record<string, unknown> = {};
const directInjectionDefault =
BACKEND_DEFAULTS.codexCliDirectInjection ?? true;
snapshot.codexCliDirectInjection =
config.codexCliDirectInjection ?? directInjectionDefault;
for (const option of BACKEND_TOGGLE_OPTIONS) {
snapshot[option.key] =
config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false;
Expand Down Expand Up @@ -1130,7 +1139,7 @@ function buildBackendSettingsPreview(
const threshold5h =
config.preemptiveQuotaRemainingPercent5h ??
BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent5h ??
5;
10;
const threshold7d =
config.preemptiveQuotaRemainingPercent7d ??
BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent7d ??
Expand Down Expand Up @@ -1171,6 +1180,9 @@ function buildBackendSettingsPreview(

function buildBackendConfigPatch(config: PluginConfig): Partial<PluginConfig> {
const patch: Partial<PluginConfig> = {};
if (typeof config.codexCliDirectInjection === "boolean") {
patch.codexCliDirectInjection = config.codexCliDirectInjection;
}
for (const option of BACKEND_TOGGLE_OPTIONS) {
const value = config[option.key];
if (typeof value === "boolean") {
Expand Down Expand Up @@ -2452,7 +2464,24 @@ async function promptBackendSettings(
if (!result || result.type === "cancel") return null;
if (result.type === "save") return draft;
if (result.type === "reset") {
draft = cloneBackendPluginConfig(BACKEND_DEFAULTS);
draft = cloneBackendPluginConfig({
...BACKEND_DEFAULTS,
codexCliDirectInjection: draft.codexCliDirectInjection,
proactiveRefreshGuardian: draft.proactiveRefreshGuardian,
proactiveRefreshIntervalMs: draft.proactiveRefreshIntervalMs,
sessionAffinity: draft.sessionAffinity,
retryAllAccountsRateLimited: draft.retryAllAccountsRateLimited,
preemptiveQuotaEnabled: draft.preemptiveQuotaEnabled,
preemptiveQuotaRemainingPercent5h:
draft.preemptiveQuotaRemainingPercent5h ??
BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent5h,
preemptiveQuotaRemainingPercent7d:
draft.preemptiveQuotaRemainingPercent7d ??
BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent7d,
preemptiveQuotaMaxDeferralMs:
draft.preemptiveQuotaMaxDeferralMs ??
BACKEND_DEFAULTS.preemptiveQuotaMaxDeferralMs,
});
Comment on lines +2467 to +2484
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

backend reset should not preserve visible backend toggles.

lib/codex-manager/settings-hub.ts:2467-2475 now carries sessionAffinity, retryAllAccountsRateLimited, and preemptiveQuotaEnabled across "reset to default", but those same fields are editable in the backend hub at lib/codex-manager/settings-hub.ts:503-553. after this change, reset can leave the backend preview and saved config non-default even though the ui says it was reset. preserve only the experimental-only fields here, and let the visible backend controls fall back to BACKEND_DEFAULTS. test/settings-hub-utils.test.ts:738-762 should flip to assert that only the hidden experimental fields survive reset.

proposed fix
 		if (result.type === "reset") {
 			draft = cloneBackendPluginConfig({
 				...BACKEND_DEFAULTS,
 				codexCliDirectInjection: draft.codexCliDirectInjection,
 				proactiveRefreshGuardian: draft.proactiveRefreshGuardian,
 				proactiveRefreshIntervalMs: draft.proactiveRefreshIntervalMs,
-				sessionAffinity: draft.sessionAffinity,
-				retryAllAccountsRateLimited: draft.retryAllAccountsRateLimited,
-				preemptiveQuotaEnabled: draft.preemptiveQuotaEnabled,
 			});
 			for (const category of BACKEND_CATEGORY_OPTIONS) {
 				focusByCategory[category.key] =

As per coding guidelines, lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

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

In `@lib/codex-manager/settings-hub.ts` around lines 2467 - 2475, The
reset-to-default logic is incorrectly preserving user-visible backend toggles
(sessionAffinity, retryAllAccountsRateLimited, preemptiveQuotaEnabled); change
the cloneBackendPluginConfig call that builds draft (the block using
BACKEND_DEFAULTS and cloneBackendPluginConfig in settings-hub.ts) to only carry
forward experimental-only fields (keep codexCliDirectInjection,
proactiveRefreshGuardian, proactiveRefreshIntervalMs) and remove
sessionAffinity, retryAllAccountsRateLimited, and preemptiveQuotaEnabled so
visible controls revert to BACKEND_DEFAULTS; update the related test
expectations in test/settings-hub-utils.test.ts (around lines 738-762) to assert
that only the hidden experimental fields survive reset.

for (const category of BACKEND_CATEGORY_OPTIONS) {
focusByCategory[category.key] =
getBackendCategoryInitialFocus(category);
Expand Down Expand Up @@ -2540,6 +2569,31 @@ async function promptExperimentalSettings(
value: { type: "backup" },
color: "green",
},
{
label: `${formatDashboardSettingState(draft.codexCliDirectInjection !== false)} ${UI_COPY.settings.experimentalDirectCliInjection}`,
value: { type: "toggle-direct-cli-injection" },
color: "green",
},
{
label: `${formatDashboardSettingState(draft.sessionAffinity !== false)} ${UI_COPY.settings.experimentalManualSessionLock}`,
value: { type: "toggle-session-affinity" },
color: "yellow",
},
{
label: `${formatDashboardSettingState(draft.retryAllAccountsRateLimited !== false)} ${UI_COPY.settings.experimentalPoolFallback}`,
value: { type: "toggle-pool-retry" },
color: "green",
},
{
label: `${formatDashboardSettingState(draft.preemptiveQuotaEnabled !== false)} ${UI_COPY.settings.experimentalQuotaRotation}`,
value: { type: "toggle-preemptive-quota" },
color: "yellow",
},
{
label: UI_COPY.settings.experimentalRotationQuotaSettings,
value: { type: "open-rotation-quota" },
color: "green",
},
{
label: `${formatDashboardSettingState(draft.proactiveRefreshGuardian ?? false)} ${UI_COPY.settings.experimentalRefreshGuard}`,
value: { type: "toggle-refresh-guardian" },
Expand Down Expand Up @@ -2581,11 +2635,54 @@ async function promptExperimentalSettings(
theme: ui.theme,
selectedEmphasis: "minimal",
onInput: (raw) =>
raw.toLowerCase() === "q" ? { type: "back" } : undefined,
raw.toLowerCase() === "q"
? { type: "back" }
: raw.toLowerCase() === "o"
? { type: "open-rotation-quota" }
: undefined,
},
);
if (!action || action.type === "back") return null;
if (action.type === "save") return draft;
if (action.type === "open-rotation-quota") {
const category = getBackendCategory("rotation-quota");
if (!category) continue;
const categoryResult = await promptBackendCategorySettings(
draft,
category,
"preemptiveQuotaEnabled",
);
draft = categoryResult.draft;
continue;
}
if (action.type === "toggle-direct-cli-injection") {
draft = {
...draft,
codexCliDirectInjection: !(draft.codexCliDirectInjection !== false),
};
continue;
}
if (action.type === "toggle-session-affinity") {
draft = {
...draft,
sessionAffinity: !(draft.sessionAffinity !== false),
};
continue;
}
if (action.type === "toggle-preemptive-quota") {
draft = {
...draft,
preemptiveQuotaEnabled: !(draft.preemptiveQuotaEnabled !== false),
};
continue;
}
if (action.type === "toggle-pool-retry") {
draft = {
...draft,
retryAllAccountsRateLimited: !(draft.retryAllAccountsRateLimited !== false),
};
continue;
}
if (action.type === "toggle-refresh-guardian") {
draft = {
...draft,
Expand Down
13 changes: 11 additions & 2 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
fetchTimeoutMs: 60_000,
streamStallTimeoutMs: 45_000,
liveAccountSync: true,
codexCliDirectInjection: true,
liveAccountSyncDebounceMs: 250,
liveAccountSyncPollMs: 2_000,
sessionAffinity: true,
Expand All @@ -155,7 +156,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
serverErrorCooldownMs: 4_000,
storageBackupEnabled: true,
preemptiveQuotaEnabled: true,
preemptiveQuotaRemainingPercent5h: 5,
preemptiveQuotaRemainingPercent5h: 10,
preemptiveQuotaRemainingPercent7d: 5,
preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000,
};
Expand Down Expand Up @@ -818,6 +819,14 @@ export function getLiveAccountSync(pluginConfig: PluginConfig): boolean {
);
}

export function getCodexCliDirectInjection(pluginConfig: PluginConfig): boolean {
return resolveBooleanSetting(
"CODEX_AUTH_DIRECT_CLI_INJECTION",
pluginConfig.codexCliDirectInjection,
true,
);
}

/**
* Get the debounce interval, in milliseconds, used when synchronizing live accounts.
*
Expand Down Expand Up @@ -1057,7 +1066,7 @@ export function getPreemptiveQuotaRemainingPercent5h(pluginConfig: PluginConfig)
return resolveNumberSetting(
"CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT",
pluginConfig.preemptiveQuotaRemainingPercent5h,
5,
10,
{ min: 0, max: 100 },
);
}
Expand Down
1 change: 1 addition & 0 deletions lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const PluginConfigSchema = z.object({
fetchTimeoutMs: z.number().min(1_000).optional(),
streamStallTimeoutMs: z.number().min(1_000).optional(),
liveAccountSync: z.boolean().optional(),
codexCliDirectInjection: z.boolean().optional(),
liveAccountSyncDebounceMs: z.number().min(50).optional(),
liveAccountSyncPollMs: z.number().min(500).optional(),
sessionAffinity: z.boolean().optional(),
Expand Down
9 changes: 7 additions & 2 deletions lib/ui/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,19 @@ export const UI_COPY = {
theme: "Color Theme",
experimental: "Experimental",
experimentalTitle: "Experimental",
experimentalSubtitle: "Preview sync and backup actions before they become stable",
experimentalHelpMenu: "Enter Select | Q Back",
experimentalSubtitle: "Preview live rotation, sync, and backup actions before they become stable",
experimentalHelpMenu: "Enter Select | O Quota | Q Back",
experimentalHelpPreview: "A Apply | Q Back",
experimentalHelpStatus: "Enter Select | Q Back",
experimentalSync: "Sync Accounts to oc-chatgpt-multi-auth",
experimentalApplySync: "Apply Sync",
experimentalBackup: "Save Pool Backup",
experimentalBackupPrompt: "Backup file name (.json): ",
experimentalDirectCliInjection: "Direct Codex CLI Injection",
experimentalManualSessionLock: "Manual Session Lock",
experimentalPoolFallback: "Retry Whole Pool on Rate Limit",
experimentalQuotaRotation: "Auto-Rotate on Quota Pressure",
experimentalRotationQuotaSettings: "Open Rotation & Quota Settings",
experimentalRefreshGuard: "Enable Refresh Guard",
experimentalRefreshInterval: "Refresh Guard Interval",
experimentalDecreaseInterval: "Decrease Refresh Interval",
Expand Down
47 changes: 41 additions & 6 deletions test/accounts-load-from-disk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock("../lib/codex-cli/state.js", () => ({
}));

vi.mock("../lib/codex-cli/writer.js", () => ({
setCodexCliActiveSelection: vi.fn().mockResolvedValue(undefined),
setCodexCliActiveSelection: vi.fn().mockResolvedValue(true),
}));

import { loadAccounts, saveAccounts } from "../lib/storage.js";
Expand All @@ -37,7 +37,7 @@ describe("AccountManager loadFromDisk", () => {
storage: null,
});
vi.mocked(loadCodexCliState).mockResolvedValue(null);
vi.mocked(setCodexCliActiveSelection).mockResolvedValue(undefined);
vi.mocked(setCodexCliActiveSelection).mockResolvedValue(true);
});

it("persists Codex CLI source-of-truth storage when sync reports change", async () => {
Expand Down Expand Up @@ -159,7 +159,7 @@ describe("AccountManager loadFromDisk", () => {
expect(saveAccounts).not.toHaveBeenCalled();
});

it("syncCodexCliActiveSelectionForIndex ignores invalid indices and syncs a valid one", async () => {
it("syncCodexCliActiveSelectionForIndex ignores invalid or disabled entries and syncs a valid one", async () => {
const now = Date.now();
const manager = new AccountManager(undefined, {
version: 3 as const,
Expand All @@ -174,14 +174,26 @@ describe("AccountManager loadFromDisk", () => {
addedAt: now,
lastUsed: now,
} as never,
{
refreshToken: "refresh-2",
accountId: "acct-2",
email: "two@example.com",
accessToken: "access-2",
expiresAt: now + 10_000,
addedAt: now + 1,
lastUsed: now,
enabled: false,
} as never,
],
});

await manager.syncCodexCliActiveSelectionForIndex(-1);
await manager.syncCodexCliActiveSelectionForIndex(9);
await expect(manager.syncCodexCliActiveSelectionForIndex(Number.NaN)).resolves.toBe(false);
await expect(manager.syncCodexCliActiveSelectionForIndex(-1)).resolves.toBe(false);
await expect(manager.syncCodexCliActiveSelectionForIndex(9)).resolves.toBe(false);
await expect(manager.syncCodexCliActiveSelectionForIndex(1)).resolves.toBe(false);
expect(setCodexCliActiveSelection).not.toHaveBeenCalled();

await manager.syncCodexCliActiveSelectionForIndex(0);
await expect(manager.syncCodexCliActiveSelectionForIndex(0)).resolves.toBe(true);
expect(setCodexCliActiveSelection).toHaveBeenCalledTimes(1);
expect(setCodexCliActiveSelection).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -192,6 +204,29 @@ describe("AccountManager loadFromDisk", () => {
);
});

it("syncCodexCliActiveSelectionForIndex returns false when CLI selection write fails", async () => {
const now = Date.now();
const manager = new AccountManager(undefined, {
version: 3 as const,
activeIndex: 0,
accounts: [
{
refreshToken: "refresh-1",
accountId: "acct-1",
email: "one@example.com",
accessToken: "access-1",
expiresAt: now + 10_000,
addedAt: now,
lastUsed: now,
} as never,
],
});

vi.mocked(setCodexCliActiveSelection).mockResolvedValueOnce(false);

await expect(manager.syncCodexCliActiveSelectionForIndex(0)).resolves.toBe(false);
});

it("getNextForFamily skips disabled/rate-limited/cooldown accounts", () => {
const now = Date.now();
const manager = new AccountManager(undefined, {
Expand Down
Loading