@bitkyc08/opencodex 2.41.0 → 2.42.0

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.
Files changed (40) hide show
  1. package/gui/dist/assets/index-BU1tE0sr.js +112 -0
  2. package/gui/dist/assets/index-DL9-iS6J.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/cursor/protobuf-request.ts +41 -21
  6. package/src/adapters/identity.ts +8 -2
  7. package/src/adapters/openai-responses.ts +43 -3
  8. package/src/bridge.ts +25 -3
  9. package/src/cli/account-auth.ts +28 -3
  10. package/src/cli/account-extended.ts +7 -1
  11. package/src/cli/capabilities.ts +2 -2
  12. package/src/cli/observe.ts +3 -1
  13. package/src/codex/auth-api.ts +102 -9
  14. package/src/codex/catalog/effort.ts +15 -2
  15. package/src/codex/catalog/metadata.ts +114 -9
  16. package/src/codex/catalog/native-models.ts +71 -0
  17. package/src/codex/catalog/parsing.ts +3 -3
  18. package/src/codex/catalog/provider-fetch.ts +4 -3
  19. package/src/codex/catalog.ts +1 -1
  20. package/src/codex/data/upstream-models.json +169 -0
  21. package/src/codex/inject.ts +96 -6
  22. package/src/codex/injected-marker.ts +30 -4
  23. package/src/codex/journal.ts +14 -0
  24. package/src/generated/compatibility-version.json +40 -32
  25. package/src/oauth/account-quota-rank.ts +40 -1
  26. package/src/oauth/chatgpt-device.ts +187 -0
  27. package/src/oauth/chatgpt.ts +31 -4
  28. package/src/oauth/index.ts +13 -3
  29. package/src/oauth/log.ts +3 -0
  30. package/src/providers/muse-subscription-usage.ts +95 -0
  31. package/src/providers/quota.ts +96 -0
  32. package/src/providers/registry.ts +1 -1
  33. package/src/server/index.ts +15 -7
  34. package/src/server/live.ts +18 -4
  35. package/src/server/management/oauth-account-routes.ts +10 -3
  36. package/src/server/responses/core.ts +34 -0
  37. package/src/server/responses/empty-completion-guard.ts +4 -0
  38. package/src/types/request.ts +8 -0
  39. package/gui/dist/assets/index-B2YjLA-i.css +0 -1
  40. package/gui/dist/assets/index-aPup8CKb.js +0 -112
@@ -10,7 +10,7 @@
10
10
  },
11
11
  {
12
12
  "path": "package.json",
13
- "sha256": "47de57e8a70dd7d845a202d2863bcb8b0a27b4f842de035a18565f8dcf104ebf"
13
+ "sha256": "583fb118e7f8db57d275f6d81958a67042afa446eb6d78f218f588c8812d5272"
14
14
  },
15
15
  {
16
16
  "path": "scripts/model-metadata.source.json",
@@ -194,7 +194,7 @@
194
194
  },
195
195
  {
196
196
  "path": "src/adapters/cursor/protobuf-request.ts",
197
- "sha256": "ce38936510a5999b47b207e540a434929b1fe18e4d255d9e25c788ec73846ab0"
197
+ "sha256": "3e2aa6f21bd9e6c4d4d78480bb1755424828fd41bc50361bb7696812cd25e62e"
198
198
  },
199
199
  {
200
200
  "path": "src/adapters/cursor/request-builder.ts",
@@ -266,7 +266,7 @@
266
266
  },
267
267
  {
268
268
  "path": "src/adapters/identity.ts",
269
- "sha256": "ee5ecb8164e70da97e2e75495a3abc909abcf058e49609325d6b473c3634110f"
269
+ "sha256": "21a147b96b5c5d6741d1b793be71d8b73944649f84f0c4c351cd149d9bf9e562"
270
270
  },
271
271
  {
272
272
  "path": "src/adapters/image.ts",
@@ -342,7 +342,7 @@
342
342
  },
343
343
  {
344
344
  "path": "src/adapters/openai-responses.ts",
345
- "sha256": "4a12d138aca9dd1e56ef6d11907aa69a143acd9e1534d991806d217dbe975890"
345
+ "sha256": "19e94674fedee992fd52ac88c33e9d1aaeaf5a310d296698ee92b4bbeaaa9819"
346
346
  },
347
347
  {
348
348
  "path": "src/adapters/registry.ts",
@@ -378,7 +378,7 @@
378
378
  },
379
379
  {
380
380
  "path": "src/bridge.ts",
381
- "sha256": "a88d4ef081496d93610194cf1bd3c4befb68b088f91338edc709b795cdc55cae"
381
+ "sha256": "6dc37628f3651a8db77d528265b87ca6c61e9ede9e49808c55c6b6e721ec38dc"
382
382
  },
