@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925

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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -0,0 +1,166 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync,
4
+ openSync, readSync, unlinkSync, writeFileSync, type Stats,
5
+ } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath } from "../../lib/windows-secret-acl";
8
+
9
+ const TOKEN_FILE = "proxy-token";
10
+ const TOKEN_LENGTH = 43; // 32 random bytes encoded as unpadded base64url.
11
+ const TOKEN_MAX_BYTES = TOKEN_LENGTH + 2; // Optional LF or CRLF, never arbitrary whitespace.
12
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
13
+ const NONBLOCK = constants.O_NONBLOCK ?? 0;
14
+
15
+ export function claudeInterceptProxyTokenPath(configDir: string): string {
16
+ return join(configDir, "claude-intercept", TOKEN_FILE);
17
+ }
18
+
19
+ function invalidCredential(): Error {
20
+ return new Error("Claude intercept credential is not a safe, valid owner-controlled file");
21
+ }
22
+
23
+ function assertOwnedNode(stat: Stats, directory = false): void {
24
+ if (stat.isSymbolicLink() || !(directory ? stat.isDirectory() : stat.isFile())) throw invalidCredential();
25
+ if (process.platform !== "win32" && typeof process.geteuid === "function" && stat.uid !== process.geteuid()) {
26
+ throw invalidCredential();
27
+ }
28
+ }
29
+
30
+ function assertSameNode(path: string, expected: Stats, directory = false): void {
31
+ const current = lstatSync(path);
32
+ assertOwnedNode(current, directory);
33
+ if (current.dev !== expected.dev || current.ino !== expected.ino) throw invalidCredential();
34
+ }
35
+
36
+ /** Read only through the validated descriptor, with a fixed maximum allocation and no links. */
37
+ function readPinnedToken(configDir: string, harden: boolean): string {
38
+ const dir = join(configDir, "claude-intercept");
39
+ const directory = lstatSync(dir);
40
+ assertOwnedNode(directory, true);
41
+ if (process.platform !== "win32" && (directory.mode & 0o077) !== 0) throw invalidCredential();
42
+ const path = claudeInterceptProxyTokenPath(configDir);
43
+ const entry = lstatSync(path);
44
+ assertOwnedNode(entry);
45
+ if (entry.size < TOKEN_LENGTH || entry.size > TOKEN_MAX_BYTES) throw invalidCredential();
46
+ // NONBLOCK prevents a raced-in FIFO from blocking before fstat can reject it.
47
+ const fd = openSync(path, constants.O_RDONLY | NOFOLLOW | NONBLOCK);
48
+ try {
49
+ const opened = fstatSync(fd);
50
+ assertOwnedNode(opened);
51
+ if (opened.dev !== entry.dev || opened.ino !== entry.ino
52
+ || opened.size < TOKEN_LENGTH || opened.size > TOKEN_MAX_BYTES) throw invalidCredential();
53
+ const bytes = Buffer.alloc(TOKEN_MAX_BYTES + 1);
54
+ let length = 0;
55
+ while (length < bytes.length) {
56
+ const count = readSync(fd, bytes, length, bytes.length - length, length);
57
+ if (count === 0) break;
58
+ length += count;
59
+ }
60
+ const contents = bytes.subarray(0, length).toString("utf8");
61
+ const token = contents.slice(0, TOKEN_LENGTH);
62
+ const ending = contents.slice(TOKEN_LENGTH);
63
+ if (token.length !== TOKEN_LENGTH || !/^[A-Za-z0-9_-]+$/.test(token)
64
+ || !["", "\n", "\r\n"].includes(ending)) throw invalidCredential();
65
+ const afterRead = fstatSync(fd);
66
+ if (length !== afterRead.size || afterRead.mtimeMs !== opened.mtimeMs) throw invalidCredential();
67
+ assertSameNode(path, opened);
68
+ assertSameNode(dir, directory, true);
69
+ if (harden) {
70
+ if (process.platform === "win32") hardenSecretPath(path, { required: true });
71
+ else fchmodSync(fd, 0o600); // Never chmod a followed/replaced pathname.
72
+ assertSameNode(path, opened);
73
+ assertSameNode(dir, directory, true);
74
+ } else if (process.platform !== "win32" && (afterRead.mode & 0o077) !== 0) {
75
+ throw invalidCredential();
76
+ }
77
+ return token;
78
+ } finally {
79
+ closeSync(fd);
80
+ }
81
+ }
82
+
83
+ /** The current usable credential, or null. Inspection and CONNECT admission never create one. */
84
+ export function readClaudeInterceptProxyToken(configDir: string): string | null {
85
+ try {
86
+ return readPinnedToken(configDir, false);
87
+ } catch { // no-excuse-ok: catch -- unusable credentials fail closed without exposing filesystem or token details.
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function ensurePrivateDirectory(configDir: string): Stats {
93
+ const dir = join(configDir, "claude-intercept");
94
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
95
+ try {
96
+ mkdirSync(dir, { mode: 0o700 });
97
+ } catch (error) {
98
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
99
+ }
100
+ const entry = lstatSync(dir);
101
+ assertOwnedNode(entry, true);
102
+ if (process.platform === "win32") {
103
+ hardenSecretDir(dir, { required: true });
104
+ } else {
105
+ const fd = openSync(dir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | NOFOLLOW);
106
+ try {
107
+ const opened = fstatSync(fd);
108
+ assertOwnedNode(opened, true);
109
+ if (entry.dev !== opened.dev || entry.ino !== opened.ino) throw invalidCredential();
110
+ fchmodSync(fd, 0o700);
111
+ } finally {
112
+ closeSync(fd);
113
+ }
114
+ }
115
+ assertSameNode(dir, entry, true);
116
+ return entry;
117
+ }
118
+
119
+ /** Create once without replacing a winner, invalid file, symlink, or other existing entry. */
120
+ export function ensureClaudeInterceptProxyToken(configDir: string): string {
121
+ const dir = join(configDir, "claude-intercept");
122
+ const directory = ensurePrivateDirectory(configDir);
123
+ try {
124
+ return readPinnedToken(configDir, true);
125
+ } catch (error) {
126
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
127
+ }
128
+ assertSameNode(dir, directory, true);
129
+ const path = claudeInterceptProxyTokenPath(configDir);
130
+ const token = randomBytes(32).toString("base64url");
131
+ const temp = join(dir, `.${TOKEN_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
132
+ const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o600);
133
+ const created = fstatSync(fd);
134
+ try {
135
+ writeFileSync(fd, `${token}\n`, { encoding: "utf8" });
136
+ if (process.platform !== "win32") fchmodSync(fd, 0o600);
137
+ else hardenSecretPath(temp, { required: true, timeoutMemoKey: path });
138
+ assertSameNode(temp, created);
139
+ assertSameNode(dir, directory, true);
140
+ try {
141
+ linkSync(temp, path);
142
+ } catch (error) {
143
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
144
+ // A concurrent winner is validated below. No rename/overwrite fallback is safe here.
145
+ }
146
+ return readPinnedToken(configDir, true);
147
+ } finally {
148
+ // Cleanup cannot turn a committed credential into a failed apply or hide the
149
+ // original publication error. Do not retry close: its fd may already be reused.
150
+ let cleanupFailed = false;
151
+ try { closeSync(fd); } catch { cleanupFailed = true; }
152
+ let removed = false;
153
+ try {
154
+ assertSameNode(temp, created);
155
+ unlinkSync(temp);
156
+ removed = true;
157
+ } catch (error) {
158
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") removed = true;
159
+ else cleanupFailed = true;
160
+ }
161
+ if (removed) forgetEphemeralSecretPath(temp);
162
+ if (cleanupFailed) {
163
+ console.warn("[claude] Credential temporary cleanup incomplete; review retained temporary state.");
164
+ }
165
+ }
166
+ }
@@ -8,6 +8,8 @@ import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocal
8
8
  import type { PickerRouteInput } from "./picker-models";
9
9
  import { createPickerRuntime, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
10
10
  import type { SecurityRunner } from "./picker-trust";
11
+ import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./proxy-auth";
12
+ import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./settings";
11
13
 
12
14
  /**
13
15
  * Lifecycle for the Claude intercept pair (CONNECT proxy + TLS listener).
@@ -126,7 +128,20 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
126
128
  if (options.requestedPort === 0 && !explicitPort) return null;
127
129
  const configDir = options.configDir ?? getConfigDir();
128
130
  const ca = await ensureLocalInterceptCaForStartup(configDir);
131
+ const authToken = ensureClaudeInterceptProxyToken(configDir);
129
132
  const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS);
133
+ // Refresh an env we already own (e.g. a pre-auth proxy URL left by an upgrade) before the
134
+ // authenticated proxy takes over the port — a plain `ocx start` after an update would
135
+ // otherwise 407 every CONNECT until the next `ocx ensure` or apply. Only `stale` state is
136
+ // rewritten, so installs that never applied first-party are untouched.
137
+ try {
138
+ const proxyPort = claudeInterceptProxyPort(options.config, options.publicPort);
139
+ migrateClaudeInterceptSettings(buildClaudeInterceptEnv(proxyPort, claudeInterceptCaCertPath(configDir), authToken));
140
+ } catch (error) {
141
+ // A skipped rewrite degrades to the pre-migration behaviour and ensure/apply retries it,
142
+ // but silence here leaves upgraded clients hitting 407 with no recorded cause.
143
+ console.warn(`[claude-intercept] settings migration skipped: ${error instanceof Error ? error.message : String(error)}`);
144
+ }
130
145
  const listener = startClaudeInterceptListener<T>({
131
146
  leaf,
132
147
  dispatch: options.dispatch,
@@ -137,6 +152,9 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
137
152
  try {
138
153
  proxy = await startConnectProxy(claudeInterceptProxyPort(options.config, options.publicPort), {
139
154
  interceptPort: listener.port!,
155
+ // A real apply may recreate a missing token while this listener remains live.
156
+ // Read current validated authority per CONNECT; absent/invalid means deny, not mint.
157
+ authToken: () => readClaudeInterceptProxyToken(configDir),
140
158
  });
141
159
  } catch (error) {
142
160
  await listener.stop(true);
@@ -165,6 +183,9 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
165
183
  try {
166
184
  pickerProxy = await startConnectProxy(claudePickerProxyPort(options.config, options.publicPort), {
167
185
  interceptPort,
186
+ // No authToken: Desktop's egressProxyUrl cannot present proxy credentials, so this
187
+ // listener stays an unauthenticated loopback relay until the profile format can carry
188
+ // one. The intercept proxy above is the credential-bearing hop.
168
189
  // No host list here: the choice below depends on which client opened the tunnel.
169
190
  interceptHosts: [],
170
191
  selectTunnel: (host, port, request) => {
@@ -1,5 +1,6 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { atomicWriteFile } from "../../config/atomic-write";
3
4
  import { claudeConfigDir } from "../auth-detect";
4
5
 
5
6
  /**
@@ -10,7 +11,7 @@ import { claudeConfigDir } from "../auth-detect";
10
11
  * call. Two keys make every Claude Code process route through the local CONNECT proxy while
11
12
  * the app itself stays a first-party install:
12
13
  *
13
- * env.HTTPS_PROXY = http://127.0.0.1:<proxy port>
14
+ * env.HTTPS_PROXY = http://opencodex:<token>@127.0.0.1:<proxy port>
14
15
  * env.NODE_EXTRA_CA_CERTS = <configDir>/claude-intercept/ca.pem
15
16
  *
16
17
  * Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives
@@ -26,12 +27,12 @@ export interface ClaudeInterceptEnv {
26
27
  NODE_EXTRA_CA_CERTS: string;
27
28
  }
28
29
 
29
- export function claudeInterceptProxyUrl(port: number): string {
30
- return `http://127.0.0.1:${port}`;
30
+ export function claudeInterceptProxyUrl(port: number, authToken: string): string {
31
+ return `http://opencodex:${encodeURIComponent(authToken)}@127.0.0.1:${port}`;
31
32
  }
32
33
 
33
- export function buildClaudeInterceptEnv(proxyPort: number, caCertPath: string): ClaudeInterceptEnv {
34
- return { HTTPS_PROXY: claudeInterceptProxyUrl(proxyPort), NODE_EXTRA_CA_CERTS: caCertPath };
34
+ export function buildClaudeInterceptEnv(proxyPort: number, caCertPath: string, authToken: string): ClaudeInterceptEnv {
35
+ return { HTTPS_PROXY: claudeInterceptProxyUrl(proxyPort, authToken), NODE_EXTRA_CA_CERTS: caCertPath };
35
36
  }
36
37
 
37
38
  export type ClaudeInterceptSettingsState =
@@ -69,7 +70,7 @@ function envRecord(doc: SettingsDoc): Record<string, unknown> {
69
70
 
70
71
  /** Loopback proxy URLs are the only shape opencodex ever writes. */
71
72
  export function isClaudeInterceptProxyUrl(value: unknown): value is string {
72
- return typeof value === "string" && /^http:\/\/127\.0\.0\.1:\d{1,5}\/?$/.test(value.trim());
73
+ return typeof value === "string" && /^http:\/\/(?:opencodex:[^@/]+@)?127\.0\.0\.1:\d{1,5}\/?$/.test(value.trim());
73
74
  }
74
75
 
75
76
  function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string {
@@ -111,9 +112,9 @@ export function inspectClaudeInterceptSettings(
111
112
 
112
113
  function writeSettings(path: string, doc: SettingsDoc): void {
113
114
  mkdirSync(dirname(path), { recursive: true });
114
- const tmp = `${path}.${process.pid}.tmp`;
115
- writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
116
- renameSync(tmp, path);
115
+ // The managed env embeds the proxy token, so the file must stay owner-only:
116
+ // atomicWriteFile applies the real NTFS ACL on Windows where chmod is a no-op.
117
+ atomicWriteFile(path, `${JSON.stringify(doc, null, 2)}\n`);
117
118
  }
118
119
 
119
120
  export type ClaudeInterceptSettingsWrite =
@@ -123,7 +124,7 @@ export type ClaudeInterceptSettingsWrite =
123
124
  /** Capture only the managed keys. Rollback preserves unrelated edits and refuses
124
125
  * to overwrite a newer proxy/CA choice made after this apply. */
125
126
  export function captureClaudeInterceptSettingsRollback(
126
- expected: ClaudeInterceptEnv,
127
+ expected: ClaudeInterceptEnv | (() => ClaudeInterceptEnv),
127
128
  configDir = claudeConfigDir(),
128
129
  ): () => boolean {
129
130
  const path = settingsPath(configDir);
@@ -132,10 +133,11 @@ export function captureClaudeInterceptSettingsRollback(
132
133
  const previous = "doc" in before ? { ...envRecord(before.doc) } : {};
133
134
  return () => {
134
135
  try {
136
+ const target = typeof expected === "function" ? expected() : expected;
135
137
  const current = readSettings(path);
136
138
  if (!("doc" in current)) return false;
137
139
  const env = envRecord(current.doc);
138
- if (CLAUDE_INTERCEPT_MANAGED_ENV.some(key => env[key] !== expected[key])) return false;
140
+ if (CLAUDE_INTERCEPT_MANAGED_ENV.some(key => env[key] !== target[key])) return false;
139
141
  for (const key of CLAUDE_INTERCEPT_MANAGED_ENV) {
140
142
  if (previous[key] === undefined) delete env[key];
141
143
  else env[key] = previous[key];
@@ -168,6 +170,25 @@ export function applyClaudeInterceptSettings(
168
170
  return { ok: true, changed: true, path };
169
171
  }
170
172
 
173
+ /**
174
+ * Rewrite an env block opencodex already owns when it no longer matches what this run
175
+ * would write — a pre-auth `http://127.0.0.1:<port>` left behind by an upgrade would get
176
+ * a 407 from the now-authenticated proxy until `ocx ensure` or an apply ran. Unlike apply
177
+ * this never creates an absent env: only `stale` (owned) state is rewritten, so the
178
+ * runtime can call it on every start without enabling the integration for anyone else.
179
+ */
180
+ export function migrateClaudeInterceptSettings(
181
+ env: ClaudeInterceptEnv,
182
+ configDir = claudeConfigDir(),
183
+ ): ClaudeInterceptSettingsWrite {
184
+ const path = settingsPath(configDir);
185
+ const state = inspectClaudeInterceptSettings(env, configDir);
186
+ if (state.kind === "unreadable") return { ok: false, reason: "unreadable", path };
187
+ if (state.kind === "foreign") return { ok: false, reason: "foreign_env", path };
188
+ if (state.kind !== "stale") return { ok: true, changed: false, path };
189
+ return applyClaudeInterceptSettings(env, configDir);
190
+ }
191
+
171
192
  /** Remove the managed keys, but only the values opencodex owns. */
172
193
  export function removeClaudeInterceptSettings(
173
194
  ownedCaPath: string,
@@ -33,7 +33,7 @@ function reasoningIdentityDigest(value: string): string {
33
33
  }
34
34
 
35
35
  /** Fixed-size identity that preserves protocol boundaries without retaining upstream strings. */
36
- function boundedReasoningIdentity(value: unknown): string {
36
+ export function boundedReasoningIdentity(value: unknown): string {
37
37
  if (typeof value === "number") {
38
38
  if (Number.isSafeInteger(value) && value >= 0) return `n${value}`;
39
39
  if (Number.isFinite(value)) return `d${value}`;
@@ -104,7 +104,7 @@ export function anthropicUsage(usage: unknown, webSearchRequests = 0): Rec {
104
104
  };
105
105
  }
106
106
 
107
- function sseFrame(name: string, data: Rec): string {
107
+ export function sseFrame(name: string, data: Rec): string {
108
108
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
109
109
  }
110
110
 
@@ -161,7 +161,7 @@ export function sanitizeWebSearchInput(input: unknown): Rec {
161
161
  * input (query/queries) and the web_search_tool_result content (hits, or the error
162
162
  * object when the search failed). Shared by the SSE and JSON translation paths.
163
163
  */
164
- function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultContent: unknown; completed: boolean } {
164
+ export function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultContent: unknown; completed: boolean } {
165
165
  const action = isRec(item.action) ? item.action : {};
166
166
  const queries = Array.isArray(action.queries)
167
167
  ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0)
@@ -205,7 +205,7 @@ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultConte
205
205
  * is not a claim about upstream's tokenizer, and it is not final — `message_delta` carries the
206
206
  * authoritative count for every reader that waits for it, exactly as before.
207
207
  */
208
- function messageSnapshot(model: string, confirmedUsage?: Rec, inputTokenFloor?: number): Rec {
208
+ export function messageSnapshot(model: string, confirmedUsage?: Rec, inputTokenFloor?: number): Rec {
209
209
  const usage = confirmedUsage
210
210
  ?? (typeof inputTokenFloor === "number" && Number.isFinite(inputTokenFloor) && inputTokenFloor > 0
211
211
  ? { input_tokens: Math.trunc(inputTokenFloor), output_tokens: 0 }
@@ -222,6 +222,43 @@ function messageSnapshot(model: string, confirmedUsage?: Rec, inputTokenFloor?:
222
222
  };
223
223
  }
224
224
 
225
+ /**
226
+ * A Responses incomplete reason as the Anthropic client sees it: an output cap and a content
227
+ * filter are real stop reasons; every other reason is a retryable overload so Claude Code backs
228
+ * off instead of accepting a truncated turn.
229
+ */
230
+ export function anthropicIncompleteOutcome(
231
+ reason: unknown,
232
+ message: unknown,
233
+ ): { stopReason: "max_tokens" | "refusal" } | { failMessage: string } {
234
+ if (reason === "max_output_tokens") return { stopReason: "max_tokens" };
235
+ if (reason === "content_filter") return { stopReason: "refusal" };
236
+ return {
237
+ failMessage: typeof message === "string" && message.trim()
238
+ ? message
239
+ : `upstream response was incomplete${typeof reason === "string" ? ` (${reason})` : ""}`,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * The HTTP status a Responses `response.failed` error object maps to on the Anthropic wire.
245
+ * Internal failure envelopes carry the classified {type, code, message} but no numeric status,
246
+ * so it is derived with the same mapping /api/logs uses; a classified 429/401/400 then reaches
247
+ * Claude Code as its real Anthropic error type instead of a retryable overload.
248
+ */
249
+ export function anthropicFailedStatus(error: Rec, message: string): number {
250
+ const code = typeof error.code === "string" ? error.code : undefined;
251
+ return code === "translation_buffer_limit"
252
+ ? 413
253
+ : typeof error.status === "number"
254
+ ? error.status
255
+ : httpStatusFromTerminalError({
256
+ type: typeof error.type === "string" ? error.type : undefined,
257
+ code: typeof error.code === "string" ? error.code : null,
258
+ message,
259
+ });
260
+ }
261
+
225
262
  interface OpenBlock {
226
263
  kind: "text" | "thinking" | "tool_use";
227
264
  index: number;
@@ -668,16 +705,9 @@ export function responsesSseToAnthropicSse(
668
705
  case "response.incomplete": {
669
706
  const response = isRec(data.response) ? data.response : {};
670
707
  const details = isRec(response.incomplete_details) ? response.incomplete_details : {};
671
- if (details.reason === "max_output_tokens") {
672
- finish("max_tokens", response.usage);
673
- } else if (details.reason === "content_filter") {
674
- finish("refusal", response.usage);
675
- } else {
676
- const message = typeof details.message === "string" && details.message.trim()
677
- ? details.message
678
- : `upstream response was incomplete${typeof details.reason === "string" ? ` (${details.reason})` : ""}`;
679
- fail(529, message, true);
680
- }
708
+ const outcome = anthropicIncompleteOutcome(details.reason, details.message);
709
+ if ("stopReason" in outcome) finish(outcome.stopReason, response.usage);
710
+ else fail(529, outcome.failMessage, true);
681
711
  break;
682
712
  }
683
713
  case "response.failed": {
@@ -688,19 +718,7 @@ export function responsesSseToAnthropicSse(
688
718
  if (code === "translation_buffer_limit") {
689
719
  throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_TURN_BYTES);
690
720
  }
691
- const status = code === "translation_buffer_limit"
692
- ? 413
693
- : typeof error.status === "number"
694
- ? error.status
695
- // Internal response.failed envelopes carry the classified {type, code, message}
696
- // but no numeric status. Derive it with the same mapping /api/logs uses so a
697
- // classified 429/401/400 reaches Claude Code as its real Anthropic error type
698
- // instead of being masked as retryable overload.
699
- : httpStatusFromTerminalError({
700
- type: typeof error.type === "string" ? error.type : undefined,
701
- code: typeof error.code === "string" ? error.code : null,
702
- message,
703
- });
721
+ const status = anthropicFailedStatus(error, message);
704
722
  // Unclassified status-absent response.failed (relaySseWithFailedTail synthetic
705
723
  // tail) still lands on a transient 5xx here — the mid-stream reset shape maps to
706
724
  // overloaded_error by design.
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "../config";
2
+ import { isReservedCodexAccountWord, reportCodexAccountTargetError, resolveCodexAccountTarget } from "./account-target";
2
3
  import { hasPassiveAccountQuota } from "../providers/quota";
3
4
  import { closeSync, openSync, readSync } from "node:fs";
4
5
  import {
@@ -39,15 +40,15 @@ const AUTO_NOTE = "auto (no pin — lowest-usage account is selected per request
39
40
  const EXTENDED_USAGE = `Usage:
40
41
  ocx account refresh <provider> [--json]
41
42
  ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
42
- ocx account alias <provider> <id|main> <display-name|-> [--json]
43
- ocx account priority <provider> <id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]
44
- ocx account pause <provider> <id|main> [--json]
45
- ocx account resume <provider> <id|main> [--json]
43
+ ocx account alias <provider> <id|alias|main> <display-name|-> [--json]
44
+ ocx account priority <provider> <id|alias|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]
45
+ ocx account pause <provider> <id|alias|main> [--json]
46
+ ocx account resume <provider> <id|alias|main> [--json]
46
47
  ocx account pause-exhausted <provider> [--json]
47
48
  ocx account strategy <provider> [<quota|round-robin|fill-first|reset-first>] [--json]
48
49
  ocx account sticky <provider> [<1-100>] [--json]
49
- ocx account remove <provider> <id|main> --yes [--json]
50
- ocx account clear-cooldown <provider> <id|main> [--json]
50
+ ocx account remove <provider> <id|alias|main> --yes [--json]
51
+ ocx account clear-cooldown <provider> <id|alias|main> [--json]
51
52
  ocx account add-key <provider> [--label <label>] [--json]
52
53
  ocx account import <provider> --format <format> (--file <path>|--stdin) [--json]`;
53
54
  const PIPE_GUIDANCE = `Pipe the API key on stdin, for example:
@@ -447,12 +448,24 @@ export async function cmdRemove(args: string[], deps: AccountDeps): Promise<numb
447
448
  }
448
449
  const classified = configAndType(deps, name);
449
450
  if ("error" in classified) return wantsJson ? fail(classified.error) : usage(`Error: ${classified.error}`);
450
- const id = classified.type === "codex" && requestedId === "main" ? MAIN_ID : requestedId;
451
- if (classified.type === "codex" && id === MAIN_ID) return wantsJson
451
+ if (classified.type === "codex" && (requestedId === "main" || requestedId === MAIN_ID)) return wantsJson
452
452
  ? fail("the main Codex App login cannot be removed")
453
453
  : usage("Error: the main Codex App login cannot be removed");
454
454
  const baseUrl = await resolveBaseUrl(deps);
455
455
  if (!baseUrl) return fail("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
456
+ let id = requestedId;
457
+ if (classified.type === "codex") {
458
+ const target = await resolveCodexAccountTarget(deps, baseUrl, requestedId);
459
+ if ("networkDown" in target) {
460
+ const reason = target.transportError ? ` reason: ${target.transportError}` : "";
461
+ return fail(`Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.${reason}`);
462
+ }
463
+ if ("error" in target) {
464
+ const message = target.kind === "not_found" ? `account or key "${requestedId}" was not found` : target.error;
465
+ return wantsJson ? fail(message) : usage(`Error: ${message}`);
466
+ }
467
+ id = target.id;
468
+ }
456
469
  const before = await fetchRows(deps, baseUrl, name, classified.type);
457
470
  if (before.networkDown) return fail("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
458
471
  if (before.errorJson) return fail(errorText(before.errorJson, `failed to verify ${name} before removal`));
@@ -633,9 +646,12 @@ export async function cmdClearCooldown(args: string[], deps: AccountDeps): Promi
633
646
  if (classified.type !== "codex") {
634
647
  return usage(`Error: ${name} is not a Codex account pool; cooldown clearing applies to Codex accounts only`);
635
648
  }
636
- const id = requestedId === "main" ? MAIN_ID : requestedId;
637
649
  const baseUrl = await resolveBaseUrl(deps);
638
650
  if (!baseUrl) return proxyUnreachable();
651
+ const target = await resolveCodexAccountTarget(deps, baseUrl, requestedId);
652
+ if ("networkDown" in target) return proxyUnreachable(target.transportError);
653
+ if ("error" in target) return reportCodexAccountTargetError(target);
654
+ const id = target.id;
639
655
  const response = await apiJson(deps, baseUrl, "POST", "/api/codex-auth/accounts/clear-cooldown", { id });
640
656
  if (response.status === 0) return proxyUnreachable(response.transportError);
641
657
  if (response.status !== 200) return apiError(response.json, `failed to clear cooldown for ${requestedId}`, response.status);
@@ -693,8 +709,6 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
693
709
  if (classified.type !== "codex") {
694
710
  return usage("Error: selection order only applies to the openai Codex account pool");
695
711
  }
696
- const id = requestedId === "main" ? MAIN_ID : requestedId;
697
-
698
712
  // Validate before touching the network so a typo never reaches the proxy.
699
713
  let priority: number | null | undefined;
700
714
  if (requestedPriority !== undefined) {
@@ -706,6 +720,13 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
706
720
 
707
721
  const baseUrl = await resolveBaseUrl(deps);
708
722
  if (!baseUrl) return proxyUnreachable();
723
+ const target = await resolveCodexAccountTarget(deps, baseUrl, requestedId);
724
+ if ("networkDown" in target) return proxyUnreachable(target.transportError);
725
+ if ("error" in target) {
726
+ if (target.kind === "not_found" && priority === undefined) return usage(`Error: no ${name} account ${requestedId}`);
727
+ return reportCodexAccountTargetError(target);
728
+ }
729
+ const id = target.id;
709
730
 
710
731
  // No value means "show" — a read must not rewrite what it is reporting.
711
732
  if (priority === undefined) {
@@ -770,10 +791,12 @@ export async function cmdPause(args: string[], deps: AccountDeps, paused: boolea
770
791
  if (classified.type !== "codex") {
771
792
  return usage(`Error: ${verb} applies to the openai Codex account pool`);
772
793
  }
773
- const id = requestedId === "main" ? MAIN_ID : requestedId;
774
-
775
794
  const baseUrl = await resolveBaseUrl(deps);
776
795
  if (!baseUrl) return proxyUnreachable();
796
+ const target = await resolveCodexAccountTarget(deps, baseUrl, requestedId);
797
+ if ("networkDown" in target) return proxyUnreachable(target.transportError);
798
+ if ("error" in target) return reportCodexAccountTargetError(target);
799
+ const id = target.id;
777
800
 
778
801
  const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/accounts/pause", { id, paused });
779
802
  // Transport sentinel first: status 0 means the proxy never answered, and comparing it to
@@ -996,12 +1019,21 @@ export async function cmdAlias(args: string[], deps: AccountDeps): Promise<numbe
996
1019
  if (!name || !requestedId || requestedAlias === undefined || args.length) return usage();
997
1020
  const classified = configAndType(deps, name);
998
1021
  if ("error" in classified) return usage(`Error: ${classified.error}`);
999
- const id = classified.type === "codex" && requestedId === "main" ? MAIN_ID : requestedId;
1000
- if (id === MAIN_ID) return usage("Error: the main Codex App login cannot be renamed");
1022
+ if (classified.type === "codex" && (requestedId === "main" || requestedId === MAIN_ID)) {
1023
+ return usage("Error: the main Codex App login cannot be renamed");
1024
+ }
1001
1025
  const alias = requestedAlias === "-" ? "" : requestedAlias.trim();
1002
1026
  if (alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) return usage("Error: alias must be at most 80 printable characters");
1027
+ if (classified.type === "codex" && isReservedCodexAccountWord(alias)) return usage(`Error: "${alias}" is reserved for \`ocx account use\` and cannot be an alias`);
1003
1028
  const baseUrl = await resolveBaseUrl(deps);
1004
1029
  if (!baseUrl) return proxyUnreachable();
1030
+ let id = requestedId;
1031
+ if (classified.type === "codex") {
1032
+ const target = await resolveCodexAccountTarget(deps, baseUrl, requestedId);
1033
+ if ("networkDown" in target) return proxyUnreachable(target.transportError);
1034
+ if ("error" in target) return reportCodexAccountTargetError(target);
1035
+ id = target.id;
1036
+ }
1005
1037
  const path = classified.type === "codex"
1006
1038
  ? "/api/codex-auth/accounts/alias"
1007
1039
  : classified.type === "oauth"