Date: 2026-03-02 Scope: 13 fixes across 9 files + 1 Python script Status: Amended + implemented (2026-03-02)
- Node.js natively supports
Buffer.from(str, 'base64url')since v16+ — no manual-→+/_→/replacement needed - The current
codex-auth.tsdoes manual replacement, which works but is unnecessary. Theindex.tsversion does neither and is broken - Decision: Use
Buffer.from(payload, 'base64url')everywhere (simplest, most correct)
- OpenAI refresh tokens are single-use — once consumed, they cannot be reused (returns 401)
invalid_granterrors should immediately mark auth-invalid, no retry- Transient errors (500/502/503) should retry with exponential backoff (3 attempts, 500ms/750ms/1125ms)
- Token refresh 400 errors after inactivity = refresh token expired, must re-auth
- Multiple processes refreshing simultaneously can cause token invalidation — need locking
- Decision: Fix A3 is even more critical than originally planned. Token failures must distinguish grant errors from transient errors
- Weighted strategy should not stop after one selected alias if that alias token refresh fails
- A weighted pick should still fallback to other healthy aliases in the same request cycle
- Decision: Weighted selection remains first choice, but candidate list now includes healthy fallbacks
- Token refresh failures should
throw new Error(), not return Response objects - API error responses should be returned as-is (don't consume/wrap them)
id_tokenis required for account detection (extractingchatgpt_account_idfrom JWT claims)- The Codex CLI
auth.jsonexpectsid_tokenin the tokens object - Decision: For B1 (probe idToken), we should gracefully skip probing for accounts without
idTokenrather than using empty string. TheidTokenIS needed by the Codex CLI
proper-lockfileis still the standard (8.7M weekly downloads), usesmkdirstrategy- No built-in Node.js cross-process locking API exists
- Decision: Not adding
proper-lockfilein this PR (would be a new dependency). Instead, document the limitation. The existing atomic rename approach prevents data loss
truststorepackage (uses OS cert store) is the 2025 best practice for Python 3.10+certifiis the fallback for older Pythonssl.create_default_context()works on Python 3.10+ with system certs- Decision: Use
ssl.create_default_context()as default, add--no-ssl-verifyflag as explicit opt-in
- WCAG AA minimum touch target: 24x24px hit area
- Recommended button height: 36-40px for desktop
- Dashboard body text minimum: 13px (0.8125rem)
- Absolute label minimum: 11px (0.6875rem)
- Dark backgrounds: avoid pure black/white, use
#111-#1a1a1abg +#e8e8e8text remunits are better for accessibility (respects browser font-size settings)- Decision: Bump sizes ~20% using px (converting to rem would be a larger refactor). Ensure nothing is below 12px. Ensure buttons meet 36px minimum height
File: src/index.ts (lines 52-62)
Change: Remove inline decodeJWT function. Import decodeJwtPayload from ./codex-auth.js and use it at all call sites in index.ts.
Research insight: Could also modernize to Buffer.from(payload, 'base64url') but reusing the existing tested function is safer.
File: src/cli.ts (line 71)
Change: Import getRuntimeSettings from ./settings.js. Replace console.log('Strategy: round-robin') with:
const { settings } = getRuntimeSettings()
console.log(`Strategy: ${settings.rotationStrategy}`)File: src/rotation.ts (lines 279-285)
Change: When ensureValidToken returns null, mark as authInvalid with cooldown instead of rateLimitedUntil:
if (!token) {
store = updateAccount(candidate, {
authInvalid: true,
authInvalidatedAt: now,
limitError: '[multi-auth] Token unavailable (refresh failed?)',
lastLimitErrorAt: now
})
continue
}Research insight: OpenAI refresh tokens are single-use. A failed refresh likely means the token is permanently invalid, not temporarily rate-limited. Using authInvalid correctly prevents the account from being retried until re-authenticated.
Amendment applied: This fix now depends on structured refresh failure classification in src/auth.ts:
invalid_grant(400) and401/403=> permanent auth invalidation429/500/502/503/504+ network errors => transient with retry backoff (500ms/750ms/1125ms)- rotation only marks
authInvalidfor permanent failures, and applies cooldown for transient failures
File: src/auth-sync.ts (lines 74-81)
Change: Guard expiresAt against undefined by falling back to the existing account's value:
const existingAccount = store.accounts[existingAlias]
updateAccount(existingAlias, {
accessToken: auth.access,
refreshToken: auth.refresh,
expiresAt: auth.expires ?? existingAccount?.expiresAt ?? (Date.now() + 3600_000),
email: derivedEmail,
accountId: derivedAccountId
})Also apply the same guard at line 101 for the addAccount call.
File: src/codex-auth.ts (line 30)
Change: Replace const CODEX_AUTH_FILE = getCodexAuthFilePath() with a lazy getter:
let _codexAuthFile: string | null = null
function getCodexAuthFile(): string {
if (!_codexAuthFile) _codexAuthFile = getCodexAuthFilePath()
return _codexAuthFile
}Replace all references to CODEX_AUTH_FILE with getCodexAuthFile().
Amendment applied: Ensure directory creation uses dirname(getCodexAuthFile()) so env-overridden auth file paths work outside ~/.codex.
File: src/rotation.ts
Change: In weighted-round-robin, keep weighted-selected alias first, but append healthy fallback aliases. This prevents false No available accounts when only the first choice fails token refresh.
File: src/probe-limits.ts (lines 44-47)
Change: Instead of throwing when idToken is missing, skip the probe gracefully:
function writeAuthJson(dir: string, account: AccountCredentials): boolean {
if (!account.accessToken || !account.refreshToken || !account.idToken) {
return false // Can't probe without full token set
}
// ... write auth.json as before
return true
}Update probeRateLimitsForAccount to check the return value and return an error result if writeAuthJson returns false.
Research insight: The Codex CLI requires id_token for account detection. Accounts synced from OpenCode (which lack idToken) genuinely cannot be probed — this is expected, not a bug.
Amendment applied: /api/limits/refresh now returns explicit errors for:
- requested alias missing
idToken - no probeable aliases in store
File: auto-login/auto_login.py (lines 41-42, 158-238)
Changes:
- Fix store path:
~/.config/opencode-multi-auth/accounts.json(matching Node.js plugin) - Fix store format:
accounts: {}dict keyed by alias (not list) - Use
activeAliasinstead ofactiveIndex - Build alias from email prefix (matching
codex-auth.tsbuildAliaslogic) - Handle reading existing dict-format stores
STORE_DIR = Path.home() / ".config" / "opencode-multi-auth"
STORE_FILE = STORE_DIR / "accounts.json"
def load_store():
if not STORE_FILE.exists():
return {
"version": 2,
"accounts": {},
"activeAlias": None,
"rotationIndex": 0,
"lastRotation": int(time.time() * 1000),
}
with open(STORE_FILE, "r") as f:
return json.load(f)
def build_alias(email, existing_aliases):
base = email.split("@")[0] if email else "account"
candidate = base or "account"
suffix = 1
while candidate in existing_aliases:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def add_account_to_store(tokens):
# ... build new_account dict as before ...
store = load_store()
existing_aliases = set(store["accounts"].keys())
# Check for existing account by email
if email:
for alias, acc in store["accounts"].items():
if acc.get("email") == email:
store["accounts"][alias] = {**acc, **new_account, ...}
save_store(store)
return email, alias, False
alias = build_alias(email, existing_aliases)
new_account["alias"] = alias
store["accounts"][alias] = new_account
if not store.get("activeAlias"):
store["activeAlias"] = alias
save_store(store)
return email, alias, TrueAmendment applied: cmd_check and login output paths were updated for dict/alias store shape (no remaining activeIndex assumptions in runtime logic).
File: src/settings.ts (line 110)
Change: The existing code already merges current.settings with updates before calling validateSettings. Verify that validateSettings in types.ts actually checks criticalThreshold < lowThreshold. If it does, this is already handled. If not, add explicit check:
if (newSettings.criticalThreshold >= newSettings.lowThreshold) {
errors.push({ field: 'criticalThreshold', message: 'Critical threshold must be less than low threshold' })
}File: src/probe-limits.ts
Changes:
- Add
cleanupProbeHome(alias: string)function:
export function cleanupProbeHome(alias: string): void {
const dir = getAliasHome(alias)
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
// ignore cleanup errors
}
}
export function cleanupAllProbeHomes(): void {
if (!fs.existsSync(CODEX_HOME_ROOT)) return
try {
const entries = fs.readdirSync(CODEX_HOME_ROOT)
for (const entry of entries) {
const full = path.join(CODEX_HOME_ROOT, entry)
fs.rmSync(full, { recursive: true, force: true })
}
} catch {
// ignore
}
}- Call
cleanupProbeHome(account.alias)at the end ofprobeRateLimitsForAccountafter getting results - Optionally expose
cleanupAllProbeHomesvia the dashboard API
Amendment applied: cleanup now runs in a finally block so directories are removed on every return path (success/failure/early exit).
File: auto-login/auto_login.py (lines 141, 151)
Changes:
- Replace
ssl._create_unverified_context()withssl.create_default_context():
def get_ssl_context(no_ssl_verify=False):
if no_ssl_verify:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
return ssl.create_default_context()- Add
--no-ssl-verifyCLI flag in argparse - Thread the SSL context through
exchange_code_for_tokensandfetch_userinfo_email - Print a warning when
--no-ssl-verifyis used
File: src/web.ts (CSS section, approximately lines 46-610)
| Element | Current | New |
|---|---|---|
button padding |
10px 14px |
12px 17px |
button font-size (implicit ~14px) |
default | 14px explicit |
button.small padding |
6px 10px |
8px 12px |
button.small font-size |
12px |
13px |
.meta-item padding |
12px 14px |
14px 16px |
.meta-item span font-size |
12px |
13px |
.meta-item strong font-size |
16px |
18px |
.filters input/select padding |
10px 12px |
12px 14px |
.filters input/select font-size (implicit) |
default | 14px explicit |
.account-card padding |
16px |
18px |
.badge / tag padding |
4px 8px |
5px 10px |
.badge / tag font-size |
11px |
12px |
| Smallest text (9px tags) | 9px |
11px |
| Section label font-sizes | 12px |
13px |
| Card detail font-sizes | 13px |
14px |
| Force mode / strategy selects | inline styles | bump padding to 10px 14px |
header h1 font-size |
28px |
30px |
.subtitle font-size |
14px |
15px |
.add-row input padding |
12px 14px |
14px 16px |
.add-row input font-size |
14px |
15px |
Principles:
- Nothing below 11px (WCAG floor)
- Buttons at least 36px effective height (padding + line-height)
- All increases are proportional — dense layout preserved, just slightly more spacious
Amendment applied: Included missed tiny text (.confidence-badge 9px -> 11px), plus force/help controls so interactive affordances are consistently sized.
| File | Fixes | Risk |
|---|---|---|
src/index.ts |
A1 (JWT decode) | Low — removing duplicate, using existing tested function |
src/cli.ts |
A2 (status strategy) | Low — display-only change |
src/rotation.ts |
A3 (token failure category) | Medium — changes error classification behavior |
src/auth.ts |
A3 support (refresh classification + retry backoff) | Medium — auth refresh control flow changes |
src/auth-sync.ts |
A4 (expiresAt guard) | Low — adds fallback, no behavior change when value exists |
src/codex-auth.ts |
A5 (lazy CODEX_AUTH_FILE) | Low — same result, deferred evaluation |
src/probe-limits.ts |
B1 (idToken handling), B4 (cleanup) | Low — graceful degradation + cleanup |
auto-login/auto_login.py |
B2 (store format), B5 (SSL) | Medium — changes Python store I/O format |
src/settings.ts |
B3 (cross-field validation) | Low — verify/add validation |
src/web.ts |
B1 API UX + C1 (UI sizing) | Low — API message clarity + CSS-only changes |
- A1, A2, A4, A5 — Independent quick fixes, can be done in parallel
- A3 — Rotation behavior change, test after A1-A5
- B1, B3, B4 — Medium fixes on Node.js side
- B2, B5 — Python script fixes (independent from Node.js)
- C1 — Dashboard CSS (independent, do last)
- Cross-process file locking with
proper-lockfile(new dependency, needs discussion) - Splitting
web.tsinto separate frontend/backend files (major refactor) - Converting CSS from
pxtoremunits (accessibility improvement but large change) - Background token keep-alive pings (discovered in research — prevents token expiry during inactivity)
- Encrypting LKG file when store encryption is active