383
383
  {
384
384
  "path": "src/chat/inbound.ts",
@@ -470,7 +470,7 @@
470
470
  },
471
471
  {
472
472
  "path": "src/cli/account-auth.ts",
473
- "sha256": "6144462a5dbd94a2da1942ac0266a633eb977601ba3f316044ab503af802672b"
473
+ "sha256": "afabb1f192fbb1be02fd60315deba1c6ed906a771cf6ec4374090166fea9e944"
474
474
  },
475
475
  {
476
476
  "path": "src/cli/account-catalog-refresh.ts",
@@ -478,7 +478,7 @@
478
478
  },
479
479
  {
480
480
  "path": "src/cli/account-extended.ts",
481
- "sha256": "361ea2bea9f7864336df1c1886d76e02194efabb993f6c4c7565c5abf9ce0bed"
481
+ "sha256": "9308c5fc1ee6ea481749cc353288be347d78d2cf35b85839749b94813115020d"
482
482
  },
483
483
  {
484
484
  "path": "src/cli/account-main.ts",
@@ -506,7 +506,7 @@
506
506
  },
507
507
  {
508
508
  "path": "src/cli/capabilities.ts",
509
- "sha256": "1605eaf4531ec5bbbfe1b56073760a1c8ab6b9260709d7600a87b95ae956ca7f"
509
+ "sha256": "acf94353c1ffcbce3badc1f144ab2423d12e0653c763edd6ccebea8d24d33c90"
510
510
  },
511
511
  {
512
512
  "path": "src/cli/catalog-prewarm.ts",
@@ -630,7 +630,7 @@
630
630
  },
631
631
  {
632
632
  "path": "src/cli/observe.ts",
633
- "sha256": "4f9b8ce65ff93276837379e91481467ae09bbbce095c02df16048f8fe84f0d98"
633
+ "sha256": "7b46284dfcacaf9b902f5951bf622c17adff74cc4d59bf8b2c4dc6b605b5f6c1"
634
634
  },
635
635
  {
636
636
  "path": "src/cli/opencode.ts",
@@ -802,7 +802,7 @@
802
802
  },
803
803
  {
804
804
  "path": "src/codex/auth-api.ts",
805
- "sha256": "68317d04dfcf132eca089e7657d8cd47f7d3e133bc15280448ad9f119a049a6d"
805
+ "sha256": "ede7eba228b9185e57f8d142aa27b7fef2255b6c6d253cd7bc79fd089824abee"
806
806
  },
807
807
  {
808
808
  "path": "src/codex/auth-collision.ts",
@@ -830,7 +830,7 @@
830
830
  },
831
831
  {
832
832
  "path": "src/codex/catalog.ts",
833
- "sha256": "31ec258ffb16b1f7393480f0fe3454263a0b8f7b9a646d84acc35c4122b30d0a"
833
+ "sha256": "29907ddc709e5a287a732bcb3716211faa07eb4faf5873fe2bbc73b9d6c12142"
834
834
  },
835
835
  {
836
836
  "path": "src/codex/catalog/account-models.ts",
@@ -846,7 +846,7 @@
846
846
  },
847
847
  {
848
848
  "path": "src/codex/catalog/effort.ts",
849
- "sha256": "b837a78528f944ece136efc155aee4ff819f44f57d1affc65db1b36f47048ba6"
849
+ "sha256": "ab599d14e7e2c8e84745e0481198b2ff7274772518225f67bcd849a866628c28"
850
850
  },
851
851
  {
852
852
  "path": "src/codex/catalog/filesystem-evidence.ts",
@@ -858,19 +858,19 @@
858
858
  },
859
859
  {
860
860
  "path": "src/codex/catalog/metadata.ts",
861
- "sha256": "7931ecde8e90f661b2550e3961607d04fd88e042099d628fb04d40c0b988ddbf"
861
+ "sha256": "bfed4ab1d763c0349acc6814507d97039f13b7a8365465cc014eaab62bae51d0"
862
862
  },
863
863
  {
864
864
  "path": "src/codex/catalog/native-models.ts",
865
- "sha256": "8e042ff9737e6dd233298a54ace305d8d07920b5e783387f94821bae9efb5c47"
865
+ "sha256": "24bc83bbe17d58783eb3d137d9072313340814ca0e2e48759a7fc20077e2265a"
866
866
  },
867
867
  {
868
868
  "path": "src/codex/catalog/parsing.ts",
869
- "sha256": "8dbf8350b263c8364f87e9b92aea6ccb9a97672047f81dad4d154f0e877f3a1e"
869
+ "sha256": "517edd354acc48611a5968ca8e8c9085de4749dc1b05561db9ea504859b11c52"
870
870
  },
