@bitkyc08/opencodex 2.60.0 → 2.61.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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-CH6C5H7x.js +50 -0
  5. package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
  6. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  7. package/gui/dist/assets/index-wpTOyepx.js +86 -0
  8. package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -0,0 +1,98 @@
1
+ import type { Server } from "bun";
2
+ import type { OcxConfig } from "../../types";
3
+ import { getConfigDir } from "../../config/paths";
4
+ import { CLAUDE_INTERCEPT_HOSTS, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy";
5
+ import { startClaudeInterceptListener } from "./listener";
6
+ import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf } from "./local-ca";
7
+
8
+ /**
9
+ * Lifecycle for the Claude intercept pair (CONNECT proxy + TLS listener).
10
+ *
11
+ * Started next to the public listener, torn down with it. The proxy port is derived from the
12
+ * public port unless configured, because Claude Code's `settings.json` must name a port that
13
+ * survives restarts; the TLS listener is ephemeral and only ever reached through the proxy.
14
+ */
15
+
16
+ export const CLAUDE_INTERCEPT_PORT_OFFSET = 100;
17
+
18
+ export function claudeInterceptEnabled(config: Pick<OcxConfig, "claudeCode" | "runtimeRole">): boolean {
19
+ if (config.runtimeRole === "client") return false;
20
+ if (config.claudeCode?.enabled === false) return false;
21
+ return config.claudeCode?.intercept?.enabled !== false;
22
+ }
23
+
24
+ export function claudeInterceptProxyPort(config: Pick<OcxConfig, "claudeCode">, publicPort: number): number {
25
+ const configured = config.claudeCode?.intercept?.port;
26
+ if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured;
27
+ return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET;
28
+ }
29
+
30
+ export interface ClaudeInterceptState {
31
+ proxyPort: number;
32
+ caCertPath: string;
33
+ }
34
+
35
+ export interface ClaudeInterceptHandle<T = undefined> extends ClaudeInterceptState {
36
+ listener: Server<T>;
37
+ stop(): Promise<void>;
38
+ }
39
+
40
+ let activeState: ClaudeInterceptState | null = null;
41
+
42
+ /** Live intercept endpoints, or `null` when the pair is not running in this process. */
43
+ export function getClaudeInterceptState(): ClaudeInterceptState | null {
44
+ return activeState;
45
+ }
46
+
47
+ export interface StartClaudeInterceptOptions<T> {
48
+ config: OcxConfig;
49
+ /** Bound public port; the derived proxy port is offset from it. */
50
+ publicPort: number;
51
+ /**
52
+ * Port the operator asked for. `0` (ephemeral) gives the derived proxy port no stable value
53
+ * to write into `settings.json`, so intercept stays off unless `intercept.port` is explicit.
54
+ */
55
+ requestedPort?: number;
56
+ dispatch: (req: Request, server: Server<T>) => Promise<Response>;
57
+ maxRequestBodySize?: number;
58
+ configDir?: string;
59
+ }
60
+
61
+ /**
62
+ * Bind both halves. Resolves `null` when intercept is disabled. A bind failure is reported by
63
+ * rejecting; callers treat it as a degraded optional integration, never as a startup failure.
64
+ */
65
+ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptions<T>): Promise<ClaudeInterceptHandle<T> | null> {
66
+ if (!claudeInterceptEnabled(options.config)) return null;
67
+ const explicitPort = typeof options.config.claudeCode?.intercept?.port === "number";
68
+ if (options.requestedPort === 0 && !explicitPort) return null;
69
+ const configDir = options.configDir ?? getConfigDir();
70
+ const ca = await ensureLocalInterceptCaForStartup(configDir);
71
+ const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS);
72
+ const listener = startClaudeInterceptListener<T>({
73
+ leaf,
74
+ dispatch: options.dispatch,
75
+ upstreamBase: options.config.claudeCode?.anthropicBaseUrl,
76
+ ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}),
77
+ });
78
+ let proxy: ConnectProxyHandle;
79
+ try {
80
+ proxy = await startConnectProxy(claudeInterceptProxyPort(options.config, options.publicPort), {
81
+ interceptPort: listener.port!,
82
+ });
83
+ } catch (error) {
84
+ await listener.stop(true);
85
+ throw error;
86
+ }
87
+ const state: ClaudeInterceptState = { proxyPort: proxy.port, caCertPath: claudeInterceptCaCertPath(configDir) };
88
+ activeState = state;
89
+ return {
90
+ ...state,
91
+ listener,
92
+ stop: async () => {
93
+ if (activeState === state) activeState = null;
94
+ await proxy.close();
95
+ await listener.stop(true);
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,189 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { claudeConfigDir } from "../auth-detect";
4
+
5
+ /**
6
+ * Claude Code `settings.json` env block for intercept mode.
7
+ *
8
+ * Both the Claude Desktop Code tab and the standalone `claude` CLI read the user
9
+ * `~/.claude/settings.json` and export its `env` map into the process before any network
10
+ * call. Two keys make every Claude Code process route through the local CONNECT proxy while
11
+ * the app itself stays a first-party install:
12
+ *
13
+ * env.HTTPS_PROXY = http://127.0.0.1:<proxy port>
14
+ * env.NODE_EXTRA_CA_CERTS = <configDir>/claude-intercept/ca.pem
15
+ *
16
+ * Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives
17
+ * under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names
18
+ * that file is treated as ours. A user's own proxy or CA setting is left alone.
19
+ */
20
+
21
+ export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const;
22
+ export type ClaudeInterceptManagedEnv = typeof CLAUDE_INTERCEPT_MANAGED_ENV[number];
23
+
24
+ export interface ClaudeInterceptEnv {
25
+ HTTPS_PROXY: string;
26
+ NODE_EXTRA_CA_CERTS: string;
27
+ }
28
+
29
+ export function claudeInterceptProxyUrl(port: number): string {
30
+ return `http://127.0.0.1:${port}`;
31
+ }
32
+
33
+ export function buildClaudeInterceptEnv(proxyPort: number, caCertPath: string): ClaudeInterceptEnv {
34
+ return { HTTPS_PROXY: claudeInterceptProxyUrl(proxyPort), NODE_EXTRA_CA_CERTS: caCertPath };
35
+ }
36
+
37
+ export type ClaudeInterceptSettingsState =
38
+ | { kind: "absent" }
39
+ | { kind: "applied"; env: ClaudeInterceptEnv }
40
+ | { kind: "stale"; env: Partial<ClaudeInterceptEnv> }
41
+ | { kind: "foreign"; env: Partial<Record<ClaudeInterceptManagedEnv, string>> }
42
+ | { kind: "unreadable"; path: string };
43
+
44
+ type SettingsDoc = Record<string, unknown> & { env?: Record<string, unknown> };
45
+
46
+ function settingsPath(configDir: string): string {
47
+ return join(configDir, "settings.json");
48
+ }
49
+
50
+ function readSettings(path: string): { doc: SettingsDoc } | { error: "missing" | "unreadable" } {
51
+ let raw: string;
52
+ try {
53
+ raw = readFileSync(path, "utf8");
54
+ } catch (error) { // no-excuse-ok: catch -- an absent settings file is the fresh-install state.
55
+ return { error: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unreadable" };
56
+ }
57
+ try {
58
+ const parsed = JSON.parse(raw) as unknown;
59
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { error: "unreadable" };
60
+ return { doc: parsed as SettingsDoc };
61
+ } catch { // no-excuse-ok: catch -- a corrupt settings file must be reported, not overwritten.
62
+ return { error: "unreadable" };
63
+ }
64
+ }
65
+
66
+ function envRecord(doc: SettingsDoc): Record<string, unknown> {
67
+ return doc.env && typeof doc.env === "object" && !Array.isArray(doc.env) ? doc.env : {};
68
+ }
69
+
70
+ /** Loopback proxy URLs are the only shape opencodex ever writes. */
71
+ 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
+ }
74
+
75
+ function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string {
76
+ return typeof value === "string" && value.trim() === ownedCaPath;
77
+ }
78
+
79
+ /** Classify the current settings env against the values this router would write. */
80
+ export function inspectClaudeInterceptSettings(
81
+ expected: ClaudeInterceptEnv,
82
+ configDir = claudeConfigDir(),
83
+ ): ClaudeInterceptSettingsState {
84
+ const path = settingsPath(configDir);
85
+ const read = readSettings(path);
86
+ if ("error" in read) return read.error === "missing" ? { kind: "absent" } : { kind: "unreadable", path };
87
+ const env = envRecord(read.doc);
88
+ const proxy = env.HTTPS_PROXY;
89
+ const ca = env.NODE_EXTRA_CA_CERTS;
90
+ if (proxy === undefined && ca === undefined) return { kind: "absent" };
91
+ if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS) return { kind: "applied", env: expected };
92
+ const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS);
93
+ const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy);
94
+ if (caOurs && proxyOurs) {
95
+ return {
96
+ kind: "stale",
97
+ env: {
98
+ ...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}),
99
+ ...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}),
100
+ },
101
+ };
102
+ }
103
+ return {
104
+ kind: "foreign",
105
+ env: {
106
+ ...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}),
107
+ ...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}),
108
+ },
109
+ };
110
+ }
111
+
112
+ function writeSettings(path: string, doc: SettingsDoc): void {
113
+ 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);
117
+ }
118
+
119
+ export type ClaudeInterceptSettingsWrite =
120
+ | { ok: true; changed: boolean; path: string }
121
+ | { ok: false; reason: "unreadable" | "foreign_env"; path: string };
122
+
123
+ /** Capture only the managed keys. Rollback preserves unrelated edits and refuses
124
+ * to overwrite a newer proxy/CA choice made after this apply. */
125
+ export function captureClaudeInterceptSettingsRollback(
126
+ expected: ClaudeInterceptEnv,
127
+ configDir = claudeConfigDir(),
128
+ ): () => boolean {
129
+ const path = settingsPath(configDir);
130
+ const before = readSettings(path);
131
+ if ("error" in before && before.error !== "missing") return () => false;
132
+ const previous = "doc" in before ? { ...envRecord(before.doc) } : {};
133
+ return () => {
134
+ try {
135
+ const current = readSettings(path);
136
+ if (!("doc" in current)) return false;
137
+ const env = envRecord(current.doc);
138
+ if (CLAUDE_INTERCEPT_MANAGED_ENV.some(key => env[key] !== expected[key])) return false;
139
+ for (const key of CLAUDE_INTERCEPT_MANAGED_ENV) {
140
+ if (previous[key] === undefined) delete env[key];
141
+ else env[key] = previous[key];
142
+ }
143
+ if (Object.keys(env).length === 0) delete current.doc.env;
144
+ else current.doc.env = env;
145
+ writeSettings(path, current.doc);
146
+ return true;
147
+ } catch { return false; }
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Write the intercept env into `settings.json`. Refuses when a managed key already holds a
153
+ * value opencodex did not write (a user-configured corporate proxy, for instance).
154
+ */
155
+ export function applyClaudeInterceptSettings(
156
+ env: ClaudeInterceptEnv,
157
+ configDir = claudeConfigDir(),
158
+ ): ClaudeInterceptSettingsWrite {
159
+ const path = settingsPath(configDir);
160
+ const state = inspectClaudeInterceptSettings(env, configDir);
161
+ if (state.kind === "unreadable") return { ok: false, reason: "unreadable", path };
162
+ if (state.kind === "foreign") return { ok: false, reason: "foreign_env", path };
163
+ if (state.kind === "applied") return { ok: true, changed: false, path };
164
+ const read = readSettings(path);
165
+ const doc: SettingsDoc = "doc" in read ? read.doc : {};
166
+ doc.env = { ...envRecord(doc), ...env };
167
+ writeSettings(path, doc);
168
+ return { ok: true, changed: true, path };
169
+ }
170
+
171
+ /** Remove the managed keys, but only the values opencodex owns. */
172
+ export function removeClaudeInterceptSettings(
173
+ ownedCaPath: string,
174
+ configDir = claudeConfigDir(),
175
+ ): ClaudeInterceptSettingsWrite {
176
+ const path = settingsPath(configDir);
177
+ const read = readSettings(path);
178
+ if ("error" in read) {
179
+ return read.error === "missing" ? { ok: true, changed: false, path } : { ok: false, reason: "unreadable", path };
180
+ }
181
+ const env = envRecord(read.doc);
182
+ if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path };
183
+ delete env.NODE_EXTRA_CA_CERTS;
184
+ if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY;
185
+ if (Object.keys(env).length === 0) delete read.doc.env;
186
+ else read.doc.env = env;
187
+ writeSettings(path, read.doc);
188
+ return { ok: true, changed: true, path };
189
+ }
package/src/cli/access.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  const USAGE = `Usage:
13
13
  ocx access key [list] [--json]
14
14
  ocx access key create [name] [--json]
15
+ ocx access key get <id-or-name> [--json]
16
+ ocx access key set <id-or-name> [--allow-provider <name>]... [--allow-model <id>]... [--clear] [--json]
15
17
  ocx access key rotate <id> [--json]
16
18
  ocx access key rotate commit <id> <rotation-id> [--json]
17
19
  ocx access key rotate abort <id> <rotation-id> [--json]
@@ -65,6 +67,51 @@ function formatKeyRows(payload: Record<string, unknown>, keys: Array<Record<stri
65
67
  return footer.length > 0 ? [...lines, "", ...footer] : lines;
66
68
  }
67
69
 
70
+ /**
71
+ * Repeatable option values, in the order given.
72
+ *
73
+ * takeOption removes one occurrence, so a scope with several entries needs the
74
+ * loop: reading it once would silently keep only the first `--allow-model` and
75
+ * write a narrower scope than the operator typed.
76
+ */
77
+ function takeAllOptions(args: string[], name: string): string[] {
78
+ const values: string[] = [];
79
+ for (;;) {
80
+ const value = takeOption(args, name);
81
+ if (value === undefined) break;
82
+ values.push(value);
83
+ }
84
+ return values;
85
+ }
86
+
87
+ /**
88
+ * Find a key by id or by name, without ever reading the secret.
89
+ *
90
+ * The management API keys every mutation by id, so a name has to be resolved
91
+ * here. An ambiguous name is refused rather than resolved to the first match:
92
+ * silently scoping one of two keys that share a name is the kind of mistake
93
+ * only discovered when the wrong client stops working.
94
+ */
95
+ function findKeyRow(keys: Array<Record<string, unknown>>, selector: string): Record<string, unknown> {
96
+ const wanted = selector.trim().toLowerCase();
97
+ const byId = keys.filter(entry => String(entry.id ?? "").toLowerCase() === wanted);
98
+ if (byId.length === 1) return byId[0]!;
99
+ const byName = keys.filter(entry => String(entry.name ?? "").trim().toLowerCase() === wanted);
100
+ if (byName.length === 1) return byName[0]!;
101
+ if (byName.length > 1) throw new CliUsageError("key name " + selector + " is ambiguous; use the id", USAGE);
102
+ throw new CliUsageError("no API key matches " + selector, USAGE);
103
+ }
104
+
105
+ function scopeLines(entry: Record<string, unknown>): string[] {
106
+ const list = (value: unknown): string =>
107
+ Array.isArray(value) && value.length > 0 ? (value as string[]).join(", ") : "(any)";
108
+ return [
109
+ "API key " + String(entry.name ?? "") + " (" + String(entry.id ?? "") + ")",
110
+ " allowed providers: " + list(entry.allowedProviders),
111
+ " allowed models: " + list(entry.allowedModels),
112
+ ];
113
+ }
114
+
68
115
  async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
69
116
  const args = [...argv];
70
117
  const action = (args.shift() ?? "list").toLowerCase();
@@ -90,6 +137,46 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
90
137
  ]);
91
138
  return;
92
139
  }
140
+ if (action === "get") {
141
+ const selector = args.shift();
142
+ if (!selector) throw new CliUsageError("key id or name is required", USAGE);
143
+ rejectArgs(args, USAGE);
144
+ const result = await runtimeRequest<Record<string, unknown>>("/api/keys", {}, deps);
145
+ const entry = findKeyRow(Array.isArray(result.keys) ? result.keys as Array<Record<string, unknown>> : [], selector);
146
+ // The list response carries the masked prefix and never the secret, so the
147
+ // row is safe to print as-is under --json.
148
+ printData(entry, wantsJson, scopeLines(entry));
149
+ return;
150
+ }
151
+ if (action === "set") {
152
+ const selector = args.shift();
153
+ if (!selector) throw new CliUsageError("key id or name is required", USAGE);
154
+ const clear = takeFlag(args, "--clear");
155
+ const providers = takeAllOptions(args, "--allow-provider");
156
+ const models = takeAllOptions(args, "--allow-model");
157
+ rejectArgs(args, USAGE);
158
+ if (!clear && providers.length === 0 && models.length === 0) {
159
+ throw new CliUsageError("set requires --allow-provider, --allow-model, or --clear", USAGE);
160
+ }
161
+ const listed = await runtimeRequest<Record<string, unknown>>("/api/keys", {}, deps);
162
+ const target = findKeyRow(Array.isArray(listed.keys) ? listed.keys as Array<Record<string, unknown>> : [], selector);
163
+ // A set REPLACES the named dimension rather than appending to it, and
164
+ // --clear removes both. Naming one dimension leaves the other alone, so
165
+ // narrowing providers cannot accidentally widen models.
166
+ const body: Record<string, unknown> = { id: target.id };
167
+ if (clear) {
168
+ body.allowedProviders = null;
169
+ body.allowedModels = null;
170
+ }
171
+ if (providers.length > 0) body.allowedProviders = providers;
172
+ if (models.length > 0) body.allowedModels = models;
173
+ const result = await runtimeRequest<Record<string, unknown>>("/api/keys", {
174
+ method: "PATCH",
175
+ body: JSON.stringify(body),
176
+ }, deps);
177
+ printData(result, wantsJson, scopeLines(result));
178
+ return;
179
+ }
93
180
  if (action === "rotate") {
94
181
  const operation = args[0] === "commit" || args[0] === "abort" ? args.shift()! : "start";
95
182
  const id = args.shift();
@@ -2,6 +2,7 @@ import { writeSync } from "node:fs";
2
2
  import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance";
3
3
  import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh";
4
4
  import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery";
5
+ import { BROWSER_LAUNCH_FAILED_NOTICE } from "../lib/browser-launch-notice";
5
6
  import {
6
7
  CliUsageError,
7
8
  printData,
@@ -69,8 +70,25 @@ interface LoginStart {
69
70
  flowId?: string;
70
71
  instructions?: string;
71
72
  deviceCode?: string;
73
+ /** Whether the host actually opened a browser. Absent from older proxies. */
74
+ browserLaunch?: "started" | "failed" | "skipped";
72
75
  }
73
76
 
77
+ /**
78
+ * Said only when the host could not open a browser (#5261).
79
+ *
80
+ * Without it, a failed launch is indistinguishable from a successful one: the URL is printed
81
+ * either way, so the user waits at a terminal that looks like it is working. Names the fixed
82
+ * callback port because that is the part people cannot guess — ChatGPT supplies the redirect
83
+ * URI, so the flow cannot move to a free port, and `--device` is the way around it.
84
+ *
85
+ * Extends the shared notice rather than repeating it: only the second line is specific to this
86
+ * flow, and the first is the sentence every other login prints for the same failure.
87
+ */
88
+ export const BROWSER_LAUNCH_FAILED_HINT =
89
+ BROWSER_LAUNCH_FAILED_NOTICE
90
+ + "\n If nothing on this machine can reach http://localhost:1455, rerun with --device instead.";
91
+
74
92
  /** `-` means "read it from stdin", the documented way to pass a code silently. */
75
93
  const STDIN_SENTINEL = "-";
76
94
 
@@ -145,6 +163,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
145
163
  start.url ? `Open this URL to sign in:\n${start.url}` : "",
146
164
  start.deviceCode ? `Device code: ${start.deviceCode}` : "",
147
165
  start.instructions ?? "",
166
+ start.browserLaunch === "failed" ? BROWSER_LAUNCH_FAILED_HINT : "",
148
167
  start.flowId ? `Flow: ${start.flowId}` : "",
149
168
  ].filter(line => line !== "").join("\n");
150
169
  if (block) writeStdoutFully(`${block}\n`);
@@ -211,6 +211,21 @@ export const CAPABILITIES: readonly Capability[] = [
211
211
  json: "envelope",
212
212
  details: ["Reads /healthz plus local config; drives no management API route."],
213
213
  },
214
+ {
215
+ command: ["resolve"],
216
+ summary: "One JSON document naming the config home, the effective port, and the identity-checked proxy liveness verdict.",
217
+ // No management route, same split as status: discovery is the identity-checked
218
+ // /healthz probe inside findLiveProxy plus local config and the home from
219
+ // src/config/paths.ts.
220
+ routes: [],
221
+ flags: [{ name: "--json", value: "boolean", summary: "Emit the resolve document as JSON (the shell contract)." }],
222
+ mutates: false,
223
+ json: "envelope",
224
+ details: [
225
+ "Exit 0 carries a trustworthy verdict (live or proven absent); exit 1 means the CLI could not resolve and a caller must refuse to guess — unknown liveness never reads as absent.",
226
+ "Built for embedding shells (desktop app): the liveness budgets stay owned by src/server/proxy-liveness.ts.",
227
+ ],
228
+ },
214
229
  {
215
230
  command: ["hub", "invite"],
216
231
  summary: "Mint a single-use pairing code on a hub and print the exact `ocx connect` line for one more machine.",
@@ -314,6 +329,22 @@ export const CAPABILITIES: readonly Capability[] = [
314
329
  "Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.",
315
330
  ],
316
331
  },
332
+ {
333
+ command: ["companion"],
334
+ summary: "Inspect and configure menu-bar and widget companion usage settings.",
335
+ routes: [
336
+ { method: "GET", path: "/api/companion/settings" },
337
+ { method: "GET", path: "/api/usage/timeline" },
338
+ { method: "PUT", path: "/api/companion/settings" },
339
+ ],
340
+ flags: [{ name: "--json", value: "boolean", summary: "Emit companion settings as JSON." }],
341
+ mutates: true,
342
+ json: "payload",
343
+ details: [
344
+ "`show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults.",
345
+ "Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly.",
346
+ ],
347
+ },
317
348
  {
318
349
  command: ["account", "history"],
319
350
  summary: "Cached quota observations for one stored Codex pool account.",