@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -42,10 +42,39 @@ export class RuntimeApiError extends Error {
42
42
  }
43
43
  }
44
44
 
45
+ /**
46
+ * Refusal for a management request that resolved to a connected-client machine listener.
47
+ *
48
+ * A connected client runs `src/client/machine-listener.ts`, which binds the SAME address the
49
+ * standalone proxy would (`port ?? config.port ?? 10100`) and answers `/healthz` as opencodex.
50
+ * Liveness therefore finds it — correctly, it is our process — but it serves only
51
+ * `/api/machine/*` and returns a JSON 404 for every other `/api/*` path. Before this refusal
52
+ * every management-backed subcommand on such a machine died on that opaque 404:
53
+ * `{"error":"not_found","method":"PUT","path":"/api/custom-models/<id>"}`, one character away
54
+ * from the real handler's unknown-id `{"error":"not found"}` and indistinguishable from it
55
+ * (#4662). The role was already on the wire; only the parser was throwing it away.
56
+ *
57
+ * 503 rather than 404: the management plane is unavailable here, the resource is not missing,
58
+ * and `runCliAction` maps 404 to exit 4 ("no such thing") — the wrong answer to give a script.
59
+ */
60
+ function clientRoleManagementRefusal(port: number): string {
61
+ return [
62
+ `The opencodex listener on port ${port} is running in the client role. It serves only the machine routes (/api/machine/*), so this machine has no management API to call.`,
63
+ "Custom-model edits and other management changes are made on the hub this machine is connected to: run the command there, or use the hub dashboard (the dashboard on this machine relays to it when the connection uses the relay transport).",
64
+ "To change this machine's own configuration instead: edit customModels in config.json, then run: ocx sync",
65
+ ].join("\n");
66
+ }
67
+
45
68
  export async function runtimeBaseUrl(deps: RuntimeApiDeps = {}): Promise<string> {
46
69
  if (deps.baseUrl) return deps.baseUrl.replace(/\/$/, "");
47
70
  const live = await (deps.findLiveProxy ?? findLiveProxy)();
48
71
  if (!live) throw new RuntimeApiError("Proxy is not running. Start it with: ocx start", 503, null);
72
+ // The role comes from the same identity-checked /healthz body liveness already parsed, so
73
+ // this costs no extra request. Only the client role is refused: an absent role is a
74
+ // standalone or hub proxy (or a legacy body that predates the field), and both serve /api/*.
75
+ if (live.role === "client") {
76
+ throw new RuntimeApiError(clientRoleManagementRefusal(live.port), 503, null);
77
+ }
49
78
  return `http://${probeHostname(live.hostname)}:${live.port}`;
50
79
  }
51
80
 
@@ -62,11 +91,26 @@ function stringField(record: Record<string, unknown>, key: string): string | und
62
91
  * is unavailable). Both were dropped here, so a fenced management plane was
63
92
  * indistinguishable from a generic failure and an operator had no way to tell a port