871
871
  {
872
872
  "path": "src/codex/catalog/provider-fetch.ts",
873
- "sha256": "b2e70967445db892ada6ba0525a7718a885b6fe362a56e85a5fa79de67ddd288"
873
+ "sha256": "69ad89553d387a69795d56706adce0406218bc28a0b7ab584e4cf1c9285b5bf7"
874
874
  },
875
875
  {
876
876
  "path": "src/codex/catalog/sync.ts",
@@ -906,7 +906,7 @@
906
906
  },
907
907
  {
908
908
  "path": "src/codex/data/upstream-models.json",
909
- "sha256": "d2758371028f5068b63e569c2aeacec9292f6e8a1d55b22e7f31e7c74dfff9a9"
909
+ "sha256": "3f83e796c1cbec028a47526a19eb9a1a314cf6444f2cbef10e6935ff3bc4398c"
910
910
  },
911
911
  {
912
912
  "path": "src/codex/desired-state.ts",
@@ -966,11 +966,11 @@
966
966
  },
967
967
  {
968
968
  "path": "src/codex/inject.ts",
969
- "sha256": "2aae2e66977dc851b8c5b726c64d794f047214b16467849c2b5b9c5d6731fc39"
969
+ "sha256": "9bc9759d276c0eaf59d136bd98341994e676339e6399af894b5d8318d827183e"
970
970
  },
971
971
  {
972
972
  "path": "src/codex/injected-marker.ts",
973
- "sha256": "5d99ec3ac06d598c839f0f4ec80a24f43a61ebb8e682cad4f80a2630ee71998e"
973
+ "sha256": "e5f2ad207839ef89ee03db9866bfc9b9ba82494f4cc4ca3331d7595094d1cbac"
974
974
  },
975
975
  {
976
976
  "path": "src/codex/integration-record.ts",
@@ -986,7 +986,7 @@
986
986
  },
987
987
  {
988
988
  "path": "src/codex/journal.ts",
989
- "sha256": "81ebfc825f2bc7b5e04310e141dded6926e74031f7b46a60a3aff13514464d1a"
989
+ "sha256": "8985c66c13982144e83ab49802c0cf19b06e912aeed4c653eeca43607cba6241"
990
990
  },
991
991
  {
992
992
  "path": "src/codex/log-guard/inspect.ts",
@@ -2238,7 +2238,7 @@
2238
2238
  },
2239
2239
  {
2240
2240
  "path": "src/oauth/account-quota-rank.ts",
2241
- "sha256": "572463cce8798c9b2f12f046e8cd75094a9b8b3bc203ca9e76ea898b798fdda1"
2241
+ "sha256": "7c458eb8c109548586dcfba64e0ce0c9939fe8e801917cb83393e50bf4e0116d"
2242
2242
  },
2243
2243
  {
2244
2244
  "path": "src/oauth/anthropic-routing.ts",
@@ -2252,9 +2252,13 @@
2252
2252
  "path": "src/oauth/callback-server.ts",
2253
2253
  "sha256": "3c481c4b26bcaaee246aff098be3fa402e1c0f813adf0f00a4122af92d8d5264"
2254
2254
  },
2255
+ {
2256
+ "path": "src/oauth/chatgpt-device.ts",
2257
+ "sha256": "c3021b86c4c94b4b64bb0ee26d67e57cf0b155390a7a7a8d1ab56c8281c4ef8a"
2258
+ },
2255
2259
  {
2256
2260
  "path": "src/oauth/chatgpt.ts",
2257
- "sha256": "83ee3e60409df8039c9497f61683c628d603a8a8c695e1f02f8e01c5f97b723c"
2261
+ "sha256": "bbb17b33b4de58afd94086885d993906e96ed06652f1a41dee9a8081372f657e"
2258
2262
  },
2259
2263
  {
2260
2264
  "path": "src/oauth/command-code.ts",
@@ -2282,7 +2286,7 @@
2282
2286
  },
2283
2287
  {
2284
2288
  "path": "src/oauth/index.ts",
2285
- "sha256": "361a8f0a010b93cdb165eb0f083dd8f38b9754fae6f9efae54225db4fbbd87b7"
2289
+ "sha256": "9e84daa06ac81db3da3e8af026add2ed59085ff367da15ba9a53123ccc4d0346"
2286
2290
  },
2287
2291
  {
2288
2292
  "path": "src/oauth/key-providers.ts",
@@ -2306,7 +2310,7 @@
2306
2310
  },
2307
2311
  {
2308
2312
  "path": "src/oauth/log.ts",
2309
- "sha256": "a6f0b95c49d68a1ddfa2421ce9f6652e6644c6a6e9f1f039d531f8792ad36d51"
2313
+ "sha256": "bafd097b059bc2aaa81b1f0a50dad867ab32673218732ea592d7e84b5bc376d0"
2310
2314
  },
2311
2315
  {
2312
2316
  "path": "src/oauth/login-cli.ts",
@@ -2460,6 +2464,10 @@
2460
2464
  "path": "src/providers/model-rename-startup.ts",
2461
2465
  "sha256": "8a143284d736759bf63c8cb296c19e61dec9596dfc781d8dd87038778153f55f"
2462
2466
  },
2467
+ {
2468
+ "path": "src/providers/muse-subscription-usage.ts",
2469
+ "sha256": "e4e7ee3aa1632122177d2552137e1a97faedd5686b58d304c56741fff78fd564"
2470
+ },
2463
2471
  {
2464
2472
  "path": "src/providers/new-model-policy.ts",
2465
2473
  "sha256": "02c7c4000aa9a8bdd8264027fa4e07844f09c92eda516afec7c2270818ae1618"
@@ -2510,11 +2518,11 @@
2510
2518
  },
2511
2519
  {
2512
2520
  "path": "src/providers/quota.ts",
2513
- "sha256": "5699763f606b59af65e75800a2e3f90127719b9f0f49e8cf87e4be43e316cf30"
2521
+ "sha256": "112799e24b19bb0ef3403ebe530f32ab462e581982c4dfd285f249907af2210b"
2514
2522
  },
2515
2523
  {
2516
2524
  "path": "src/providers/registry.ts",
2517
- "sha256": "089732b5710356083ffe63ba2fea49974f5e5284e0df64829660e606323749b9"
2525
+ "sha256": "8fcf151ab118c70cea0e2a07de6f516bfcadbd5834cadb65063c803294152032"
2518
2526
  },
2519
2527
  {
2520
2528
  "path": "src/providers/request-pacing.ts",
@@ -2798,7 +2806,7 @@
2798
2806
  },
2799
2807
  {
2800
2808
  "path": "src/server/index.ts",
2801
- "sha256": "77a4c65499054cde948de592c27b8e522a12f16e06bdc472cb80e1a4978d1398"
2809
+ "sha256": "6d920629f3dccac784e3f1bdf34068a39b64d17fe403efe696c241718613e82f"
2802
2810
  },
2803
2811
  {
2804
2812
  "path": "src/server/lifecycle.ts",
@@ -2806,7 +2814,7 @@
2806
2814
  },
2807
2815
  {
2808
2816
  "path": "src/server/live.ts",
2809
- "sha256": "ce0cd3ed3c36fec1490ab0121691c85563e2ea91226ea0084e2468db812f23b3"
2817
+ "sha256": "6347a6dd273bb6d5d2c1ba9826497087edb59f0c61d4c10196ddfbd01f3b6205"
2810
2818
  },
2811
2819
  {
2812
2820
  "path": "src/server/local-management-read-client.ts",
@@ -2894,7 +2902,7 @@
2894
2902
  },
2895
2903
  {
2896
2904
  "path": "src/server/management/oauth-account-routes.ts",
2897
- "sha256": "21e092715e45e8552849d9d428d3cf54a076b6dd3dd73af02afca792bbb4eb4a"
2905
+ "sha256": "7f63a560692d3fe3e7c4a9614819c44fe183f970a38c7120fe7bd3a345c6ae88"
2898
2906
  },
2899
2907
  {
2900
2908
  "path": "src/server/management/provider-capability-config.ts",
@@ -3094,11 +3102,11 @@
3094
3102
  },
3095
3103
  {
3096
3104
  "path": "src/server/responses/core.ts",
3097
- "sha256": "73233daa85faa14e355d11e0b9aaabda463d586e23d7c44f40373cddab8eb265"
3105
+ "sha256": "73dc56d86793a3b7e738bb2ac0a7b60be003688df060d748aa7fd30ef7950674"
3098
3106
  },
3099
3107
  {
3100
3108
  "path": "src/server/responses/empty-completion-guard.ts",
3101
- "sha256": "d7489220a3cd6b3209b7f50454033511d57c42fb7c75141918a2b1e7692decd5"
3109
+ "sha256": "069cdad33a1fd996d37541d13be189223dda4971e2e54b9bd65c76dda97102ce"
3102
3110
  },
3103
3111
  {
3104
3112
  "path": "src/server/responses/encrypted-payload.ts",
@@ -3290,7 +3298,7 @@
3290
3298
  },
3291
3299
  {
3292
3300
  "path": "src/types/request.ts",
3293
- "sha256": "8f108a629693e51287921f24e7a5d5c21f8240d77f6d87ccb30d394ab12d86b3"
3301
+ "sha256": "30272ac9e6abf66754171d938b2a8f6be8b44d2f9f153380e22e7a786fd8f08e"
3294
3302
  },
