claude-multiacc 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bash
2
- # claude-multiacc sandboxed test suite.
3
- # No network, no real accounts, no quota: fake `claude` binary + file:// usage fixtures.
2
+ # claude-multiacc sandboxed test suite — covers BOTH providers (claude + codex).
3
+ # No network, no real accounts, no quota: fake `claude`/`codex` binaries + file://
4
+ # usage/token fixtures.
4
5
  set -u
5
6
 
6
7
  REPO_DIR="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -88,6 +89,12 @@ export CLAUDE_MULTIACC_MIN_FETCH=0
88
89
  # The oauth token endpoint must NEVER be hit for real from tests: default to a missing
89
90
  # file:// fixture (refresh fails fast, offline); the refresh tests override per-case.
90
91
  export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
92
+ # Same for the usage endpoint: the SHIM's opportunistic background `limits --quiet`
93
+ # kick inherits this default, so it can never reach a real endpoint from tests
94
+ # (every explicit limits test overrides the URL inline). The pre-armed .limits-kick
95
+ # throttle keeps those background kicks from racing explicit limits runs.
96
+ export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
97
+ : > "$ACC/.limits-kick"
91
98
 
92
99
  now="$(date +%s)"
93
100
 
@@ -1245,6 +1252,788 @@ else
1245
1252
  t_ok "npm-layer tests skipped (node not installed)"
1246
1253
  fi
1247
1254
 
1255
+ # ============================ CODEX PROVIDER =====================================
1256
+ # The codex pool (bin/codex + bin/codex-accounts + lib/codex_audit.py) is separate
1257
+ # code over separate state (~/.codex-accounts), so it gets its own sandboxed pass:
1258
+ # fake `codex` binary, JWT-shaped auth fixtures, file:// usage/token endpoints.
1259
+
1260
+ export CODEX_ACCOUNTS_DIR="$WORK/codex-accounts"
1261
+ CX="$CODEX_ACCOUNTS_DIR"
1262
+ mkdir -p "$CX/tmp"
1263
+ unset CODEX_HOME CODEX_ACCOUNT CODEX_SHIM_ACTIVE 2>/dev/null || true
1264
+ export CODEX_MULTIACC_NO_SYNC=1
1265
+ export CODEX_MULTIACC_MIN_FETCH=0
1266
+ export CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-endpoint-missing.json"
1267
+ # Default usage URL is an offline missing fixture: the SHIM's opportunistic
1268
+ # background `limits --quiet` kick must never reach a real endpoint from tests
1269
+ # (each limits test overrides the URL inline). The pre-armed .limits-kick throttle
1270
+ # below keeps those background kicks from racing the explicit limits runs at all.
1271
+ export CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-endpoint-missing.json"
1272
+ export CODEX_MULTIACC_FORCE_TTY=1
1273
+ export FAKE_CTL2="$WORK/ctl2"
1274
+ : > "$CX/.limits-kick"
1275
+
1276
+ # Codex auth fixtures are real-shaped: auth.json carrying JWTs whose exp/email
1277
+ # claims the audit decodes offline (exactly what the CLI does).
1278
+ MKAUTH="$WORK/mk_cx_auth.py"
1279
+ cat > "$MKAUTH" <<'EOF'
1280
+ import base64, json, sys
1281
+ def jwt(claims):
1282
+ enc = lambda o: base64.urlsafe_b64encode(json.dumps(o).encode()).rstrip(b'=').decode()
1283
+ return f"{enc({'alg':'RS256'})}.{enc(claims)}.sig"
1284
+ path, email, exp, mode = sys.argv[1], sys.argv[2], float(sys.argv[3]), (sys.argv[4] if len(sys.argv) > 4 else '')
1285
+ if mode == 'apikey':
1286
+ doc = {"auth_mode": "apikey", "OPENAI_API_KEY": "sk-test-api-key", "tokens": None, "last_refresh": None}
1287
+ else:
1288
+ auth_claim = {"chatgpt_plan_type": "pro", "chatgpt_account_id": "acct-uuid"}
1289
+ tokens = {
1290
+ "id_token": jwt({"email": email, "exp": exp, "https://api.openai.com/auth": auth_claim}),
1291
+ "access_token": jwt({"exp": exp, "https://api.openai.com/auth": auth_claim}),
1292
+ "refresh_token": "rt-" + email,
1293
+ "account_id": "acct-uuid",
1294
+ }
1295
+ if mode == 'norefresh':
1296
+ tokens.pop('refresh_token')
1297
+ doc = {"auth_mode": "chatgpt", "OPENAI_API_KEY": None, "tokens": tokens,
1298
+ "last_refresh": "2026-01-01T00:00:00Z"}
1299
+ json.dump(doc, open(path, 'w'))
1300
+ EOF
1301
+ export MKAUTH
1302
+ mk_cx_auth() { python3 "$MKAUTH" "$@"; }
1303
+ FUTURE_EXP=$((now + 864000))
1304
+
1305
+ # Fake "real" codex: prints which CODEX_HOME it ran under; scriptable login +
1306
+ # failures via a control file. (Note: must not contain the hyphenated shim marker.)
1307
+ cat > "$FAKEBIN/codex" <<EOF
1308
+ #!/usr/bin/env bash
1309
+ # fake real codex for tests (not a shim)
1310
+ if [ "\${1:-}" = "login" ]; then
1311
+ shift
1312
+ # record the flags the CLI chose (device-auth default vs --browser opt-out)
1313
+ printf '%s\n' "\$*" > "\${FAKE_LOGIN_ARGS:-/dev/null}" 2>/dev/null || true
1314
+ [ -n "\${FAKE_LOGIN_FAIL:-}" ] && { echo "login aborted" >&2; exit 1; }
1315
+ # simulate a completed ChatGPT sign-in: write a JWT-shaped auth.json
1316
+ python3 "\$MKAUTH" "\${CODEX_HOME:-/dev/null}/auth.json" "\${FAKE_EMAIL:-fake@test}" "\$(( \$(date +%s) + 864000 ))"
1317
+ echo "Successfully logged in"
1318
+ exit 0
1319
+ fi
1320
+ ctl="\${FAKE_CTL2:-/nonexistent}"
1321
+ acct="\$(basename "\${CODEX_HOME:-none}")"
1322
+ if [ -f "\$ctl" ] && grep -qx "fail:\$acct" "\$ctl" 2>/dev/null; then
1323
+ echo "ERROR: 429 Too Many Requests — you have hit your usage limit" >&2
1324
+ exit 1
1325
+ fi
1326
+ if [ -f "\$ctl" ] && grep -qx "authfail:\$acct" "\$ctl" 2>/dev/null; then
1327
+ echo "Error: token expired. Please run codex login again" >&2
1328
+ exit 1
1329
+ fi
1330
+ if [ -f "\$ctl" ] && grep -qx "orgfail:\$acct" "\$ctl" 2>/dev/null; then
1331
+ echo "Codex has been disabled by your workspace admin"
1332
+ exit 1
1333
+ fi
1334
+ outfile=""
1335
+ prev=""
1336
+ for a in "\$@"; do
1337
+ case "\$prev" in -o|--output-last-message) outfile="\$a" ;; esac
1338
+ case "\$a" in
1339
+ --exit7) echo "ordinary failure, not auth related" >&2; exit 7 ;;
1340
+ --echo-stdin) cat; exit 0 ;;
1341
+ esac
1342
+ prev="\$a"
1343
+ done
1344
+ [ -n "\$outfile" ] && printf 'OK' > "\$outfile"
1345
+ echo "CFG=\$acct"
1346
+ EOF
1347
+ chmod +x "$FAKEBIN/codex"
1348
+
1349
+ # ---- C1. passthrough: no manifest yet ---------------------------------------------
1350
+ out="$(codex 2>&1)"
1351
+ check "codex: passthrough without manifest" "CFG=none" "$out"
1352
+
1353
+ # ---- codex manifest + two chatgpt accounts ----------------------------------------
1354
+ cat > "$CX/accounts.json" <<EOF
1355
+ {
1356
+ "version": 1,
1357
+ "server": "root@203.0.113.1",
1358
+ "server_root": "/root/.codex-accounts",
1359
+ "server_repo": "/root/claude-multiacc",
1360
+ "threshold": 90,
1361
+ "accounts": [
1362
+ {"id": "acct-01", "email": "a@cx", "home": "mac", "added_at": "2026-08-21T00:00:00Z"},
1363
+ {"id": "acct-02", "email": "b@cx", "home": "mac", "added_at": "2026-08-21T00:00:00Z"}
1364
+ ]
1365
+ }
1366
+ EOF
1367
+ for i in 01 02; do
1368
+ mkdir -p "$CX/acct-$i"
1369
+ done
1370
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx "$FUTURE_EXP"
1371
+ mk_cx_auth "$CX/acct-02/auth.json" b@cx "$FUTURE_EXP"
1372
+
1373
+ # ---- C2. passthrough guards --------------------------------------------------------
1374
+ out="$(CODEX_HOME=/tmp/other codex 2>&1)"
1375
+ check "codex: passthrough with CODEX_HOME" "CFG=other" "$out"
1376
+ out="$(CODEX_MULTIACC_DISABLE=1 codex 2>&1)"
1377
+ check "codex: passthrough when disabled" "CFG=none" "$out"
1378
+
1379
+ # ---- C3. headroom selection --------------------------------------------------------
1380
+ cxlj() { printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":%s,"max_percent":%s,"buckets":[]}' "$now" "$1" "$2" "$3"; }
1381
+ cxlj 80 10 80 > "$CX/acct-01/limits.json"
1382
+ cxlj 20 10 20 > "$CX/acct-02/limits.json"
1383
+ all2=1
1384
+ for _ in $(seq 1 15); do
1385
+ case "$(codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
1386
+ done
1387
+ [ "$all2" = "1" ] && t_ok "codex: picks the highest weekly-headroom account" \
1388
+ || t_fail "codex headroom selection" "picked the more-utilized account"
1389
+ # high session must NOT beat better weekly headroom
1390
+ cxlj 10 85 85 > "$CX/acct-01/limits.json"
1391
+ cxlj 70 20 70 > "$CX/acct-02/limits.json"
1392
+ all1=1
1393
+ for _ in $(seq 1 15); do
1394
+ case "$(codex 2>&1)" in *CFG=acct-01*) ;; *) all1=0 ;; esac
1395
+ done
1396
+ [ "$all1" = "1" ] && t_ok "codex: high session does NOT beat better weekly headroom" \
1397
+ || t_fail "codex weekly-over-session" "ranked the account with less weekly headroom higher"
1398
+ # equal scores spread load
1399
+ cxlj 10 10 10 > "$CX/acct-01/limits.json"
1400
+ cxlj 10 10 10 > "$CX/acct-02/limits.json"
1401
+ hits1=0; hits2=0
1402
+ for _ in $(seq 1 40); do
1403
+ case "$(codex 2>&1)" in
1404
+ *CFG=acct-01*) hits1=$((hits1+1)) ;;
1405
+ *CFG=acct-02*) hits2=$((hits2+1)) ;;
1406
+ esac
1407
+ done
1408
+ { [ "$hits1" -gt 0 ] && [ "$hits2" -gt 0 ] && [ $((hits1+hits2)) -eq 40 ]; } \
1409
+ && t_ok "codex: equal scores spread randomly (acct-01=$hits1 acct-02=$hits2)" \
1410
+ || t_fail "codex tie spreading" "acct-01=$hits1 acct-02=$hits2 (want both >0, total 40)"
1411
+ # stale telemetry ranks neutral, never free
1412
+ printf '{"fetched_at":1,"weekly_percent":1,"session_percent":1,"max_percent":1,"buckets":[]}' > "$CX/acct-01/limits.json"
1413
+ cxlj 30 30 30 > "$CX/acct-02/limits.json"
1414
+ out="$(codex 2>&1)"
1415
+ check "codex: stale 1% loses to fresh 30%" "CFG=acct-02" "$out"
1416
+ rm -f "$CX"/acct-*/limits.json
1417
+
1418
+ # ---- C4. pin -----------------------------------------------------------------------
1419
+ out="$(CODEX_ACCOUNT=acct-02 codex 2>&1)"
1420
+ check "codex: CODEX_ACCOUNT pin" "CFG=acct-02" "$out"
1421
+ mkdir -p "$CX/acct-07"
1422
+ out="$(CODEX_ACCOUNT=acct-07 codex 2>&1)"
1423
+ check "codex: pin to auth-less dir (ceremony)" "CFG=acct-07" "$out"
1424
+ rmdir "$CX/acct-07"
1425
+
1426
+ # ---- C5. limited marker excludes; pin overrides; expired marker self-clears ---------
1427
+ printf '%s\nbucket=GPT-5.3-Codex-Spark:7d percent=95 reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
1428
+ all2=1
1429
+ for _ in $(seq 1 15); do
1430
+ case "$(codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
1431
+ done
1432
+ [ "$all2" = "1" ] && t_ok "codex: limited account excluded from pool" || t_fail "codex limited exclusion" "acct-01 was still picked"
1433
+ out="$(CODEX_ACCOUNT=acct-01 codex 2>&1)"
1434
+ check "codex: explicit pin wins over marker" "CFG=acct-01" "$out"
1435
+ printf '%s\nbucket=5h percent=95 reason=limits\n' "$((now-10))" > "$CX/acct-01/.limited"
1436
+ codex >/dev/null 2>&1
1437
+ [ ! -f "$CX/acct-01/.limited" ] && t_ok "codex: expired .limited marker self-clears" \
1438
+ || t_fail "codex marker expiry" ".limited survived its reset time"
1439
+
1440
+ # ---- C6. all limited -> least-utilized fallback -------------------------------------
1441
+ printf '%s\nbucket=7d percent=95 reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
1442
+ printf '%s\nbucket=7d percent=99 reason=limits\n' "$((now+3600))" > "$CX/acct-02/.limited"
1443
+ cxlj 95 10 95 > "$CX/acct-01/limits.json"
1444
+ cxlj 99 10 99 > "$CX/acct-02/limits.json"
1445
+ out="$(codex 2>&1)"
1446
+ check "codex: all-limited falls back to least utilized" "CFG=acct-01" "$out"
1447
+ grep -q "all-limited fallback=acct-01" "$CX/selection.log" \
1448
+ && t_ok "codex: all-limited fallback logged" || t_fail "codex fallback log" "no all-limited line"
1449
+ rm -f "$CX"/acct-*/.limited "$CX"/acct-*/limits.json
1450
+
1451
+ # ---- C7. dead logins: .expired excludes, heals on newer credential ------------------
1452
+ printf '%s\nreason=refresh-denied-http-400 marked_at=t detail=x\n' "$now" > "$CX/acct-01/.expired"
1453
+ all2=1
1454
+ for _ in $(seq 1 10); do
1455
+ case "$(codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
1456
+ done
1457
+ [ "$all2" = "1" ] && t_ok "codex: dead login (proven park) is never selected" \
1458
+ || t_fail "codex dead login" "a parked account was selected"
1459
+ # ...not even as the all-limited fallback
1460
+ printf '%s\nbucket=7d percent=95 reason=limits\n' "$((now+3600))" > "$CX/acct-02/.limited"
1461
+ out="$(codex 2>&1)"
1462
+ check "codex: limited-but-alive beats dead in the fallback" "CFG=acct-02" "$out"
1463
+ rm -f "$CX/acct-02/.limited"
1464
+ # a NEWER credential heals a credential-scoped park
1465
+ sleep 1
1466
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx "$FUTURE_EXP"
1467
+ out="$(CODEX_SHIM_SELECT=random codex 2>&1)"
1468
+ [ ! -f "$CX/acct-01/.expired" ] && t_ok "codex: newer auth.json clears a credential park" \
1469
+ || t_fail "codex park heal" ".expired survived a newer credential"
1470
+ # an org-blocked park survives a newer credential (policy, not credential)
1471
+ printf '%s\nreason=org-blocked marked_at=t detail=x\n' "$now" > "$CX/acct-01/.expired"
1472
+ sleep 1
1473
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx "$FUTURE_EXP"
1474
+ codex >/dev/null 2>&1
1475
+ [ -f "$CX/acct-01/.expired" ] && t_ok "codex: org-blocked park survives a credential refresh" \
1476
+ || t_fail "codex org park" "a token write cleared an org-policy park"
1477
+ rm -f "$CX/acct-01/.expired"
1478
+ # a soft-stamped park expires on its own
1479
+ printf '%s\nreason=auth-error soft_until=%s marked_at=t detail=x\n' "$now" "$((now-5))" > "$CX/acct-02/.expired"
1480
+ codex >/dev/null 2>&1
1481
+ [ ! -f "$CX/acct-02/.expired" ] && t_ok "codex: soft_until park expires on its own" \
1482
+ || t_fail "codex soft park" "an elapsed soft park still excluded the account"
1483
+
1484
+ # ---- C8. exec auto-retry ------------------------------------------------------------
1485
+ # acct-01 gets the strictly better score so headroom selection deterministically
1486
+ # picks it FIRST; the scripted failure then forces the retry onto acct-02.
1487
+ cx_first_01() { cxlj 5 5 5 > "$CX/acct-01/limits.json"; cxlj 20 20 20 > "$CX/acct-02/limits.json"; }
1488
+ cx_first_01
1489
+ # rate limit: retry on the other account + self-expiring cooldown for the failed one
1490
+ echo "fail:acct-01" > "$FAKE_CTL2"
1491
+ out="$(codex exec "hello" < /dev/null 2>&1)"
1492
+ case "$out" in *CFG=acct-02*) t_ok "codex: exec retries a rate-limited account on another" ;;
1493
+ *) t_fail "codex retry" "did not land on acct-02: $out" ;; esac
1494
+ if [ -f "$CX/acct-01/.limited" ]; then
1495
+ grep -q "error-cooldown" "$CX/acct-01/.limited" \
1496
+ && t_ok "codex: rate-limited account got a cooldown, not a park" \
1497
+ || t_fail "codex cooldown" "marker is not an error-cooldown"
1498
+ else
1499
+ t_fail "codex cooldown" "no .limited cooldown written"
1500
+ fi
1501
+ rm -f "$CX/acct-01/.limited" "$FAKE_CTL2"
1502
+ # auth failure parks (soft) instead of a cooldown
1503
+ cx_first_01
1504
+ echo "authfail:acct-01" > "$FAKE_CTL2"
1505
+ codex exec "hello" < /dev/null >/dev/null 2>&1
1506
+ if [ -f "$CX/acct-01/.expired" ]; then
1507
+ grep -q "reason=auth-error" "$CX/acct-01/.expired" && grep -q "soft_until=" "$CX/acct-01/.expired" \
1508
+ && t_ok "codex: auth failure parks the account with a soft stamp" \
1509
+ || t_fail "codex auth park" "marker malformed: $(cat "$CX/acct-01/.expired")"
1510
+ else
1511
+ t_fail "codex auth park" "no .expired written after an auth failure"
1512
+ fi
1513
+ rm -f "$CX/acct-01/.expired" "$FAKE_CTL2"
1514
+ # org-disabled failure parks as org-blocked
1515
+ cx_first_01
1516
+ echo "orgfail:acct-01" > "$FAKE_CTL2"
1517
+ codex exec "hello" < /dev/null >/dev/null 2>&1
1518
+ grep -q "reason=org-blocked" "$CX/acct-01/.expired" 2>/dev/null \
1519
+ && t_ok "codex: workspace-disabled failure parks as org-blocked" \
1520
+ || t_fail "codex org park from run" "marker: $(cat "$CX/acct-01/.expired" 2>/dev/null || echo none)"
1521
+ rm -f "$CX/acct-01/.expired" "$FAKE_CTL2"
1522
+ # ordinary failure: exit code passes through, no retry, no marker
1523
+ out="$(codex exec --exit7 < /dev/null 2>&1)"
1524
+ rc=$?
1525
+ [ "$rc" = "7" ] && t_ok "codex: non-auth failure exit code passes through" || t_fail "codex rc passthrough" "rc=$rc"
1526
+ [ ! -f "$CX/acct-01/.limited" ] && [ ! -f "$CX/acct-02/.limited" ] \
1527
+ && t_ok "codex: ordinary failure marks nothing" || t_fail "codex ordinary failure" "a marker appeared"
1528
+ # retry only engages for exec: a plain (interactive-style) run with a failing account
1529
+ # passes the failure straight through — no retry, no marker classification.
1530
+ printf 'fail:acct-01\nfail:acct-02\n' > "$FAKE_CTL2"
1531
+ out="$(codex "prompt" < /dev/null 2>&1)"
1532
+ rc=$?
1533
+ [ "$rc" = "1" ] && t_ok "codex: non-exec run is never retried (rc passes through)" \
1534
+ || t_fail "codex non-exec retry" "rc=$rc"
1535
+ [ ! -f "$CX/acct-01/.limited" ] && [ ! -f "$CX/acct-02/.limited" ] \
1536
+ && t_ok "codex: non-exec failure writes no markers" \
1537
+ || t_fail "codex non-exec markers" "a marker appeared without the retry path"
1538
+ rm -f "$FAKE_CTL2" "$CX"/acct-*/.limited "$CX"/acct-*/.expired
1539
+ # stdin/stdout byte fidelity through the retry path
1540
+ printf 'line1\nline2\n' > "$WORK/cx-stdin.txt"
1541
+ out="$(codex exec --echo-stdin < "$WORK/cx-stdin.txt" 2>&1)"
1542
+ [ "$out" = "$(printf 'line1\nline2')" ] && t_ok "codex: stdin passes byte-identically through retry buffering" \
1543
+ || t_fail "codex stdin fidelity" "got: $out"
1544
+ # user args survive selection
1545
+ out="$(codex exec "two words" --exit7 < /dev/null 2>&1)"
1546
+ rc=$?
1547
+ [ "$rc" = "7" ] && t_ok "codex: user args survive selection (pick_best untouched \$@)" \
1548
+ || t_fail "codex args" "rc=$rc out=$out"
1549
+ # HOME unset: fail open into passthrough
1550
+ out="$(env -u HOME -u CODEX_ACCOUNTS_DIR PATH="$PATH" "$REPO_DIR/bin/codex" 2>&1)"
1551
+ check "codex: HOME unset fails open into passthrough" "CFG=none" "$out"
1552
+ rm -f "$CX"/acct-*/limits.json
1553
+
1554
+ # ---- C9. selection log --------------------------------------------------------------
1555
+ grep -qE 'acct-0[12]' "$CX/selection.log" && t_ok "codex: selection.log written" \
1556
+ || t_fail "codex selection.log" "no selections logged"
1557
+
1558
+ # ---- C10. CLI: list / import / remove / dedupe --------------------------------------
1559
+ out="$(codex-accounts list 2>&1)"
1560
+ check "codex-accounts list shows accounts" "a@cx" "$out"
1561
+ check "codex-accounts list shows auth kind" "auth=chatgpt" "$out"
1562
+ mk_cx_auth "$WORK/cx-import.json" c@cx "$FUTURE_EXP"
1563
+ out="$(codex-accounts import c@cx --id acct-03 --auth "$WORK/cx-import.json" --no-sync 2>&1)"
1564
+ check "codex-accounts import" "Imported acct-03" "$out"
1565
+ [ -f "$CX/acct-03/auth.json" ] && t_ok "codex: imported auth.json in place" || t_fail "codex import" "auth.json missing"
1566
+ perm="$(ls -l "$CX/acct-03/auth.json" | cut -c1-10)"
1567
+ [ "$perm" = "-rw-------" ] && t_ok "codex: imported auth.json is 0600" || t_fail "codex import perms" "$perm"
1568
+ # duplicate email refused without --force / --id
1569
+ out="$(codex-accounts import c@cx --no-sync 2>&1)"
1570
+ rc=$?
1571
+ check "codex: duplicate import refused" "already registered as acct-03" "$out"
1572
+ [ "$rc" != "0" ] && t_ok "codex: duplicate import exits nonzero" || t_fail "codex dup import rc" "rc=0"
1573
+ # dedupe removes a forced duplicate, keeping the authed one
1574
+ codex-accounts import c@cx --id acct-04 --force --no-sync >/dev/null 2>&1
1575
+ out="$(codex-accounts dedupe --yes 2>&1)"
1576
+ check "codex: dedupe removes the duplicate" "Removed 1 duplicate" "$out"
1577
+ codex-accounts list 2>&1 | grep -q "acct-04" \
1578
+ && t_fail "codex dedupe" "acct-04 still listed" || t_ok "codex: dedupe kept one entry per email"
1579
+ out="$(codex-accounts remove acct-03 --yes 2>&1)"
1580
+ check "codex-accounts remove" "Removed acct-03" "$out"
1581
+ [ ! -d "$CX/acct-03" ] && t_ok "codex: removed account dir deleted" || t_fail "codex remove" "dir still present"
1582
+
1583
+ # ---- C11. login-first add ceremony --------------------------------------------------
1584
+ # derived email: no argument, identity read back from the sign-in
1585
+ out="$(FAKE_EMAIL=new@cx codex-accounts add 2>&1)"
1586
+ check "codex: add with no email derives it from the sign-in" "Registered acct-03 for new@cx" "$out"
1587
+ grep -q '"email": "new@cx"' "$CX/accounts.json" && t_ok "codex: derived email registered" \
1588
+ || t_fail "codex add derive" "manifest lacks new@cx"
1589
+ # signed in as a different account than named: register who actually authenticated
1590
+ out="$(FAKE_EMAIL=other@cx codex-accounts add named@cx 2>&1)"
1591
+ check "codex: mismatched sign-in registers the real account" "registering the account that actually authenticated" "$out"
1592
+ grep -q '"email": "other@cx"' "$CX/accounts.json" && t_ok "codex: actual identity registered" \
1593
+ || t_fail "codex add mismatch" "manifest lacks other@cx"
1594
+ codex-accounts remove acct-04 --yes >/dev/null 2>&1
1595
+ # aborted login leaves zero traces
1596
+ before="$(ls "$CX" | sort)"
1597
+ out="$(FAKE_LOGIN_FAIL=1 codex-accounts add gone@cx 2>&1)"
1598
+ rc=$?
1599
+ [ "$rc" != "0" ] && t_ok "codex: aborted login exits nonzero" || t_fail "codex abort rc" "rc=0"
1600
+ after="$(ls "$CX" | sort)"
1601
+ [ "$before" = "$after" ] && t_ok "codex: aborted login leaves zero traces" \
1602
+ || t_fail "codex abort traces" "pool dir changed: $after"
1603
+ grep -q "gone@cx" "$CX/accounts.json" && t_fail "codex abort manifest" "gone@cx registered" \
1604
+ || t_ok "codex: aborted login registered nothing"
1605
+ # adding an already-registered email skips before sign-in
1606
+ out="$(codex-accounts add a@cx 2>&1)"
1607
+ rc=$?
1608
+ check "codex: add of registered email skips gracefully" "already added as acct-01" "$out"
1609
+ [ "$rc" = "0" ] && t_ok "codex: duplicate add exits 0 (no-op)" || t_fail "codex dup add rc" "rc=$rc"
1610
+ # signing in AS an already-registered email (unnamed) also refuses a second entry
1611
+ out="$(FAKE_EMAIL=a@cx codex-accounts add 2>&1)"
1612
+ check "codex: sign-in as registered email refused" "already added as acct-01" "$out"
1613
+ n="$(grep -c '"email": "a@cx"' "$CX/accounts.json")"
1614
+ [ "$n" = "1" ] && t_ok "codex: no second entry for a re-signed-in email" || t_fail "codex dup signin" "count=$n"
1615
+
1616
+ # ---- C12. login command + expired worklist + relogin --------------------------------
1617
+ # an account with no auth on this machine: login completes it
1618
+ codex-accounts import d@cx --id acct-04 --no-sync >/dev/null 2>&1
1619
+ out="$(codex-accounts expired 2>&1)"
1620
+ rc=$?
1621
+ check "codex: expired lists the auth-less account" "acct-04" "$out"
1622
+ [ "$rc" != "0" ] && t_ok "codex: expired exits 1 when a human is needed" || t_fail "codex expired rc" "rc=0"
1623
+ out="$(FAKE_EMAIL=d@cx codex-accounts login acct-04 2>&1)"
1624
+ check "codex: login completes an auth-less account" "login saved" "$out"
1625
+ [ -s "$CX/acct-04/auth.json" ] && t_ok "codex: login wrote auth.json" || t_fail "codex login" "no auth.json"
1626
+ # wrong sign-in identity is refused AND the prior credential is restored — the wrong
1627
+ # account's auth.json must never stay installed under this id (codex-review finding)
1628
+ out="$(FAKE_EMAIL=wrong@cx codex-accounts login acct-04 2>&1)"
1629
+ rc=$?
1630
+ check "codex: login refuses a wrong-account sign-in" "you signed in as wrong@cx" "$out"
1631
+ [ "$rc" != "0" ] && t_ok "codex: wrong-identity login exits nonzero" || t_fail "codex login identity rc" "rc=0"
1632
+ got_email="$(python3 - "$CX/acct-04/auth.json" <<'EOF'
1633
+ import base64, json, sys
1634
+ t = json.load(open(sys.argv[1]))['tokens']['id_token']
1635
+ p = t.split('.')[1]; p += '=' * (-len(p) % 4)
1636
+ print(json.loads(base64.urlsafe_b64decode(p)).get('email', ''))
1637
+ EOF
1638
+ )"
1639
+ [ "$got_email" = "d@cx" ] && t_ok "codex: refused login restores the prior credential" \
1640
+ || t_fail "codex login restore" "auth.json now belongs to: $got_email"
1641
+ # the sign-in ceremony is DEVICE-CODE by default (the localhost browser callback
1642
+ # cannot reach a remote/SSH machine); --browser opts into the callback flow
1643
+ export FAKE_LOGIN_ARGS="$WORK/cx-login-args"
1644
+ : > "$FAKE_LOGIN_ARGS"
1645
+ FAKE_EMAIL=d@cx codex-accounts login acct-04 >/dev/null 2>&1
1646
+ rc=$?
1647
+ { [ "$rc" = "0" ] && [ "$(cat "$FAKE_LOGIN_ARGS")" = "--device-auth" ]; } \
1648
+ && t_ok "codex: sign-in uses the device-code flow by default" \
1649
+ || t_fail "codex device default" "rc=$rc login args: '$(cat "$FAKE_LOGIN_ARGS")'"
1650
+ : > "$FAKE_LOGIN_ARGS"
1651
+ FAKE_EMAIL=d@cx codex-accounts login acct-04 --browser >/dev/null 2>&1
1652
+ rc=$?
1653
+ { [ "$rc" = "0" ] && [ "$(cat "$FAKE_LOGIN_ARGS")" = "" ]; } \
1654
+ && t_ok "codex: --browser opts into the localhost callback flow" \
1655
+ || t_fail "codex --browser opt-out" "rc=$rc login args: '$(cat "$FAKE_LOGIN_ARGS")'"
1656
+ unset FAKE_LOGIN_ARGS
1657
+
1658
+ # a parked account shows in expired and relogin fixes exactly that
1659
+ printf '%s\nreason=refresh-denied-http-400 marked_at=t detail=x\n' "$now" > "$CX/acct-04/.expired"
1660
+ out="$(codex-accounts expired 2>&1)"
1661
+ check "codex: expired shows the parked account" "acct-04" "$out"
1662
+ out="$(FAKE_EMAIL=d@cx codex-accounts relogin --yes 2>&1)"
1663
+ check "codex: relogin re-authenticates the worklist" "re-authenticated 1 of 1" "$out"
1664
+ [ ! -f "$CX/acct-04/.expired" ] && t_ok "codex: relogin cleared the park" || t_fail "codex relogin" "park survived"
1665
+ # a relogin whose ceremony fails is NOT reported fixed: the dead credential stays
1666
+ # on disk, and success is judged by whether the credential can AUTHENTICATE.
1667
+ mk_cx_auth "$CX/acct-04/auth.json" d@cx 1000 norefresh
1668
+ out="$(FAKE_LOGIN_FAIL=1 FAKE_EMAIL=d@cx codex-accounts relogin --yes 2>&1)"
1669
+ rc=$?
1670
+ check "codex: failed relogin says still failing" "still failing" "$out"
1671
+ [ "$rc" != "0" ] && t_ok "codex: failed relogin exits nonzero" || t_fail "codex failed relogin rc" "rc=0"
1672
+ out="$(codex-accounts expired 2>&1)"
1673
+ check "codex: the un-fixed account is still on the worklist" "acct-04" "$out"
1674
+ # api-key-only auth is not subscription auth: audited as unusable, never selected
1675
+ mk_cx_auth "$CX/acct-04/auth.json" d@cx "$FUTURE_EXP" apikey
1676
+ out="$(codex-accounts expired 2>&1)"
1677
+ check "codex: api-key auth is called out as unsupported" "API key" "$out"
1678
+ out="$(CODEX_SHIM_SELECT=random codex 2>&1)"
1679
+ case "$out" in *CFG=acct-04*) t_fail "codex apikey selection" "api-key account was selected" ;;
1680
+ *) t_ok "codex: api-key account is never selected" ;; esac
1681
+ codex-accounts remove acct-04 --yes >/dev/null 2>&1
1682
+
1683
+ # ---- C13. limits: fixture endpoint, marking, classification -------------------------
1684
+ : > "$CX/.limits-kick" # re-arm the shim-kick throttle: no background limits racing these
1685
+ cat > "$WORK/cx-usage-high.json" <<EOF
1686
+ {"email":"a@cx","plan_type":"pro",
1687
+ "rate_limit":{"allowed":true,"limit_reached":false,
1688
+ "primary_window":{"used_percent":42,"limit_window_seconds":18000,"reset_at":$((now+3600))},
1689
+ "secondary_window":{"used_percent":57,"limit_window_seconds":604800,"reset_at":$((now+90000))}},
1690
+ "code_review_rate_limit":null,
1691
+ "additional_rate_limits":[
1692
+ {"limit_name":"GPT-5.3-Codex-Spark","rate_limit":{"allowed":true,"limit_reached":false,
1693
+ "primary_window":{"used_percent":12,"limit_window_seconds":18000,"reset_at":$((now+3600))},
1694
+ "secondary_window":{"used_percent":95,"limit_window_seconds":604800,"reset_at":$((now+90000))}}}]}
1695
+ EOF
1696
+ out="$(CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-high.json" codex-accounts limits 2>&1)"
1697
+ check "codex: >=90% model bucket marks the account" "LIMITED GPT-5.3-Codex-Spark:7d at 95%" "$out"
1698
+ [ -f "$CX/acct-01/.limited" ] && t_ok "codex: .limited marker written" || t_fail "codex limits marker" "missing"
1699
+ IFS= read -r first < "$CX/acct-01/.limited"
1700
+ [ "$first" = "$((now+90000))" ] && t_ok "codex: marker carries the offender's reset epoch" \
1701
+ || t_fail "codex marker reset" "first line: $first"
1702
+ python3 - "$CX/acct-01/limits.json" <<'EOF'
1703
+ import json, sys
1704
+ d = json.load(open(sys.argv[1]))
1705
+ assert d['max_percent'] == 95, d['max_percent']
1706
+ assert d['weekly_percent'] == 95, d['weekly_percent']
1707
+ assert d['session_percent'] == 42, d['session_percent']
1708
+ names = {b['name']: (b['group'], b['percent']) for b in d['buckets']}
1709
+ assert names['5h'] == ('session', 42), names
1710
+ assert names['7d'] == ('weekly', 57), names
1711
+ assert names['GPT-5.3-Codex-Spark:5h'] == ('session', 12), names
1712
+ assert names['GPT-5.3-Codex-Spark:7d'] == ('weekly', 95), names
1713
+ EOF
1714
+ [ $? -eq 0 ] && t_ok "codex: buckets classified session/weekly with per-model names" \
1715
+ || t_fail "codex bucket classification" "see assertions"
1716
+ # below-threshold refresh clears the marker
1717
+ cat > "$WORK/cx-usage-low.json" <<EOF
1718
+ {"email":"a@cx","plan_type":"pro",
1719
+ "rate_limit":{"allowed":true,"limit_reached":false,
1720
+ "primary_window":{"used_percent":5,"limit_window_seconds":18000,"reset_at":$((now+3600))},
1721
+ "secondary_window":{"used_percent":9,"limit_window_seconds":604800,"reset_at":$((now+90000))}},
1722
+ "additional_rate_limits":[]}
1723
+ EOF
1724
+ out="$(CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits 2>&1)"
1725
+ check "codex: below-threshold refresh clears the marker" "marker cleared" "$out"
1726
+ [ ! -f "$CX/acct-01/.limited" ] && t_ok "codex: marker gone after clean pass" || t_fail "codex marker clear" "still present"
1727
+ # a hard limit_reached verdict excludes even when no window is >= threshold
1728
+ cat > "$WORK/cx-usage-blocked.json" <<EOF
1729
+ {"email":"a@cx","plan_type":"pro",
1730
+ "rate_limit":{"allowed":false,"limit_reached":true,
1731
+ "primary_window":{"used_percent":50,"limit_window_seconds":18000,"reset_at":$((now+3600))}}}
1732
+ EOF
1733
+ out="$(CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-blocked.json" codex-accounts limits 2>&1)"
1734
+ check "codex: limit_reached without a >=90% window still marks" "LIMITED limit_reached at 100%" "$out"
1735
+ rm -f "$CX"/acct-*/.limited
1736
+ # reshaped payload: windows are still found by the recursive fallback
1737
+ cat > "$WORK/cx-usage-reshaped.json" <<EOF
1738
+ {"totally":{"new":{"shape":{"used_percent":97,"limit_window_seconds":604800,"reset_at":$((now+90000))}}}}
1739
+ EOF
1740
+ out="$(CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-reshaped.json" codex-accounts limits 2>&1)"
1741
+ check "codex: reshaped payload still tracked (fallback walk)" "LIMITED" "$out"
1742
+ rm -f "$CX"/acct-*/.limited
1743
+ # garbage payload degrades that account only, never the run
1744
+ printf 'this is not json' > "$WORK/cx-usage-garbage.json"
1745
+ out="$(CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-garbage.json" codex-accounts limits 2>&1)"
1746
+ rc=$?
1747
+ [ "$rc" = "0" ] && t_ok "codex: garbage payload fails open (rc=0)" || t_fail "codex garbage rc" "rc=$rc"
1748
+ check "codex: garbage payload logged as failure" "usage fetch failed" "$out"
1749
+ # fetch throttle: fresh data is not re-fetched
1750
+ out="$(CODEX_MULTIACC_MIN_FETCH=9999 CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits 2>&1)"
1751
+ printf '%s' "$out" | grep -q "acct-01" \
1752
+ && t_fail "codex fetch throttle" "fresh account was re-fetched: $out" \
1753
+ || t_ok "codex: fresh account skipped by the fetch throttle"
1754
+ # 429 backoff honored
1755
+ python3 - "$CX/acct-02/limits.json" "$now" <<'EOF'
1756
+ import json, sys
1757
+ json.dump({'fetched_at': 0, 'retry_after': int(sys.argv[2]) + 300, 'backoff': 120},
1758
+ open(sys.argv[1], 'w'))
1759
+ EOF
1760
+ out="$(CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits 2>&1)"
1761
+ check "codex: 429 backoff honored" "acct-02: backing off after 429" "$out"
1762
+ rm -f "$CX"/acct-*/limits.json
1763
+
1764
+ # ---- C14. oauth refresh via the token endpoint --------------------------------------
1765
+ # expired bearer, missing endpoint: fail open with backoff
1766
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx 1000
1767
+ out="$(CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits 2>&1)"
1768
+ rc=$?
1769
+ check "codex: failed oauth refresh logged with backoff" "acct-01: oauth refresh failed" "$out"
1770
+ check "codex: expired bearer logged, not fatal" "acct-01: no fresh bearer" "$out"
1771
+ [ "$rc" = "0" ] && t_ok "codex: limits exits 0 with an expired-bearer account" || t_fail "codex refresh rc" "rc=$rc"
1772
+ [ -f "$CX/acct-01/.oauth-refresh.json" ] && t_ok "codex: refresh failure recorded for backoff" \
1773
+ || t_fail "codex refresh backoff file" "missing"
1774
+ # a now-working endpoint is not retried inside the backoff window
1775
+ python3 - "$WORK/cx-token-ok.json" "$((now + 864000))" <<'EOF'
1776
+ import base64, json, sys
1777
+ def jwt(claims):
1778
+ enc = lambda o: base64.urlsafe_b64encode(json.dumps(o).encode()).rstrip(b'=').decode()
1779
+ return f"{enc({'alg':'RS256'})}.{enc(claims)}.sig"
1780
+ exp = float(sys.argv[2])
1781
+ auth_claim = {"chatgpt_plan_type": "pro", "chatgpt_account_id": "acct-uuid"}
1782
+ json.dump({"access_token": jwt({"exp": exp, "https://api.openai.com/auth": auth_claim, "marker": "REFRESHED"}),
1783
+ "refresh_token": "rt-rotated-new",
1784
+ "id_token": jwt({"email": "a@cx", "exp": exp, "https://api.openai.com/auth": auth_claim})},
1785
+ open(sys.argv[1], 'w'))
1786
+ EOF
1787
+ out="$(CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-ok.json" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits 2>&1)"
1788
+ check "codex: refresh backoff honored (no early retry)" "acct-01: no fresh bearer" "$out"
1789
+ grep -q "rt-rotated-new" "$CX/acct-01/auth.json" \
1790
+ && t_fail "codex backoff" "auth.json rewritten inside the backoff window" \
1791
+ || t_ok "codex: no refresh inside the backoff window"
1792
+ # --force refreshes: rotated tokens persisted 0600, telemetry follows
1793
+ out="$(CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-ok.json" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force 2>&1)"
1794
+ check "codex: --force refreshes the expired token" "acct-01: access token refreshed" "$out"
1795
+ check "codex: refreshed account fetches telemetry" "acct-01: ok" "$out"
1796
+ python3 - "$CX/acct-01/auth.json" <<'EOF'
1797
+ import base64, json, os, stat, sys
1798
+ doc = json.load(open(sys.argv[1]))
1799
+ t = doc['tokens']
1800
+ payload = t['access_token'].split('.')[1]
1801
+ payload += '=' * (-len(payload) % 4)
1802
+ claims = json.loads(base64.urlsafe_b64decode(payload))
1803
+ assert claims.get('marker') == 'REFRESHED', 'access token not replaced'
1804
+ assert t['refresh_token'] == 'rt-rotated-new', 'refresh token not rotated'
1805
+ assert doc.get('last_refresh', '').startswith('20'), 'last_refresh not stamped'
1806
+ mode = stat.S_IMODE(os.stat(sys.argv[1]).st_mode)
1807
+ assert mode == 0o600, oct(mode)
1808
+ EOF
1809
+ [ $? -eq 0 ] && t_ok "codex: rotated credential persisted with 0600" || t_fail "codex rotation" "see assertions"
1810
+ [ ! -f "$CX/acct-01/.oauth-refresh.json" ] && t_ok "codex: successful refresh clears the backoff file" \
1811
+ || t_fail "codex refresh clear" "backoff file still present"
1812
+ grep -q "rt-rotated-new" "$CX/limits.log" \
1813
+ && t_fail "codex token leak" "a refresh token leaked into limits.log" \
1814
+ || t_ok "codex: limits.log leaks no tokens"
1815
+ # recently-expired token is left alone (a live codex session may own it) — even --force
1816
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx "$((now - 100))"
1817
+ cp "$CX/acct-01/auth.json" "$WORK/cx-recent.bak"
1818
+ rm -f "$CX/acct-01/limits.json" "$CX/acct-01/.oauth-refresh.json"
1819
+ out="$(CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-ok.json" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force 2>&1)"
1820
+ check "codex: recently-expired token not refreshed (even --force)" "acct-01: no fresh bearer" "$out"
1821
+ cmp -s "$CX/acct-01/auth.json" "$WORK/cx-recent.bak" \
1822
+ && t_ok "codex: recently-expired credential left untouched" \
1823
+ || t_fail "codex refresh gate" "credential rewritten inside the 5-min grace window"
1824
+ # no refresh token at all: parked with a clear reason
1825
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx 1000 norefresh
1826
+ rm -f "$CX/acct-01/limits.json" "$CX/acct-01/.oauth-refresh.json"
1827
+ out="$(CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-ok.json" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force 2>&1)"
1828
+ grep -q "reason=no-refresh-token" "$CX/acct-01/.expired" 2>/dev/null \
1829
+ && t_ok "codex: refreshless expired credential is parked" \
1830
+ || t_fail "codex no-refresh park" "no marker written"
1831
+ # a later successful authenticated fetch unparks it
1832
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx "$FUTURE_EXP"
1833
+ rm -f "$CX/acct-01/limits.json"
1834
+ out="$(CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force 2>&1)"
1835
+ [ ! -f "$CX/acct-01/.expired" ] && t_ok "codex: a successful usage fetch clears the dead-auth marker" \
1836
+ || t_fail "codex unpark" ".expired survived an authenticated fetch"
1837
+ # ...but never an org-blocked park (telemetry proves nothing about workspace policy)
1838
+ printf '%s\nreason=org-blocked marked_at=t detail=x\n' "$now" > "$CX/acct-01/.expired"
1839
+ rm -f "$CX/acct-01/limits.json"
1840
+ CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force >/dev/null 2>&1
1841
+ [ -f "$CX/acct-01/.expired" ] && t_ok "codex: a usage fetch does not unpark an org-blocked account" \
1842
+ || t_fail "codex org unpark" "telemetry cleared an org block it cannot observe"
1843
+ rm -f "$CX/acct-01/.expired"
1844
+ # a 4xx from the token endpoint must not park the pool; invalid_grant is proof
1845
+ port=""
1846
+ python3 "$srv_script" > "$WORK/cx-token-port" 2>/dev/null &
1847
+ cx_srv_pid=$!
1848
+ for _ in $(seq 1 20); do
1849
+ port="$(head -1 "$WORK/cx-token-port" 2>/dev/null)"
1850
+ case "$port" in ''|*[!0-9]*) port=""; sleep 0.2 ;; *) break ;; esac
1851
+ done
1852
+ if [ -z "$port" ]; then
1853
+ kill "$cx_srv_pid" 2>/dev/null; wait "$cx_srv_pid" 2>/dev/null || true
1854
+ t_ok "codex: token-endpoint 4xx tests skipped (cannot bind a loopback port here)"
1855
+ else
1856
+ mk_cx_stale() {
1857
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx 1000
1858
+ rm -f "$CX/acct-01/limits.json" "$CX/acct-01/.oauth-refresh.json" "$CX/acct-01/.expired"
1859
+ }
1860
+ mk_cx_stale
1861
+ CODEX_MULTIACC_TOKEN_URL="http://127.0.0.1:$port/boom" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force >/dev/null 2>&1
1862
+ [ ! -f "$CX/acct-01/.expired" ] && t_ok "codex: one opaque 4xx does not park an account" \
1863
+ || t_fail "codex 4xx park" "a single non-invalid_grant 400 parked the account"
1864
+ CODEX_MULTIACC_TOKEN_URL="http://127.0.0.1:$port/boom" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force >/dev/null 2>&1
1865
+ CODEX_MULTIACC_TOKEN_URL="http://127.0.0.1:$port/boom" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force >/dev/null 2>&1
1866
+ [ -f "$CX/acct-01/.expired" ] && t_ok "codex: a repeatedly-refused grant is parked on the third strike" \
1867
+ || t_fail "codex 4xx strikes" "still not parked after three refusals"
1868
+ mk_cx_stale
1869
+ CODEX_MULTIACC_TOKEN_URL="http://127.0.0.1:$port/invalid-grant" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force >/dev/null 2>&1
1870
+ grep -q "invalid_grant" "$CX/acct-01/.expired" 2>/dev/null \
1871
+ && t_ok "codex: invalid_grant parks the account immediately" \
1872
+ || t_fail "codex invalid_grant" "no marker for an explicit invalid_grant"
1873
+ kill "$cx_srv_pid" 2>/dev/null; wait "$cx_srv_pid" 2>/dev/null || true
1874
+ fi
1875
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx "$FUTURE_EXP"
1876
+ rm -f "$CX/acct-01/.expired" "$CX/acct-01/.oauth-refresh.json" "$CX"/acct-*/limits.json
1877
+ # malformed tokens object (null) degrades that account only
1878
+ cp "$CX/acct-01/auth.json" "$WORK/cx-auth01.bak"
1879
+ printf '{"auth_mode":"chatgpt","tokens": null}' > "$CX/acct-01/auth.json"
1880
+ out="$(CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-ok.json" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force 2>&1)"
1881
+ rc=$?
1882
+ [ "$rc" = "0" ] && t_ok "codex: null tokens object fails open (rc=0)" || t_fail "codex null tokens rc" "rc=$rc"
1883
+ check "codex: null tokens degrades only that account" "acct-01: no fresh bearer" "$out"
1884
+ [ -f "$CX/acct-02/limits.json" ] && t_ok "codex: accounts after a malformed one still refresh" \
1885
+ || t_fail "codex fail-open loop" "acct-02 starved by acct-01's malformed auth"
1886
+ cp "$WORK/cx-auth01.bak" "$CX/acct-01/auth.json"
1887
+ rm -f "$CX"/acct-*/limits.json
1888
+
1889
+ # refresh is not attempted at all when the rotated credential could not be persisted
1890
+ # (the grant rotates the refresh token — consuming it and then failing to write
1891
+ # auth.json would strand the account; codex-review finding)
1892
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx 1000
1893
+ rm -f "$CX/acct-01/limits.json" "$CX/acct-01/.oauth-refresh.json"
1894
+ chmod 500 "$CX/acct-01"
1895
+ out="$(CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-ok.json" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force 2>&1)"
1896
+ chmod 700 "$CX/acct-01"
1897
+ check "codex: unpersistable credential skips the refresh grant" "refresh not attempted" "$out"
1898
+ grep -q "rt-rotated-new" "$CX/acct-01/auth.json" \
1899
+ && t_fail "codex refresh preflight" "the grant was consumed despite an unwritable dir" \
1900
+ || t_ok "codex: refresh grant not consumed when persistence would fail"
1901
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx "$FUTURE_EXP"
1902
+ rm -f "$CX/acct-01/.oauth-refresh.json"
1903
+
1904
+ # two parallel imports get distinct ids and both land in the manifest (lock coverage;
1905
+ # codex-review finding)
1906
+ codex-accounts import p1@cx --no-sync >/dev/null 2>&1 &
1907
+ imp1=$!
1908
+ codex-accounts import p2@cx --no-sync >/dev/null 2>&1 &
1909
+ imp2=$!
1910
+ wait "$imp1"; wait "$imp2"
1911
+ n1="$(grep -c '"email": "p1@cx"' "$CX/accounts.json")"
1912
+ n2="$(grep -c '"email": "p2@cx"' "$CX/accounts.json")"
1913
+ id1="$(python3 -c "import json,sys; print(next((a['id'] for a in json.load(open('$CX/accounts.json'))['accounts'] if a['email']=='p1@cx'), ''))")"
1914
+ id2="$(python3 -c "import json,sys; print(next((a['id'] for a in json.load(open('$CX/accounts.json'))['accounts'] if a['email']=='p2@cx'), ''))")"
1915
+ { [ "$n1" = "1" ] && [ "$n2" = "1" ] && [ -n "$id1" ] && [ -n "$id2" ] && [ "$id1" != "$id2" ]; } \
1916
+ && t_ok "codex: parallel imports get distinct ids ($id1, $id2)" \
1917
+ || t_fail "codex parallel import" "p1=$n1($id1) p2=$n2($id2)"
1918
+ codex-accounts remove "$id1" --yes >/dev/null 2>&1
1919
+ codex-accounts remove "$id2" --yes >/dev/null 2>&1
1920
+
1921
+ # ---- C15. verify --------------------------------------------------------------------
1922
+ out="$(codex-accounts verify --quick 2>&1)"
1923
+ check "codex: verify --quick passes chatgpt accounts" "acct-01 a@cx: OK" "$out"
1924
+ check "codex: verify --quick counts" "0 failure(s)" "$out"
1925
+ out="$(codex-accounts verify 2>&1)"
1926
+ check "codex: full verify PASS via the real-call matrix" "acct-01 a@cx: PASS" "$out"
1927
+ check "codex: full verify zero failures" "0 failure(s)" "$out"
1928
+ # verify parks a dead login it discovers, and a PASS clears an existing park
1929
+ echo "authfail:acct-02" > "$FAKE_CTL2"
1930
+ printf '%s\nreason=auth-error marked_at=t detail=x\n' "$now" > "$CX/acct-01/.expired"
1931
+ out="$(codex-accounts verify 2>&1)"
1932
+ check "codex: verify flags the dead login" "acct-02 b@cx: FAIL" "$out"
1933
+ check "codex: verify says what fixes it" "codex-accounts relogin acct-02" "$out"
1934
+ grep -q "reason=auth-error" "$CX/acct-02/.expired" 2>/dev/null \
1935
+ && t_ok "codex: verify parks the dead login" || t_fail "codex verify park" "no marker"
1936
+ [ ! -f "$CX/acct-01/.expired" ] && t_ok "codex: a verify PASS clears an existing park" \
1937
+ || t_fail "codex verify unpark" "PASS left the marker in place"
1938
+ rm -f "$FAKE_CTL2" "$CX/acct-02/.expired"
1939
+ # an org-disabled account is parked as org-blocked
1940
+ echo "orgfail:acct-02" > "$FAKE_CTL2"
1941
+ out="$(codex-accounts verify 2>&1)"
1942
+ check "codex: verify calls out the workspace block" "ORG BLOCKED" "$out"
1943
+ grep -q "reason=org-blocked" "$CX/acct-02/.expired" 2>/dev/null \
1944
+ && t_ok "codex: verify parks the org-blocked account" || t_fail "codex verify org park" "no marker"
1945
+ rm -f "$FAKE_CTL2" "$CX/acct-02/.expired"
1946
+
1947
+ # ---- C16. audit vocabulary + empty pool ---------------------------------------------
1948
+ mkdir -p "$CX/acct-06"
1949
+ python3 - "$CX/accounts.json" <<'EOF'
1950
+ import json, sys
1951
+ doc = json.load(open(sys.argv[1]))
1952
+ doc['accounts'] = [a for a in doc['accounts'] if a['id'] != 'acct-06']
1953
+ doc['accounts'].append({'id': 'acct-06', 'email': 'srv@cx', 'home': 'server'})
1954
+ doc['accounts'].sort(key=lambda a: a['id'])
1955
+ json.dump(doc, open(sys.argv[1], 'w'), indent=2)
1956
+ EOF
1957
+ out="$(python3 "$REPO_DIR/lib/codex_audit.py" "$CX" linux | awk -F'\t' '$1=="acct-06"{print $4}')"
1958
+ [ "$out" = "missing" ] && t_ok "codex: home=server on the server means NO LOGIN, not 'elsewhere'" \
1959
+ || t_fail "codex home vocabulary" "expected missing on linux, got: $out"
1960
+ out="$(python3 "$REPO_DIR/lib/codex_audit.py" "$CX" mac | awk -F'\t' '$1=="acct-06"{print $4}')"
1961
+ [ "$out" = "remote" ] && t_ok "codex: home=server on the Mac is correctly 'elsewhere'" \
1962
+ || t_fail "codex home vocabulary" "expected remote on mac, got: $out"
1963
+ python3 - "$CX/accounts.json" <<'EOF'
1964
+ import json, sys
1965
+ doc = json.load(open(sys.argv[1]))
1966
+ doc['accounts'] = [a for a in doc['accounts'] if a['id'] != 'acct-06']
1967
+ json.dump(doc, open(sys.argv[1], 'w'), indent=2)
1968
+ EOF
1969
+ rm -rf "$CX/acct-06"
1970
+ cxemptypool="$WORK/cx-emptypool"
1971
+ mkdir -p "$cxemptypool/tmp"
1972
+ printf '{"version":1,"threshold":90,"accounts":[]}' > "$cxemptypool/accounts.json"
1973
+ out="$(CODEX_ACCOUNTS_DIR="$cxemptypool" codex-accounts expired 2>&1)"
1974
+ rc=$?
1975
+ check "codex: expired on an empty pool says so" "No accounts registered yet" "$out"
1976
+ [ "$rc" = "0" ] && t_ok "codex: empty pool exits 0" || t_fail "codex empty pool rc" "rc=$rc"
1977
+
1978
+ # ---- C17. security hardening --------------------------------------------------------
1979
+ mkdir -p "$WORK/cx-canary" && : > "$WORK/cx-canary/DO_NOT_DELETE"
1980
+ cp "$CX/accounts.json" "$WORK/cx-manifest.bak"
1981
+ python3 - "$CX/accounts.json" <<'EOF'
1982
+ import json, os, sys
1983
+ d = json.load(open(sys.argv[1]))
1984
+ d['accounts'].append({'id': '../cx-canary', 'email': 'evil@cx', 'home': 'mac'})
1985
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
1986
+ EOF
1987
+ out="$(codex-accounts remove ../cx-canary --yes 2>&1)"
1988
+ rc=$?
1989
+ [ -f "$WORK/cx-canary/DO_NOT_DELETE" ] && t_ok "codex: remove refuses path-traversal id" \
1990
+ || t_fail "codex path traversal" "remove ../cx-canary DELETED files outside the pool"
1991
+ [ "$rc" != "0" ] && t_ok "codex: traversal id rejected nonzero" || t_fail "codex traversal rc" "rc=0"
1992
+ codex-accounts list 2>&1 | grep -q "\.\./cx-canary" \
1993
+ && t_fail "codex id filter" "traversal id surfaced" \
1994
+ || t_ok "codex: invalid manifest ids are filtered out"
1995
+ cp "$WORK/cx-manifest.bak" "$CX/accounts.json"
1996
+ if [ "$(uname -s)" = "Darwin" ]; then
1997
+ python3 - "$CX/accounts.json" <<'EOF'
1998
+ import json, os, sys
1999
+ d = json.load(open(sys.argv[1]))
2000
+ d['server_root'] = "/tmp/x'; touch /tmp/cx_multiacc_PWNED; #"
2001
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
2002
+ EOF
2003
+ rm -f /tmp/cx_multiacc_PWNED
2004
+ out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
2005
+ [ ! -f /tmp/cx_multiacc_PWNED ] && t_ok "codex: sync rejects injected server_root" \
2006
+ || { t_fail "codex command injection" "server_root injection EXECUTED"; rm -f /tmp/cx_multiacc_PWNED; }
2007
+ check "codex: injected server_root refused" "not a plain absolute path" "$out"
2008
+ cp "$WORK/cx-manifest.bak" "$CX/accounts.json"
2009
+ python3 - "$CX/accounts.json" <<'EOF'
2010
+ import json, os, sys
2011
+ d = json.load(open(sys.argv[1])); d['accounts'] = []
2012
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
2013
+ EOF
2014
+ out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
2015
+ rc=$?
2016
+ check "codex: empty manifest refuses to sync" "refusing to blank the server pool" "$out"
2017
+ [ "$rc" != "0" ] && t_ok "codex: empty-manifest sync exits nonzero" || t_fail "codex empty sync rc" "rc=0"
2018
+ cp "$WORK/cx-manifest.bak" "$CX/accounts.json"
2019
+ else
2020
+ t_ok "codex: sync validation tests skipped (Mac-only feature)"
2021
+ fi
2022
+
2023
+ # ---- C18. status + npm layer --------------------------------------------------------
2024
+ out="$(codex-accounts status 2>&1)"
2025
+ check "codex: status shows threshold" "90%" "$out"
2026
+ check "codex: status shows account" "a@cx" "$out"
2027
+ check "codex: status shows plan" "plan pro" "$out"
2028
+ if command -v node >/dev/null 2>&1; then
2029
+ out="$(node "$REPO_DIR/bin/cli.mjs" codex list 2>&1)"
2030
+ check "cli.mjs codex passthrough (list)" "a@cx" "$out"
2031
+ out="$(node "$REPO_DIR/bin/cli.mjs" --help 2>&1)"
2032
+ check "cli --help documents the codex pool" "codex-accounts" "$out"
2033
+ else
2034
+ t_ok "codex npm-layer tests skipped (node not installed)"
2035
+ fi
2036
+
1248
2037
  # ---- summary ---------------------------------------------------------------------
1249
2038
  echo
1250
2039
  echo "passed: $PASS failed: $FAIL"