64
93
  * collision from an ACL refusal from a stopped proxy (#2698).
94
+ *
95
+ * A 404 body that carries both `method` and `path` is a different statement again: some
96
+ * opencodex listener answered, and it does not route that request at all. Only the
97
+ * connected-client machine listener emits that shape today (`json404` in
98
+ * src/client/machine-listener.ts), and printing its bare `not_found` token read as though the
99
+ * resource were missing — a real handler's unknown-id 404 says `not found`, one space apart
100
+ * (#4662). Name the route instead, so any listener that does not serve a path stays legible
101
+ * even if another one starts answering this way.
65
102
  */
66
103
  function responseMessage(body: unknown, status: number): string {
67
104
  if (typeof body === "string" && body.trim()) return body.trim().slice(0, 400);
68
105
  if (!body || typeof body !== "object") return `Management request failed (${status})`;
69
106
  const record = body as Record<string, unknown>;
107
+ if (status === 404) {
108
+ const method = stringField(record, "method");
109
+ const path = stringField(record, "path");
110
+ if (method && path) {
111
+ return `This opencodex listener does not serve ${method.slice(0, 16)} ${path.slice(0, 200)}, so the request was refused before any handler ran (it is not a missing record). Check that the command is pointed at a proxy that serves the management API.`;
112
+ }
113
+ }
70
114
  let primary: string | undefined;
71
115
  for (const key of ["error", "message", "detail"]) {
72
116
  primary = stringField(record, key);
@@ -0,0 +1,94 @@
1
+ export const START_USAGE = "Usage: ocx start [--port <port>] [--socks5 [host:port] | --socks5-off]";
2
+ export const START_USAGE_LINE = "ocx start [--port <port>] [--socks5 [host:port] | --socks5-off]";
3
+ export const DEFAULT_SOCKS5_PROXY = "socks5://127.0.0.1:10808";
4
+
5
+ export class StartArgsError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = "StartArgsError";
9
+ }
10
+ }
11
+
12
+ export type StartOptions = {
13
+ port?: number;
14
+ /** SOCKS5 URL to persist. */
15
+ socks5?: string;
16
+ socks5Off?: boolean;
17
+ };
18
+
19
+ export function isSocksProxyUrl(proxy: string): boolean {
20
+ return /^socks5h?:\/\//i.test(proxy.trim());
21
+ }
22
+
23
+ export function normalizeSocks5(raw: string): string {
24
+ const trimmed = raw.trim();
25
+ if (!trimmed) return DEFAULT_SOCKS5_PROXY;
26
+ if (isSocksProxyUrl(trimmed)) {
27
+ try {
28
+ const parsed = new URL(trimmed);
29
+ const port = Number(parsed.port);
30
+ if (!parsed.hostname || !parsed.port || !Number.isInteger(port) || port <= 0 || port > 65535) {
31
+ throw new Error("invalid host or port");
32
+ }
33
+ return trimmed;
34
+ } catch {
35
+ throw new StartArgsError("Invalid SOCKS5 address; expected socks5://host:port");
36
+ }
37
+ }
38
+ if (/^socks4a?:\/\//i.test(trimmed)) {
39
+ throw new StartArgsError("Only SOCKS5 proxy URLs are supported");
40
+ }
41
+ if (/^https?:\/\//i.test(trimmed)) {
42
+ throw new StartArgsError("SOCKS5 proxy must be socks5://host:port, not an HTTP URL");
43
+ }
44
+ if (/^\d+$/.test(trimmed)) {
45
+ const port = Number(trimmed);
46
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
47
+ throw new StartArgsError("Invalid SOCKS5 port number");
48
+ }
49
+ return `socks5://127.0.0.1:${port}`;
50
+ }
51
+ const hostPort = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(trimmed);
52
+ if (hostPort) {
53
+ const port = Number(hostPort[2]);
54
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
55
+ throw new StartArgsError("Invalid SOCKS5 port number");
56
+ }
57
+ return `socks5://${trimmed}`;
58
+ }
59
+ throw new StartArgsError("Invalid SOCKS5 address; expected socks5://host:port");
60
+ }
61
+
62
+ export function parseStartOptions(argv: string[]): StartOptions {
63
+ const options: StartOptions = {};
64
+ for (let i = 0; i < argv.length; i++) {
65
+ const arg = argv[i]!;
66
+ if (arg === "--port") {
67
+ const value = argv[++i];
68
+ const port = value && /^\d+$/.test(value) ? Number(value) : NaN;
69
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
70
+ throw new StartArgsError("Invalid port number");
71
+ }
72
+ options.port = port;
73
+ continue;
74
+ }
75
+ if (arg === "--socks5") {
76
+ if (options.socks5Off) throw new StartArgsError("--socks5 and --socks5-off cannot be used together");
77
+ const next = argv[i + 1];
78
+ if (next && !next.startsWith("-")) {
79
+ options.socks5 = normalizeSocks5(next);
80
+ i += 1;
81
+ } else {
82
+ options.socks5 = DEFAULT_SOCKS5_PROXY;
83
+ }
84
+ continue;
85
+ }
86
+ if (arg === "--socks5-off") {
87
+ if (options.socks5 !== undefined) throw new StartArgsError("--socks5 and --socks5-off cannot be used together");
88
+ options.socks5Off = true;
89
+ continue;
90
+ }
91
+ throw new StartArgsError(START_USAGE);
92
+ }
93
+ return options;
94
+ }
@@ -21,6 +21,8 @@ const USAGE = `Usage:
21
21
  ocx system codex-app-server [--json]
22
22
  ocx system codex-restart --yes [--json]
23
23
  ocx system codex-cli-update check [--json]
24
+ ocx system codex-cli-update attest [--json]
25
+ ocx system codex-cli-update attest --candidate <absolute-path> --npm-prefix <absolute-path> --npm-cli <absolute-path> --node <absolute-path> [--json]
24
26
  ocx system update check [--channel <latest|preview>] [--json]
25
27
  ocx system update run [--channel <latest|preview>] [--restart <on|off>] --yes [--json]
26
28
  ocx system update status <job-id> [--json]
@@ -42,6 +44,72 @@ async function status(argv: string[], deps: RuntimeApiDeps): Promise<void> {
42
44
  printData(result, wantsJson, summaryLines(result));
43
45
  }
44
46
 
47
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
48
+ return value !== null && typeof value === "object" ? value as Record<string, unknown> : undefined;
49
+ }
50
+
51
+ function desktopSwitchInertReason(reason: unknown): string {
52
+ if (reason === "client_role") return "this proxy is running in the client role";
53
+ if (reason === "non_loopback_bind_requires_admission_token") {
54
+ return "a non-loopback bind requires an admission token, so this flag is inert";
55
+ }
56
+ return "the stored setting is not effective in the current runtime configuration";
57
+ }
58
+
59
+ function desktopSwitchApplyReason(reason: unknown): string {
60
+ if (reason === "not_requested") return "no desktop switch rewrite was requested";
61
+ if (reason === "proxy_not_running") return "the proxy is not running";
62
+ if (reason === "integration_disabled") return "Codex integration is disabled";
63
+ if (reason === "write_lock_busy") return "the Codex config write lock is busy";
64
+ if (reason === "injection_refused") return "Codex config injection was refused";
65
+ return "the rewrite could not be completed";
66
+ }
67
+
68
+ function settingsUpdateLines(
69
+ result: unknown,
70
+ changed: { desktopAuthless: boolean; clientCompaction: boolean },
71
+ ): string[] {
72
+ if (!changed.desktopAuthless && !changed.clientCompaction) return ["System settings updated."];
73
+ const switches = recordValue(recordValue(result)?.codexDesktopSwitches);
74
+ if (!switches) return ["System settings updated."];
75
+
76
+ const lines: string[] = [];
77
+ const appendSwitch = (key: string, label: string): boolean => {
78
+ const state = recordValue(switches[key]);
79
+ if (!state || typeof state.stored !== "boolean" || typeof state.effective !== "boolean") return false;
80
+ lines.push(`${label}: stored ${state.stored ? "on" : "off"}.`);
81
+ // The effective value is always stated, even when it matches. Printing it only on a
82
+ // mismatch would make silence ambiguous — the reader could not tell "the stored value is
83
+ // in force" from "this build does not report effective state", and that ambiguity is a
84
+ // smaller version of the defect being fixed.
85
+ lines.push(state.effective === state.stored
86
+ ? `${label}: effective ${state.effective ? "on" : "off"}.`
87
+ : `${label}: effective ${state.effective ? "on" : "off"} because ${desktopSwitchInertReason(state.inertReason)}.`);
88
+ return true;
89
+ };
90
+
91
+ if (changed.desktopAuthless && !appendSwitch("codexDesktopAuthless", "Codex desktop authless")) {
92
+ return ["System settings updated."];
93
+ }
94
+ if (changed.clientCompaction && !appendSwitch("codexClientCompaction", "Codex client compaction")) {
95
+ return ["System settings updated."];
96
+ }
97
+
98
+ const apply = recordValue(switches.apply);
99
+ const authSource = recordValue(switches.authSource);
100
+ if (!apply || typeof apply.applied !== "boolean" || !authSource || typeof authSource.summary !== "string") {
101
+ return ["System settings updated."];
102
+ }
103
+ if (apply.applied) {
104
+ lines.push("Codex config: ~/.codex/config.toml was rewritten.");
105
+ } else {
106
+ const detail = typeof apply.detail === "string" && apply.detail.length > 0 ? ` Details: ${apply.detail}` : "";
107
+ lines.push(`Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail} Run 'ocx sync' to apply the stored settings.`);
108
+ }
109
+ lines.push(`Auth source: ${authSource.summary}`);
110
+ return lines;
111
+ }
112
+
45
113
  async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
46
114
  const args = [...argv];
47
115
  const wantsJson = takeFlag(args, "--json");
@@ -63,7 +131,10 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
63
131
  ...(clientCompaction !== undefined ? { codexClientCompaction: clientCompaction } : {}),
64
132
  };
65
133
  const result = await runtimeRequest("/api/settings", { method: "PUT", body: JSON.stringify(body) }, deps);
66
- printData(result, wantsJson, ["System settings updated."]);
134
+ printData(result, wantsJson, settingsUpdateLines(result, {
135
+ desktopAuthless: desktopAuthless !== undefined,
136
+ clientCompaction: clientCompaction !== undefined,
137
+ }));
67
138
  }
68
139
 
69
140
  async function startup(argv: string[], deps: RuntimeApiDeps): Promise<void> {
@@ -4,6 +4,7 @@ import { readClientConnectionState, sameClientConnectionOwner } from "../client/
4
4
  import { assertClientLifecycleHeld, withClientLifecycle } from "../client/lifecycle-lock";
5
5
  import { inspectRemoteDesktopCleanup, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store";
6
6
  import { removeOwnedConfigState, type ConfigRemovalResult } from "../lib/config-ownership";
7
+ import { windowsSecretAclReapPendingAtOrBelow } from "../lib/windows-secret-acl";
7
8
  import { sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
8
9
 
9
10
  export interface UninstallClientStateDeps {
@@ -13,6 +14,8 @@ export interface UninstallClientStateDeps {
13
14
  disconnect: (options?: Parameters<typeof disconnectClient>[0]) => Promise<unknown>;
14
15
  withLifecycle: typeof withClientLifecycle;
15
16
  remove: () => ConfigRemovalResult;
17
+ /** True while a timed-out icacls child still owns a path at or below the config directory. */
18
+ aclReapPending: (rootPath: string) => boolean;
16
19
  }
17
20
 
18
21
  const defaults: UninstallClientStateDeps = {
@@ -22,6 +25,7 @@ const defaults: UninstallClientStateDeps = {
22
25
  disconnect: options => disconnectClient(options),
23
26
  withLifecycle: withClientLifecycle,
24
27
  remove: () => removeOwnedConfigState(getConfigDir()),
28
+ aclReapPending: rootPath => windowsSecretAclReapPendingAtOrBelow(rootPath),
25
29
  };
26
30
 
27
31
  /** Restore connection-owned client artifacts before removing their ownership/recovery records. */
@@ -73,6 +77,14 @@ export async function removeOwnedConfigAfterDesktopCleanup(
73
77
  || (latestReceipt.kind === "valid" && latestReceipt.value.phase !== "complete")) {
74
78
  throw new Error("Client cleanup refused: connection or Desktop state changed before removal.");
75
79
  }
80
+ // The async ACL belt releases its caller on a stalled `icacls.exe`, which is what keeps
81
+ // startup and shutdown bounded. It is not evidence that the child released the directory,
82
+ // and on Windows a live handle makes this removal fail partway instead of cleanly. Refuse
83
+ // promptly and let the operator retry: waiting here would hand a stuck child the power to
84
+ // hang `ocx uninstall`, which is the bound the belt exists to preserve.
85
+ if (deps.aclReapPending(getConfigDir())) {
86
+ throw new Error("Client cleanup refused: ACL hardening still owns a path under the config directory.");
87
+ }
76
88
  return deps.remove();
77
89
  });
78
90
  }
@@ -74,10 +74,11 @@ export async function handleMachineApi(
74
74
  injected: MachineApiDeps = defaultDeps,
75
75
  ): Promise<Response | null> {
76
76
  const deps = { ...defaultDeps, ...injected };
77
- if (url.pathname === "/api/machine/status" && req.method === "GET") {
77
+ const readMethod = req.method === "GET" || req.method === "HEAD";
78
+ if (url.pathname === "/api/machine/status" && readMethod) {
78
79
  return Response.json(statusPayload(req, state, deps), { headers: { "Cache-Control": "no-store" } });
79
80
  }
80
- if (url.pathname === "/api/machine/clients" && req.method === "GET") {
81
+ if (url.pathname === "/api/machine/clients" && readMethod) {
81
82
  return Response.json({
82
83
  selectedClients: [...state.selectedClients],
83
84
  journalOwner: journalOwner(),
@@ -103,7 +104,7 @@ export async function handleMachineApi(
103
104
  return Response.json({ success: false, error: message }, { status: 502 });
104
105
  }
105
106
  }
106
- if (url.pathname === "/api/machine/shim" && req.method === "GET") {
107
+ if (url.pathname === "/api/machine/shim" && readMethod) {
107
108
  return Response.json(diagnoseCodexShim(), { headers: { "Cache-Control": "no-store" } });
108
109
  }
109
110
  if (url.pathname === "/api/machine/shim" && req.method === "POST") {
@@ -46,9 +46,14 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea
46
46
  if (req.headers.get("upgrade")) return false;
47
47
  const path = url.pathname;
48
48
  if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true;
49
- if (req.method === "GET" && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
49
+ if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
50
50
  if (req.method === "POST" && (path === "/api/machine/sync" || path === "/api/machine/shim" || path === "/api/machine/disconnect")) return true;
51
51
  if (relayEnabled && path.startsWith("/api/machine/hub-relay/")) return true;
52
+ // Known machine endpoints are admitted for every method so an unsupported
53
+ // method reaches the authenticated method restriction (403) instead of a
54
+ // bare 404 that hides the endpoint entirely.
55
+ if (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim"
56
+ || path === "/api/machine/sync" || path === "/api/machine/disconnect") return true;
52
57
  if (req.method !== "GET" || path.startsWith("/api/") || path.startsWith("/v1/")) return false;
53
58
  return GUI_SPA_PATHS.has(path)
54
59
  || path.startsWith("/integrations/")
@@ -111,6 +116,14 @@ export function startMachineListener(
111
116
  if (managementPrincipal(req, managementAuth, config) !== "gui-session") {
112
117
  return Response.json({ error: "opencodex machine GUI session required" }, { status: 401 });
113
118
  }
119
+ // A loopback dashboard session proves possession, not user presence: any local
120
+ // process can fetch the dashboard bootstrap and replay its token and CSRF value.
121
+ // Keep the connected listener useful for status/diagnostics, but never let that
122
+ // credentialless bootstrap authorize durable machine changes. Those operations
123
+ // remain available through the explicit CLI commands.
124
+ if (req.method !== "GET" && req.method !== "HEAD") {
125
+ return Response.json({ error: "opencodex machine changes require the local CLI" }, { status: 403 });
126
+ }
114
127
  return await handleMachineApi(req, url, connection, machineApiDeps) ?? json404(req);
115
128
  }
116
129
 
@@ -37,9 +37,8 @@ export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`;
37
37
  export const LOOPBACK_API_KEY_PLACEHOLDER = "opencodex-loopback";