3295
3303
  {
3296
3304
  "path": "src/types/tools.ts",
@@ -10,7 +10,7 @@
10
10
  * categories answer the only question rotation asks — "which of these is most likely to
11
11
  * serve the retry" — and within the healthy group a simple headroom sort is enough.
12
12
  */
13
- import { getCachedProviderAccountQuota } from "../providers/quota";
13
+ import { getCachedProviderAccountQuota, hasPassiveAccountQuota } from "../providers/quota";
14
14
  import { getKiroAccountExhaustion } from "../providers/kiro-usage";
15
15
 
16
16
  /** Lower sorts earlier. Unknown sits between measured-healthy and measured-empty. */
@@ -27,6 +27,24 @@ interface Ranked {
27
27
  index: number;
28
28
  }
29
29
 
30
+ /**
31
+ * How old a PASSIVELY observed quota may be and still steer routing.
32
+ *
33
+ * A probed row is fresh by construction: it exists only because a probe wrote it, and
34
+ * `fetchAccountQuota` re-probes once `ACCOUNT_QUOTA_TTL_MS` has passed. So no caller has
35
+ * ever needed an explicit age check, and `getCachedProviderAccountQuota` does not apply
36
+ * one.
37
+ *
38
+ * A passive row breaks that invariant — nothing re-probes it, so it can be hours or days
39
+ * old. Routing on such a reading is worse than routing on none: the unranked ring at
40
+ * least rotates, while a stale ranking sends every first attempt to an account that may
41
+ * have been spent since. The bound is longer than the probe TTL (an hour-old reading of
42
+ * a five-hour window is still informative) and far shorter than the six-hour disk
43
+ * horizon, which exists to preserve a value for DISPLAY — where the age is shown to the
44
+ * user and no automatic decision rides on it.
45
+ */
46
+ const PASSIVE_HEADROOM_MAX_AGE_MS = 60 * 60_000;
47
+
30
48
  /**
31
49
  * Remaining headroom across every window the provider reports.
32
50
  *
@@ -36,6 +54,9 @@ interface Ranked {
36
54
  function headroomOf(provider: string, accountId: string): number | null {
37
55
  const quota = getCachedProviderAccountQuota(provider, accountId);
38
56
  if (!quota) return null;
57
+ // Null, not a low rank: this must reproduce "no evidence" so a stale roster degrades to
58
+ // the unranked ring rather than to a differently wrong answer.
59
+ if (hasPassiveAccountQuota(provider) && Date.now() - quota.updatedAt > PASSIVE_HEADROOM_MAX_AGE_MS) return null;
39
60
  const percents = [
40
61
  quota.fiveHourPercent,
41
62
  quota.weeklyPercent,
@@ -56,6 +77,12 @@ export function rankAccountsByHeadroom(provider: string, ring: readonly string[]
56
77
  if (ring.length < 2) return [...ring];
57
78
 
58
79
  let sawEvidence = false;
80
+ // Same rule as hasHeadroomEvidence: a passive provider's partial roster must not rank
81
+ // at all. The failover path calls this directly (selectFailoverAccount), so the guard
82
+ // cannot live only in the pre-dispatch predicate.
83
+ if (hasPassiveAccountQuota(provider) && !ring.every(id => headroomOf(provider, id) !== null)) {
84
+ return [...ring];
85
+ }
59
86
  const ranked: Ranked[] = ring.map((id, index) => {
60
87
  // A provider-declared exhaustion verdict outranks the percentage: an account may sit at
61
88
  // 100% and still be servable when overage is enabled, and the verdict knows that.
@@ -84,6 +111,18 @@ export function rankAccountsByHeadroom(provider: string, ring: readonly string[]
84
111
  * can decline to act on a roster it knows nothing about.
85
112
  */
86
113
  export function hasHeadroomEvidence(provider: string, ids: readonly string[]): boolean {
114
+ // A PASSIVE provider needs evidence for EVERY candidate, not any one of them.
115
+ //
116
+ // A probe fills the whole roster in one pass (fetchProviderAccountQuotas), so "any"
117
+ // and "every" coincide there. An observation arrives one account at a time, so the
118
+ // normal passive state is "one measured, N unknown" -- and RANK_UNKNOWN (1) sorts
119
+ // AFTER RANK_HEALTHY (0), including behind a measured row sitting at 100% with zero
120
+ // headroom. Accepting partial evidence would therefore redirect the first attempt
121
+ // AWAY from an unmeasured account and TOWARD the one account known to be spent, which
122
+ // is the exact inversion of what ranking is for.
123
+ if (hasPassiveAccountQuota(provider)) {
124
+ return ids.length > 0 && ids.every(id => headroomOf(provider, id) !== null);
125
+ }
87
126
  return ids.some(id =>
88
127
  headroomOf(provider, id) !== null
89
128
  || (provider === "kiro" && getKiroAccountExhaustion(`${provider}\u0000${id}`) !== null));
@@ -0,0 +1,187 @@
1
+ import type { OAuthController, OAuthCredentials } from "./types";
2
+ import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt";
3
+
4
+ /**
5
+ * OpenAI deviceauth (device-code) grant for the ChatGPT/Codex provider.
6
+ *
7
+ * The callback flow in `./chatgpt` needs a browser and a listener on
8
+ * localhost:1455. A hub running headless in a container or over SSH has
9
+ * neither, which left "copy the long redirect URL out of the browser error
10
+ * page" as the only way to add an account there (#3366).
11
+ *
12
+ * This is the same grant Codex CLI uses. Three steps, and the middle one is
13
+ * where it differs from RFC 8628: the poll returns an authorization code plus
14
+ * a SERVER-generated PKCE verifier, which is then spent at the ordinary token
15
+ * endpoint. We never generate the verifier ourselves here.
16
+ */
17
+ const USERCODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
18
+ const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
19
+ const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
20
+
21
+ /** Where the user types the short code. Fixed, and safe to show anywhere. */
22
+ export const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
23
+
24
+ /** The grant's own lifetime. Polling past this only produces a worse error message. */
25
+ const DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
26
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
27
+ const MIN_POLL_INTERVAL_MS = 1_000;
28
+ /**
29
+ * Above ~2^31 ms a timer overflows and fires immediately, which would turn a
30
+ * hostile or corrupt `interval` into a hot loop against an auth endpoint. The
31
+ * grant only lives 15 minutes, so anything longer is meaningless anyway.
32
+ */
33
+ const MAX_POLL_INTERVAL_MS = DEVICE_FLOW_TTL_MS;
34
+
35
+ /**
36
+ * Upstream sends `interval` as a number in some responses and a string in
37
+ * others. A string would make `setTimeout` treat it as 0 and turn the poll
38
+ * into a hot loop against an auth endpoint, so coerce and floor it.
39
+ */
40
+ function normalizeIntervalMs(raw: unknown): number {
41
+ const seconds = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
42
+ if (!Number.isFinite(seconds) || seconds <= 0) return DEFAULT_POLL_INTERVAL_MS;
43
+ const ms = Math.round(seconds * 1000);
44
+ return Math.min(MAX_POLL_INTERVAL_MS, Math.max(MIN_POLL_INTERVAL_MS, ms));
45
+ }
46
+
47
+ function nonEmptyString(value: unknown): string | undefined {
48
+ return typeof value === "string" && value.length > 0 ? value : undefined;
49
+ }
50
+
51
+ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
52
+ if (signal?.aborted) throw new Error("Login cancelled");
53
+ await new Promise<void>((resolve, reject) => {
54
+ const timer = setTimeout(() => {
55
+ signal?.removeEventListener("abort", onAbort);
56
+ resolve();
57
+ }, ms);
58
+ const onAbort = (): void => {
59
+ clearTimeout(timer);
60
+ reject(new Error("Login cancelled"));
61
+ };
62
+ signal?.addEventListener("abort", onAbort, { once: true });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Device-flow errors carry the HTTP status and nothing else.
68
+ *
69
+ * The callback flow's `safeErrorDescription` reflects the upstream body into
70
+ * the message, which is fine for an OAuth error envelope but not here: these
71
+ * endpoints can echo request material, and this message reaches CLI output,
72
+ * the GUI, and issue reports.
73
+ */
74
+ function deviceError(stage: string, status: number): Error {
75
+ return new Error(`ChatGPT device authorization ${stage} failed: HTTP ${status}`);
76
+ }
77
+
78
+ interface DeviceUserCode {
79
+ deviceAuthId: string;
80
+ userCode: string;
81
+ intervalMs: number;
82
+ }
83
+
84
+ async function requestUserCode(signal?: AbortSignal): Promise<DeviceUserCode> {
85
+ const response = await fetch(USERCODE_URL, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }),
89
+ signal,
90
+ });
91
+ if (!response.ok) throw deviceError("request", response.status);
92
+ const payload = (await response.json()) as Record<string, unknown>;
93
+ const deviceAuthId = nonEmptyString(payload.device_auth_id);
94
+ // Upstream accepts both spellings, so a response using the alias must not be
95
+ // rejected as malformed.
96
+ const userCode = nonEmptyString(payload.user_code) ?? nonEmptyString(payload.usercode);
97
+ if (!deviceAuthId || !userCode) {
98
+ throw new Error("ChatGPT device authorization response missing required fields");
99
+ }
100
+ return { deviceAuthId, userCode, intervalMs: normalizeIntervalMs(payload.interval) };
101
+ }
102
+
103
+ interface DeviceGrant {
104
+ authorizationCode: string;
105
+ codeVerifier: string;
106
+ }
107
+
108
+ /**
109
+ * Poll until the user finishes at the verification page.
110
+ *
111
+ * Pending is signalled by 403/404 rather than an `authorization_pending` body,
112
+ * so status is the whole protocol here: any other non-2xx is terminal, and
113
+ * treating it as pending would keep hammering a permanently failing endpoint.
114
+ */
115
+ async function pollForGrant(
116
+ device: DeviceUserCode,
117
+ signal?: AbortSignal,
118
+ ): Promise<DeviceGrant> {
119
+ const deadline = Date.now() + DEVICE_FLOW_TTL_MS;
120
+ while (Date.now() < deadline) {
121
+ if (signal?.aborted) throw new Error("Login cancelled");
122
+ const response = await fetch(DEVICE_TOKEN_URL, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }),
126
+ signal,
127
+ });
128
+ if (response.status === 403 || response.status === 404) {
129
+ // Cap the wait at the time actually left. Sleeping a full interval past
130
+ // the deadline is how a 15-minute grant turns into a 20-minute wait.
131
+ const remaining = deadline - Date.now();
132
+ if (remaining <= 0) break;
133
+ await sleep(Math.min(device.intervalMs, remaining), signal);
134
+ continue;
135
+ }
136
+ if (!response.ok) throw deviceError("poll", response.status);
137
+ // The deadline is checked again here, not only at the top of the loop: a
138
+ // single poll can itself outlive the grant, and accepting a code that
139
+ // expired mid-flight just moves the failure to the token exchange.
140
+ if (Date.now() >= deadline) break;
141
+ const payload = (await response.json()) as Record<string, unknown>;
142
+ const authorizationCode = nonEmptyString(payload.authorization_code);
143
+ const codeVerifier = nonEmptyString(payload.code_verifier);
144
+ if (!authorizationCode || !codeVerifier) {
145
+ throw new Error("ChatGPT device authorization response missing required fields");
146
+ }
147
+ return { authorizationCode, codeVerifier };
148
+ }
149
+ throw new Error("ChatGPT device authorization expired");
150
+ }
151
+
152
+ async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise<OAuthCredentials> {
153
+ const response = await fetch(CHATGPT_TOKEN_URL, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
156
+ body: new URLSearchParams({
157
+ grant_type: "authorization_code",
158
+ client_id: CHATGPT_CLIENT_ID,
159
+ code: grant.authorizationCode,
160
+ code_verifier: grant.codeVerifier,
161
+ redirect_uri: DEVICE_REDIRECT_URI,
162
+ }).toString(),
163
+ signal,
164
+ });
165
+ if (!response.ok) throw deviceError("token exchange", response.status);
166
+ return credsFromToken((await response.json()) as Record<string, unknown>);
167
+ }
168
+
169
+ /**
170
+ * Run the device flow to completion.
171
+ *
172
+ * `deviceCode` in the `onAuth` payload is the HUMAN code, matching kimi, nous,
173
+ * and github-copilot. The opaque `device_auth_id` never leaves this module:
174
+ * every device-code surface renders `deviceCode` verbatim, and the management
175
+ * login route also uses its presence to decide a flow must not be handed to a
176
+ * local browser spawn.
177
+ */
178
+ export async function loginChatGPTDevice(ctrl: OAuthController): Promise<OAuthCredentials> {
179
+ const device = await requestUserCode(ctrl.signal);
180
+ ctrl.onAuth?.({
181
+ url: DEVICE_VERIFICATION_URL,
182
+ instructions: `Enter code: ${device.userCode}`,
183
+ deviceCode: device.userCode,
184
+ });
185
+ const grant = await pollForGrant(device, ctrl.signal);
186
+ return exchangeGrant(grant, ctrl.signal);
187
+ }
@@ -5,6 +5,10 @@ import { generatePKCE } from "./pkce";
5
5
  const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
6
6
  const AUTH_URL = "https://auth.openai.com/oauth/authorize";
7
7
  const TOKEN_URL = "https://auth.openai.com/oauth/token";
8
+
9
+ /** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
10
+ export const CHATGPT_CLIENT_ID = CLIENT_ID;
11
+ export const CHATGPT_TOKEN_URL = TOKEN_URL;
8
12
  const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
9
13
  const CALLBACK_PORT = 1455;
10
14
  const CALLBACK_PATH = "/auth/callback";
@@ -46,9 +50,17 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
46
50
  return undefined;
47
51
  }
48
52
 
49
- function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
53
+ export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
50
54
  const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
51
- const accessToken = data.access_token as string;
55
+ // This parses a response from an external boundary, so the access token is
56
+ // validated rather than cast. A 200 carrying no access_token would otherwise
57
+ // resolve a login as successful with an undefined credential, which then gets
58
+ // silently declined at persistence — a success message and no account.
59
+ const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
60
+ ? data.access_token
61
+ : undefined;
62
+ if (!accessToken) throw new Error("ChatGPT token response missing access token");
63
+ const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
52
64
  // ?? only guards null/undefined; NaN or a string expires_in would otherwise
53
65
  // produce a NaN expiry that never compares as expired, and a negative duration
54
66
  // would stamp an already-past expiry — both block refresh semantics.
@@ -62,7 +74,7 @@ function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
62
74
  const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
63
75
  return {
64
76
  access: accessToken,
65
- refresh: (data.refresh_token as string) ?? "",
77
+ refresh: refreshToken,
66
78
  expires,
67
79
  accountId: extractAccountId(idToken, accessToken),
68
80
  email: extractEmail(idToken, accessToken),
@@ -135,7 +147,22 @@ function safeErrorDescription(resp: Response): Promise<string> {
135
147
  });
136
148
  }
137
149
 
138
- export async function loginChatGPT(ctrl: OAuthController, opts?: { forceLogin?: boolean }): Promise<OAuthCredentials> {
150
+ /**
151
+ * How the user proves identity. `browser` runs the localhost:1455 callback flow;
152
+ * `device` runs the deviceauth grant, which needs no local browser or listener
153
+ * and is the only workable path on a headless or remote hub (#3366).
154
+ */
155
+ export type ChatGPTLoginFlow = "browser" | "device";
156
+
157
+ export async function loginChatGPT(
158
+ ctrl: OAuthController,
159
+ opts?: { forceLogin?: boolean; flow?: ChatGPTLoginFlow },
160
+ ): Promise<OAuthCredentials> {
161
+ if (opts?.flow === "device") {
162
+ // Imported lazily so the callback flow does not pay for a module it never uses.
163
+ const { loginChatGPTDevice } = await import("./chatgpt-device");
164
+ return loginChatGPTDevice(ctrl);
165
+ }
139
166
  const flow = new ChatGPTOAuthFlow(ctrl);
140
167
  if (opts?.forceLogin) flow.forceLogin = true;
141
168
  return flow.login();
@@ -34,7 +34,7 @@ import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenReques
34
34
  import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
35
35
  import { loginKimi, refreshKimiToken } from "./kimi";
36
36
  import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous";
37
- import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
37
+ import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
38
38
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
39
39
  import { loginCursor, refreshCursorToken } from "./cursor";
40
40
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
@@ -161,7 +161,17 @@ function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${
161
161
  function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
162
162
  export function sweepExpiredXaiPermanentFailureVerdicts(now=Date.now()):number{let removed=0;for(const[key,until]of permanentRefreshFailures){if(until>now)continue;permanentRefreshFailures.delete(key);removed+=1;}return removed;}
163
163
 
164
- export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string }
164
+ export interface LoginOpts {
165
+ forceLogin?: boolean;
166
+ /** When set, persist into this account slot and require matching identity. */
167
+ reauthAccountId?: string;
168
+ /**
169
+ * ChatGPT only: `device` selects the deviceauth grant instead of the
170
+ * localhost:1455 callback flow, for hosts with no browser or no loopback
171
+ * listener (#3366). Ignored by every other provider.
172
+ */
173
+ flow?: ChatGPTLoginFlow;
174
+ }
165
175
 
166
176
  export interface LoginFlowLifecycle {
167
177
  /** Runs after background credential/config persistence settles, before status becomes done. */
@@ -282,7 +292,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
282
292
  defaultRefreshPolicy: "lazy-only",
283
293
  },
284
294
  chatgpt: {
285
- login: loginChatGPT,
295
+ login: (ctrl, opts) => loginChatGPT(ctrl, { forceLogin: opts?.forceLogin, flow: opts?.flow }),
286
296
  refresh: (rt) => refreshChatGPTToken(rt),
287
297
  providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const },
288
298
  defaultModel: "gpt-5.4",
package/src/oauth/log.ts CHANGED
@@ -23,6 +23,9 @@ const FORBIDDEN_NORMALIZED = new Set([
23
23
  "oauth_code",
24
24
  "code_verifier",
25
25
  "clientsecret",
26
+ // Device-flow polling handle. Not a token, but it is the bearer of an
27
+ // in-flight authorization and must not be logged.
28
+ "device_auth_id",
26
29
  ]);
27
30
 
28
31
  function isForbiddenFieldKey(key: string): boolean {