38
38
 
39
39
  /**
40
- * Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey`
41
- * falls back to treating the literal text as the token when the variable is
42
- * unset, which would silently ship a bogus credential — so we never emit it.
40
+ * Legacy name retained for existing consumers. Gajae exports now use the
41
+ * non-secret loopback placeholder and do not emit an environment reference.
43
42
  */
44
43
  export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY";
45
44
 
@@ -41,7 +41,7 @@ export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProv
41
41
  export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
42
42
 
43
43
  import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts";
44
- import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants";
44
+ import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants";
45
45
  import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata";
46
46
  import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp";
47
47
  import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh";
@@ -867,7 +867,7 @@ export interface GajaeModelEntry {
867
867
  /** Gajae validates strictly: an unknown field fails the whole config. */
868
868
  export interface GajaeProviderBlock {
869
869
  baseUrl: string;
870
- apiKeyEnv: string;
870
+ apiKey: string;
871
871
  api: "openai-completions";
872
872
  models: GajaeModelEntry[];
873
873
  }
@@ -1056,7 +1056,7 @@ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig {
1056
1056
  providers: {
1057
1057
  [OPENCODE_PROVIDER_ID]: {
1058
1058
  baseUrl: ctx.baseUrl,
1059
- apiKeyEnv: GAJAE_API_KEY_ENV,
1059
+ apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
1060
1060
  api: "openai-completions",
1061
1061
  models,
1062
1062
  },
@@ -1320,8 +1320,8 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1320
1320
  id: "gajae",
1321
1321
  filename: "gajae-models.yaml",
1322
1322
  destination: env => gajaeConfigPath(env),
1323
- apiKeyEnv: GAJAE_API_KEY_ENV,
1324
- exportHint: `export ${GAJAE_API_KEY_ENV}=<your key>`,
1323
+ apiKeyEnv: "",
1324
+ exportHint: "No environment variable is needed for the loopback provider.",
1325
1325
  build: buildGajaeClientConfig,
1326
1326
  format: "yaml",
1327
1327
  summarize: summarizeGajae,
@@ -1,3 +1,4 @@
1
+ import { readOrcaAuthSource } from "./orca-auth-source";
1
2
  import { createHash } from "node:crypto";
2
3
  import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -52,11 +53,20 @@ function isObject(value: unknown): value is Record<string, unknown> {
52
53
  }
53
54
 
54
55
  function isCredential(value: unknown): value is CodexAccountCredentials {
56
+ const hasNoSource = isObject(value)
57
+ && value.sourceAuthPath === undefined
58
+ && value.sourceSubject === undefined;
59
+ const hasCompleteSource = isObject(value)
60
+ && typeof value.sourceAuthPath === "string"
61
+ && value.sourceAuthPath.length > 0
62
+ && typeof value.sourceSubject === "string"
63
+ && value.sourceSubject.length > 0;
55
64
  return isObject(value)
56
65
  && typeof value.accessToken === "string"
57
66
  && typeof value.refreshToken === "string"
58
67
  && typeof value.expiresAt === "number"
59
- && typeof value.chatgptAccountId === "string";
68
+ && typeof value.chatgptAccountId === "string"
69
+ && (hasNoSource || hasCompleteSource);
60
70
  }
61
71
 
62
72
  function isCredentialRecord(value: unknown): value is CodexAccountCredentialRecord {
@@ -78,6 +88,7 @@ export function refreshGrantFingerprintForToken(refreshToken: string): string {
78
88
  }
79
89
 
80
90
  function recordGrantFingerprint(record: CodexAccountCredentialRecord): string | undefined {
91
+ if (record.credential?.sourceAuthPath) return undefined;
81
92
  return record.refreshGrantFingerprint ?? (
82
93
  record.credential ? refreshGrantFingerprintForToken(record.credential.refreshToken) : undefined
83
94
  );
@@ -426,7 +437,7 @@ export function commitRefreshedCodexCredentialWithAliases(
426
437
  return withCredentialMutationLockSync(() => {
427
438
  const store = loadCodexAccountRecordStore();
428
439
  const current = store[id];
429
- if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) {
440
+ if (!current || current.generation !== generation || current.deletedAt != null || !current.credential || current.credential.sourceAuthPath) {
430
441
  return { committed: false, propagatedAliases: [] };
431
442
  }
432
443
  const priorCredential = current.credential;
@@ -458,7 +469,7 @@ export function commitRefreshedCodexCredentialWithAliases(
458
469
  && priorCredential.chatgptAccountId === cred.chatgptAccountId
459
470
  ) {
460
471
  for (const [aliasId, alias] of Object.entries(store)) {
461
- if (aliasId === id || alias.deletedAt != null || !alias.credential) continue;
472
+ if (aliasId === id || alias.deletedAt != null || !alias.credential || alias.credential.sourceAuthPath) continue;
462
473
  if (recordGrantFingerprint(alias) !== priorFingerprint) continue;
463
474
  if (alias.credential.accessToken !== priorCredential.accessToken) continue;
464
475
  if (alias.credential.expiresAt !== priorCredential.expiresAt) continue;
@@ -630,7 +641,14 @@ export type CodexRefreshProvenance = "self-refresh" | "joined-lineage" | "extern
630
641
 
631
642
  /** Terminal outcome of one forced refresh, as seen by the caller that requested it. */
632
643
  export type ForcedRefreshOutcome =
633
- | { kind: "resolved"; provenance: CodexRefreshProvenance; generation: number; rotated: boolean }
644
+ | {
645
+ kind: "resolved";
646
+ provenance: CodexRefreshProvenance;
647
+ generation: number;
648
+ rotated: boolean;
649
+ /** Same-grant records advanced by this refresh, at their committed generations. */
650
+ propagatedAliases?: { id: string; generation: number }[];
651
+ }
634
652
  | { kind: "failed"; error: unknown };
635
653
  const MAX_CODEX_REFRESH_FLIGHTS = 32;
636
654
  const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
@@ -776,7 +794,7 @@ function findFreshCredentialForGrant(
776
794
  // require both ids to be present and exactly equal rather than inferring identity from the grant.
777
795
  if (!expectedChatgptAccountId) return null;
778
796
  for (const [candidateId, candidate] of Object.entries(records)) {
779
- if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential) continue;
797
+ if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential || candidate.credential.sourceAuthPath) continue;
780
798
  if (recordGrantFingerprint(candidate) !== refreshGrantFingerprint) continue;
781
799
  if (!candidate.credential.chatgptAccountId) continue;
782
800
  if (candidate.credential.chatgptAccountId !== expectedChatgptAccountId) continue;
@@ -926,6 +944,9 @@ export async function forceRefreshCodexPoolToken(
926
944
  provenance: classify(resolved),
927
945
  generation: resolved.generation,
928
946
  rotated: resolved.accessToken !== options.rejectedAccessToken,
947
+ ...(resolved.propagatedAliases?.length
948
+ ? { propagatedAliases: resolved.propagatedAliases }
949
+ : {}),
929
950
  });
930
951
  },
931
952
  error => {
@@ -967,6 +988,57 @@ export async function getValidCodexToken(
967
988
  };
968
989
  }
969
990
 
991
+ /** Source credentials never join refresh flights or spend a refresh grant, including after 401. */
992
+ function resolveOrcaSourceToken(id: string, forced?: ForcedRefreshFence): CodexRefreshResult {
993
+ const initial = readCodexAccountRecord(id);
994
+ const initialCredential = initial?.credential;
995
+ if (!initial || initial.deletedAt != null || !initialCredential?.sourceAuthPath || !initialCredential.sourceSubject) {
996
+ throw new CodexCredentialGenerationConflictError();
997
+ }
998
+ const sourceCredential = readOrcaAuthSource(initialCredential.sourceAuthPath);
999
+ if (sourceCredential.chatgptAccountId !== initialCredential.chatgptAccountId
1000
+ || sourceCredential.sourceSubject !== initialCredential.sourceSubject) {
1001
+ throw new Error("Orca credential identity changed; reimport the account explicitly.");
1002
+ }
1003
+ if (sourceCredential.accessToken === initialCredential.accessToken
1004
+ && sourceCredential.expiresAt === initialCredential.expiresAt) {
1005
+ if (forced?.rejectedAccessToken === sourceCredential.accessToken) {
1006
+ throw new Error("Orca bearer was rejected; update the account in Orca and retry.");
1007
+ }
1008
+ return { accessToken: sourceCredential.accessToken, chatgptAccountId: sourceCredential.chatgptAccountId,
1009
+ generation: initial.generation, provenance: "external-replacement" };
1010
+ }
1011
+
1012
+ return withCredentialMutationLockSync(() => {
1013
+ const store = loadCodexAccountRecordStore();
1014
+ const record = store[id];
1015
+ const prior = record?.credential;
1016
+ if (!record || record.deletedAt != null || !prior?.sourceAuthPath || !prior.sourceSubject) {
1017
+ throw new CodexCredentialGenerationConflictError();
1018
+ }
1019
+ if (record.generation !== initial.generation) throw new CodexCredentialGenerationConflictError();
1020
+ const credential = readOrcaAuthSource(prior.sourceAuthPath);
1021
+ if (credential.chatgptAccountId !== prior.chatgptAccountId || credential.sourceSubject !== prior.sourceSubject) {
1022
+ throw new Error("Orca credential identity changed; reimport the account explicitly.");
1023
+ }
1024
+ if (credential.accessToken !== prior.accessToken || credential.expiresAt !== prior.expiresAt) {
1025
+ store[id] = {
1026
+ credential, generation: record.generation + 1, replacedAt: Date.now(),
1027
+ // The source identity was checked above. Retire old-generation writers without
1028
+ // splitting this same account's retained quota history on every source rotation.
1029
+ quotaHistoryIdentity: record.quotaHistoryIdentity,
1030
+ ...preservedValidationMetadata(record),
1031
+ };
1032
+ persistCredentialMutation(store);
1033
+ }
1034
+ if (forced?.rejectedAccessToken === credential.accessToken) {
1035
+ throw new Error("Orca bearer was rejected; update the account in Orca and retry.");
1036
+ }
1037
+ return { accessToken: credential.accessToken, chatgptAccountId: credential.chatgptAccountId,
1038
+ generation: store[id]!.generation, provenance: "external-replacement" };
1039
+ });
1040
+ }
1041
+
970
1042
  async function resolveCodexToken(
971
1043
  id: string,
972
1044
  forced?: ForcedRefreshFence,
@@ -976,6 +1048,7 @@ async function resolveCodexToken(
976
1048
  const record = readCodexAccountRecord(id);
977
1049
  const cred = record?.deletedAt == null ? record?.credential : undefined;
978
1050
  if (!record || !cred) throw new CodexCredentialUnavailableError();
1051
+ if (cred.sourceAuthPath) return resolveOrcaSourceToken(id, forced);
979
1052
  const refreshGrantFingerprint = recordGrantFingerprint(record);
980
1053
  if (!refreshGrantFingerprint) throw new CodexCredentialUnavailableError();
981
1054
 
@@ -1005,6 +1078,7 @@ async function resolveCodexToken(
1005
1078
  const refreshed = await awaitOwnCancellation(existing.promise, callerSignal);
1006
1079
  const current = readCodexAccountRecord(id);
1007
1080
  const currentCred = current?.deletedAt == null ? current?.credential : undefined;
1081
+ if (currentCred?.sourceAuthPath) return resolveOrcaSourceToken(id, forced);
1008
1082
  // The flight owner already committed this credential, and it is the one stored
1009
1083
  // for this account: adopt the stored state instead of CAS-writing the identical
1010
1084
  // bytes, which would bump the generation a second time and invalidate the
@@ -1126,6 +1200,7 @@ async function resolveCodexToken(
1126
1200
  const lockedRecord = readCodexAccountRecord(id);
1127
1201
  const lockedCred = lockedRecord?.deletedAt == null ? lockedRecord?.credential : undefined;
1128
1202
  if (!lockedRecord || !lockedCred) throw new CodexCredentialGenerationConflictError();
1203
+ if (lockedCred.sourceAuthPath) return resolveOrcaSourceToken(id, forced);
1129
1204
  const startGeneration = lockedRecord.generation;
1130
1205
  const lockedRefreshGrantFingerprint = recordGrantFingerprint(lockedRecord);
1131
1206
  if (lockedRefreshGrantFingerprint !== refreshGrantFingerprint) {
@@ -1331,6 +1406,7 @@ async function resolveCodexToken(
1331
1406
  // Provenance rides out with the rest: a joiner that adopts this result needs the
1332
1407
  // flight's own classification, not a guess made at the adoption site (#3019).
1333
1408
  ...(result.provenance !== undefined ? { provenance: result.provenance } : {}),
1409
+ ...(result.propagatedAliases?.length ? { propagatedAliases: result.propagatedAliases } : {}),
1334
1410
  ...(result.resolvedGrantFingerprint !== undefined
1335
1411
  ? { resolvedGrantFingerprint: result.resolvedGrantFingerprint }
1336
1412
  : {}),
@@ -9,7 +9,7 @@ import { codexWarmupFailureReason, warmCodexAccount } from "../warmup";
9
9
  import { readBoundedResponseBody } from "../../lib/bounded-body";
10
10
  import { ResourceAdmissionError } from "../../lib/admission";
11
11
  import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing";
12
- import { claimQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery";
12
+ import { claimQuotaRecovery, fencePropagatedQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery";
13
13
  import { seedLoginRowsForTests } from "./login-state";
14
14
  import { nonEmptyPlan } from "./runtime-config";
15
15
 
@@ -205,6 +205,12 @@ export async function recoverPoolQuotaFrom401(ctx: {
205
205
  onSettled: outcome => {
206
206
  if (outcome.kind === "resolved") {
207
207
  settleQuotaRecovery(accountId, claim.claimId, outcome);
208
+ // A propagated alias adopted the rotated grant without spending its own claim. Fence
209
+ // each committed alias generation so a later 401 on it cannot claim a second refresh
210
+ // of the same underlying grant.
211
+ for (const alias of outcome.propagatedAliases ?? []) {
212
+ fencePropagatedQuotaRecovery(alias.id, alias.generation);
213
+ }
208
214
  } else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) {
209
215
  // A revoked or expired grant does not become valid on the next poll. Releasing it
210
216
  // into backoff would let the following bare 401 find a non-terminal record and
@@ -476,9 +482,14 @@ export async function fetchPoolAccountQuota(
476
482
  && generation !== undefined && record.generation === generation
477
483
  && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) {
478
484
  try {
485
+ // Quota I/O may outlive the source capture. Never validate a rotated or revoked link
486
+ // using an older generation's observation. Explicit validation consent still applies.
487
+ const sourceToken = record.credential.sourceAuthPath ? await getValidToken(accountId) : null;
488
+ if (sourceToken && (sourceToken.generation !== generation
489
+ || !isCodexAccountGenerationLive(accountId, generation))) return result;
479
490
  await warmCodexAccount({
480
- accessToken: record.credential.accessToken,
481
- chatgptAccountId: record.credential.chatgptAccountId,
491
+ accessToken: sourceToken?.accessToken ?? record.credential.accessToken,
492
+ chatgptAccountId: sourceToken?.chatgptAccountId ?? record.credential.chatgptAccountId,
482
493
  });
483
494
  markCodexAccountValidated(accountId, Date.now(), generation);
484
495
  clearAccountNeedsReauth(accountId, generation);
@@ -7,7 +7,7 @@ import { getAccountQuotaHistory, listAccountQuotas } from "../quota";
7
7
  import { deleteCodexAccount } from "../account-lifecycle";
8
8
  import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
9
9
  import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority";
10
- import { codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
10
+ import { codexAccountPinDrainReason, codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
11
11
  import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation";
12
12
  import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
13
13
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
@@ -235,7 +235,22 @@ export async function handleCodexAuthAPI(
235
235
  else setCodexAccountPin(runtimeConfig, targetAccountId);
236
236
  resetCodexRoutingForManualSelection(targetAccountId);
237
237
  saveRuntimeConfig(config, runtimeConfig);
238
- return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
238
+ // A pin this route accepts can still be dropped by the very next resolve, and saying
239
+ // nothing about that is what made the setting look ignored (#4521). The checks above
240
+ // refuse an account that cannot be selected at all; this reports the one remaining
241
+ // outcome they do not cover, from the same predicate routing releases on, so the two
242
+ // cannot drift. Absent means the pin survives — additive for existing clients.
243
+ // `appliesImmediately` is unchanged: it answers whether thread affinity was cleared,
244
+ // not whether the pin is durable.
245
+ const pinDrainReason = body.accountId == null
246
+ ? undefined
247
+ : codexAccountPinDrainReason(runtimeConfig, targetAccountId);
248
+ return jsonResponse({
249
+ ok: true,
250
+ activeCodexAccountId: body.accountId,
251
+ appliesImmediately: true,
252
+ ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}),
253
+ });
239
254
  }
240
255
 
241
256
  if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {