@bitkyc08/opencodex 2.82.0 → 2.84.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 (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -0,0 +1,146 @@
1
+ import { readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { atomicWriteFile } from "../../config/atomic-write";
4
+ import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
5
+
6
+ /**
7
+ * The loopback bypass opencodex keeps in Claude Code's settings env (#6936), and the receipt
8
+ * that proves which part of `NO_PROXY` opencodex wrote.
9
+ *
10
+ * `NO_PROXY` is shared with the user, so a value alone cannot say which entries are ours. Apply
11
+ * records `{ hadKey, previous, written }` in `<claudeConfigDir>/opencodex-no-proxy.json` and
12
+ * removal restores `previous` only while `NO_PROXY` still equals `written`; anything else is
13
+ * preserved. The receipt lives exactly as long as the ownership episode that wrote it: an apply
14
+ * that starts a new episode, and every removal, invalidates it first and fails closed when it
15
+ * cannot. It holds only `NO_PROXY` values, never the proxy token.
16
+ */
17
+
18
+ /**
19
+ * Local hosts Claude Code must reach directly, in the order opencodex appends them. The proxy
20
+ * refuses every loopback and unspecified target (connect-proxy.ts isLoopbackTarget); these are the
21
+ * forms a NO_PROXY entry can name. `localhost` also covers `*.localhost` because Claude Code's
22
+ * client suffix-matches entries. Other 127/8 addresses cannot be listed (NO_PROXY has no CIDR) and
23
+ * need their own entry; the proxy's 405 body says so.
24
+ */
25
+ export const CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY = ["127.0.0.1", "localhost", "::1", "0.0.0.0"] as const;
26
+
27
+ export const NO_PROXY_RECEIPT_FILE = "opencodex-no-proxy.json";
28
+
29
+ export interface NoProxyReceipt {
30
+ version: 1;
31
+ /** Whether settings env held a NO_PROXY key before the apply. */
32
+ hadKey: boolean;
33
+ /** The value before the apply (null when the key was absent or held null). */
34
+ previous: string | null;
35
+ /** The value the apply wrote. */
36
+ written: string;
37
+ }
38
+
39
+ /** Canonical index of a loopback bypass entry, or -1. `[::1]` is the bracketed form of `::1`. */
40
+ function loopbackEntryIndex(entry: string): number {
41
+ const normalized = entry.trim().toLowerCase();
42
+ return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.indexOf(
43
+ (normalized === "[::1]" ? "::1" : normalized) as typeof CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY[number],
44
+ );
45
+ }
46
+
47
+ function noProxyEntries(value: string): string[] {
48
+ return value.split(/[,\s]+/).filter(entry => entry.length > 0);
49
+ }
50
+
51
+ /** True when the value already sends every loopback host around the proxy. */
52
+ export function noProxyCoversLoopback(value: unknown): boolean {
53
+ if (typeof value !== "string") return false;
54
+ const entries = noProxyEntries(value);
55
+ if (entries.includes("*")) return true;
56
+ const present = new Set(entries.map(loopbackEntryIndex));
57
+ return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.every((_, index) => present.has(index));
58
+ }
59
+
60
+ /** A NO_PROXY value opencodex can extend: absent, null, or a string. */
61
+ export function noProxyMergeable(value: unknown): boolean {
62
+ return value === undefined || value === null || typeof value === "string";
63
+ }
64
+
65
+ /**
66
+ * The NO_PROXY value apply writes, or null when nothing needs writing: the value already covers
67
+ * loopback, or it is not one opencodex can extend.
68
+ */
69
+ export function mergeClaudeInterceptNoProxy(current: unknown): string | null {
70
+ if (!noProxyMergeable(current) || noProxyCoversLoopback(current)) return null;
71
+ const base = typeof current === "string" ? current : "";
72
+ const present = new Set(noProxyEntries(base).map(loopbackEntryIndex));
73
+ const missing = CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.filter((_, index) => !present.has(index));
74
+ const trimmed = base.replace(/[,\s]+$/, "");
75
+ return trimmed.length === 0 ? missing.join(",") : `${trimmed},${missing.join(",")}`;
76
+ }
77
+
78
+ export function noProxyReceiptPath(configDir: string): string {
79
+ return join(configDir, NO_PROXY_RECEIPT_FILE);
80
+ }
81
+
82
+ interface ReceiptIo {
83
+ write(path: string, content: string): void;
84
+ unlink(path: string): void;
85
+ }
86
+
87
+ const defaultReceiptIo: ReceiptIo = {
88
+ write: (path, content) => atomicWriteFile(path, content),
89
+ unlink: path => unlinkSync(path),
90
+ };
91
+ let receiptIo: ReceiptIo = defaultReceiptIo;
92
+
93
+ /** Replace receipt write/unlink in tests; the real-home guard still runs. Returns a restore. */
94
+ export function setNoProxyReceiptIoForTests(io: Partial<ReceiptIo>): () => void {
95
+ const before = receiptIo;
96
+ receiptIo = { ...defaultReceiptIo, ...io };
97
+ return () => { receiptIo = before; };
98
+ }
99
+
100
+ /** The receipt, or null when it is missing or malformed (both mean: no provenance). */
101
+ export function readNoProxyReceipt(configDir: string): NoProxyReceipt | null {
102
+ let parsed: unknown;
103
+ try {
104
+ parsed = JSON.parse(readFileSync(noProxyReceiptPath(configDir), "utf8"));
105
+ } catch { // no-excuse-ok: catch -- a missing or corrupt receipt is the no-provenance state.
106
+ return null;
107
+ }
108
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
109
+ const receipt = parsed as Record<string, unknown>;
110
+ if (receipt.version !== 1 || typeof receipt.hadKey !== "boolean" || typeof receipt.written !== "string") return null;
111
+ if (receipt.previous !== null && typeof receipt.previous !== "string") return null;
112
+ return { version: 1, hadKey: receipt.hadKey, previous: receipt.previous, written: receipt.written };
113
+ }
114
+
115
+ /** Raw receipt bytes for rollback capture, or null when there is no readable file. */
116
+ export function readNoProxyReceiptBytes(configDir: string): string | null {
117
+ try {
118
+ return readFileSync(noProxyReceiptPath(configDir), "utf8");
119
+ } catch { // no-excuse-ok: catch -- an unreadable receipt is captured as absent.
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** Delete the receipt. Returns false when it exists but could not be removed. */
125
+ export function invalidateNoProxyReceipt(configDir: string): boolean {
126
+ const path = noProxyReceiptPath(configDir);
127
+ // Guard first and outside the catch, so a refusal is visible and nothing is touched.
128
+ assertNotRealClaudeConfigUnderTest(configDir, path);
129
+ try {
130
+ receiptIo.unlink(path);
131
+ return true;
132
+ } catch (error) { // no-excuse-ok: catch -- ENOENT is success; anything else is reported to the caller.
133
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
134
+ }
135
+ }
136
+
137
+ /** Write raw receipt bytes. Throws on failure; callers decide whether that is fatal. */
138
+ export function writeNoProxyReceiptBytes(configDir: string, content: string): void {
139
+ const path = noProxyReceiptPath(configDir);
140
+ assertNotRealClaudeConfigUnderTest(configDir, path);
141
+ receiptIo.write(path, content);
142
+ }
143
+
144
+ export function serializeNoProxyReceipt(receipt: NoProxyReceipt): string {
145
+ return `${JSON.stringify(receipt, null, 2)}\n`;
146
+ }
@@ -4,6 +4,19 @@ import { atomicWriteFile } from "../../config/atomic-write";
4
4
  import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
5
5
  import { claudeConfigDir } from "../auth-detect";
6
6
  import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
7
+ import {
8
+ invalidateNoProxyReceipt,
9
+ mergeClaudeInterceptNoProxy,
10
+ noProxyCoversLoopback,
11
+ noProxyMergeable,
12
+ noProxyReceiptPath,
13
+ readNoProxyReceipt,
14
+ readNoProxyReceiptBytes,
15
+ serializeNoProxyReceipt,
16
+ writeNoProxyReceiptBytes,
17
+ } from "./settings-no-proxy";
18
+
19
+ export { CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY, mergeClaudeInterceptNoProxy } from "./settings-no-proxy";
7
20
 
8
21
  /**
9
22
  * Claude Code `settings.json` env block for intercept mode.
@@ -16,9 +29,21 @@ import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
16
29
  * env.HTTPS_PROXY = http://opencodex:<token>@127.0.0.1:<proxy port>
17
30
  * env.NODE_EXTRA_CA_CERTS = <configDir>/claude-intercept/ca.pem
18
31
  *
32
+ * A third key keeps loopback off that proxy. Claude Code otherwise sends local HTTP MCP traffic
33
+ * (`POST http://127.0.0.1:<port>/mcp`) through it, and the proxy only tunnels CONNECT to
34
+ * non-loopback hosts, so every loopback MCP server failed with a 405 (#6936):
35
+ *
36
+ * env.NO_PROXY = <user entries>,127.0.0.1,localhost,::1,0.0.0.0
37
+ *
19
38
  * Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives
20
39
  * under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names
21
40
  * that file is treated as ours. A user's own proxy or CA setting is left alone.
41
+ *
42
+ * `NO_PROXY` is shared with the user. Apply appends only the missing loopback entries and leaves
43
+ * a covering value (including `*`) alone; removal puts back exactly what was there before, using
44
+ * the receipt described in ./settings-no-proxy.ts, and otherwise preserves the current value.
45
+ * A non-string `NO_PROXY` other than null is not one opencodex can extend: the block stays stale
46
+ * and apply refuses with `foreign_env` instead of rewriting it.
22
47
  */
23
48
 
24
49
  export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const;
@@ -79,6 +104,11 @@ function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string {
79
104
  return typeof value === "string" && value.trim() === ownedCaPath;
80
105
  }
81
106
 
107
+ function setOrDelete(env: Record<string, unknown>, key: string, value: unknown): void {
108
+ if (value === undefined) delete env[key];
109
+ else env[key] = value;
110
+ }
111
+
82
112
  /** Classify the current settings env against the values this router would write. */
83
113
  export function inspectClaudeInterceptSettings(
84
114
  expected: ClaudeInterceptEnv,
@@ -91,7 +121,11 @@ export function inspectClaudeInterceptSettings(
91
121
  const proxy = env.HTTPS_PROXY;
92
122
  const ca = env.NODE_EXTRA_CA_CERTS;
93
123
  if (proxy === undefined && ca === undefined) return { kind: "absent" };
94
- if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS) return { kind: "applied", env: expected };
124
+ // An owned block that still routes loopback into the proxy is stale, so the startup migrate
125
+ // and every reconcile repair installs written before the bypass existed.
126
+ if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS && noProxyCoversLoopback(env.NO_PROXY)) {
127
+ return { kind: "applied", env: expected };
128
+ }
95
129
  const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS);
96
130
  const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy);
97
131
  if (caOurs && proxyOurs) {
@@ -112,13 +146,28 @@ export function inspectClaudeInterceptSettings(
112
146
  };
113
147
  }
114
148
 
115
- function writeSettings(path: string, doc: SettingsDoc): void {
149
+ const defaultPersist = (path: string, content: string): void => atomicWriteFile(path, content);
150
+ let persistFile = defaultPersist;
151
+
152
+ /** Replace the settings.json write in tests; the real-home guard still runs first. Returns a restore. */
153
+ export function setClaudeSettingsPersistForTests(persist: (path: string, content: string) => void): () => void {
154
+ const before = persistFile;
155
+ persistFile = persist;
156
+ return () => { persistFile = before; };
157
+ }
158
+
159
+ /** Commit settings.json. Once this returns, the file on disk holds the new document. */
160
+ function persistSettings(path: string, doc: SettingsDoc): void {
116
161
  // An armed test process must not rewrite the real Claude settings.json (#6775).
117
162
  assertNotRealClaudeConfigUnderTest(dirname(path), path);
118
163
  mkdirSync(dirname(path), { recursive: true });
119
164
  // The managed env embeds the proxy token, so the file must stay owner-only:
120
165
  // atomicWriteFile applies the real NTFS ACL on Windows where chmod is a no-op.
121
- atomicWriteFile(path, `${JSON.stringify(doc, null, 2)}\n`);
166
+ persistFile(path, `${JSON.stringify(doc, null, 2)}\n`);
167
+ }
168
+
169
+ function writeSettings(path: string, doc: SettingsDoc): void {
170
+ persistSettings(path, doc);
122
171
  // Whether Claude Code reaches the proxy just changed, so its cached /model list is stale.
123
172
  invalidateClaudeCodeServedCatalog(dirname(path));
124
173
  }
@@ -137,20 +186,31 @@ export function captureClaudeInterceptSettingsRollback(
137
186
  const before = readSettings(path);
138
187
  if ("error" in before && before.error !== "missing") return () => false;
139
188
  const previous = "doc" in before ? { ...envRecord(before.doc) } : {};
189
+ const previousReceipt = readNoProxyReceiptBytes(configDir);
190
+ // What apply writes over the captured value; rollback restores NO_PROXY only while it still
191
+ // holds exactly that, so a user edit made after the apply survives. The receipt is restored
192
+ // with it, because it describes that value.
193
+ const appliedNoProxy = mergeClaudeInterceptNoProxy(previous.NO_PROXY) ?? previous.NO_PROXY;
140
194
  return () => {
141
195
  try {
196
+ assertNotRealClaudeConfigUnderTest(configDir, path, noProxyReceiptPath(configDir));
142
197
  const target = typeof expected === "function" ? expected() : expected;
143
198
  const current = readSettings(path);
144
199
  if (!("doc" in current)) return false;
145
200
  const env = envRecord(current.doc);
146
201
  if (CLAUDE_INTERCEPT_MANAGED_ENV.some(key => env[key] !== target[key])) return false;
147
202
  for (const key of CLAUDE_INTERCEPT_MANAGED_ENV) {
148
- if (previous[key] === undefined) delete env[key];
149
- else env[key] = previous[key];
203
+ setOrDelete(env, key, previous[key]);
150
204
  }
205
+ const restoreNoProxy = env.NO_PROXY === appliedNoProxy;
206
+ if (restoreNoProxy) setOrDelete(env, "NO_PROXY", previous.NO_PROXY);
151
207
  if (Object.keys(env).length === 0) delete current.doc.env;
152
208
  else current.doc.env = env;
153
209
  writeSettings(path, current.doc);
210
+ if (restoreNoProxy) {
211
+ if (previousReceipt === null) return invalidateNoProxyReceipt(configDir);
212
+ writeNoProxyReceiptBytes(configDir, previousReceipt);
213
+ }
154
214
  return true;
155
215
  } catch { return false; }
156
216
  };
@@ -158,7 +218,8 @@ export function captureClaudeInterceptSettingsRollback(
158
218
 
159
219
  /**
160
220
  * Write the intercept env into `settings.json`. Refuses when a managed key already holds a
161
- * value opencodex did not write (a user-configured corporate proxy, for instance).
221
+ * value opencodex did not write (a user-configured corporate proxy, for instance), or when
222
+ * NO_PROXY holds a non-string value opencodex cannot extend.
162
223
  */
163
224
  export function applyClaudeInterceptSettings(
164
225
  env: ClaudeInterceptEnv,
@@ -171,8 +232,21 @@ export function applyClaudeInterceptSettings(
171
232
  if (state.kind === "applied") return { ok: true, changed: false, path };
172
233
  const read = readSettings(path);
173
234
  const doc: SettingsDoc = "doc" in read ? read.doc : {};
174
- doc.env = { ...envRecord(doc), ...env };
235
+ const current = envRecord(doc);
236
+ if (!noProxyMergeable(current.NO_PROXY)) return { ok: false, reason: "foreign_env", path };
237
+ const noProxy = mergeClaudeInterceptNoProxy(current.NO_PROXY);
238
+ // A new ownership episode, or a new NO_PROXY value, makes any existing receipt describe the
239
+ // wrong value. Drop it before writing, and write nothing when it cannot be dropped.
240
+ if ((state.kind === "absent" || noProxy !== null) && !invalidateNoProxyReceipt(configDir)) {
241
+ return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
242
+ }
243
+ doc.env = { ...current, ...env, ...(noProxy === null ? {} : { NO_PROXY: noProxy }) };
175
244
  writeSettings(path, doc);
245
+ if (noProxy !== null) {
246
+ const receipt = { version: 1 as const, hadKey: "NO_PROXY" in current, previous: typeof current.NO_PROXY === "string" ? current.NO_PROXY : null, written: noProxy };
247
+ // Without a receipt, removal keeps the appended entries: the harmless direction.
248
+ try { writeNoProxyReceiptBytes(configDir, serializeNoProxyReceipt(receipt)); } catch { /* no provenance */ }
249
+ }
176
250
  return { ok: true, changed: true, path };
177
251
  }
178
252
 
@@ -195,7 +269,11 @@ export function migrateClaudeInterceptSettings(
195
269
  return applyClaudeInterceptSettings(env, configDir);
196
270
  }
197
271
 
198
- /** Remove the managed keys, but only the values opencodex owns. */
272
+ /**
273
+ * Remove the managed keys and put NO_PROXY back as it was, but only the values opencodex owns.
274
+ * The receipt is dropped before the settings write; when that fails nothing is changed, so a
275
+ * receipt can never outlive the episode it describes.
276
+ */
199
277
  export function removeClaudeInterceptSettings(
200
278
  ownedCaPath: string,
201
279
  configDir = claudeConfigDir(),
@@ -207,10 +285,24 @@ export function removeClaudeInterceptSettings(
207
285
  }
208
286
  const env = envRecord(read.doc);
209
287
  if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path };
288
+ const receipt = readNoProxyReceipt(configDir);
289
+ const receiptBytes = readNoProxyReceiptBytes(configDir);
210
290
  delete env.NODE_EXTRA_CA_CERTS;
211
291
  if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY;
292
+ if (receipt && env.NO_PROXY === receipt.written) setOrDelete(env, "NO_PROXY", receipt.hadKey ? receipt.previous : undefined);
293
+ if (!invalidateNoProxyReceipt(configDir)) return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
212
294
  if (Object.keys(env).length === 0) delete read.doc.env;
213
295
  else read.doc.env = env;
214
- writeSettings(path, read.doc);
296
+ try {
297
+ persistSettings(path, read.doc);
298
+ } catch (error) {
299
+ // A removal that was not committed has not ended the episode, so its receipt stays valid. Only the
300
+ // persist step is covered: once settings.json changed, the receipt must not come back.
301
+ if (receiptBytes !== null) {
302
+ try { writeNoProxyReceiptBytes(configDir, receiptBytes); } catch { /* keep the original error */ }
303
+ }
304
+ throw error;
305
+ }
306
+ invalidateClaudeCodeServedCatalog(dirname(path));
215
307
  return { ok: true, changed: true, path };
216
308
  }
@@ -137,6 +137,7 @@ export async function apiJson(
137
137
  body?: unknown,
138
138
  options: { signal?: AbortSignal; redirect?: RequestRedirect } = {},
139
139
  ): Promise<ApiResult> {
140
+ if (baseUrl === LINK_HOME_BASE) return linkHomeApiJson(deps, method, path);
140
141
  const fetchImpl = deps.fetchImpl ?? fetch;
141
142
  try {
142
143
  const res = await fetchImpl(`${baseUrl}${path}`, {
@@ -163,9 +164,73 @@ export async function resolveBaseUrl(deps: AccountDeps): Promise<string | null>
163
164
  if (deps.baseUrl) return deps.baseUrl;
164
165
  const live = await findLiveProxy();
165
166
  if (!live) return null;
167
+ if (live.role === "client") {
168
+ const { readLinkChildContext } = await import("./link-child");
169
+ if (readLinkChildContext()) return LINK_HOME_BASE;
170
+ }
166
171
  return `http://${probeHostname(live.hostname)}:${live.port}`;
167
172
  }
168
173
 
174
+ /**
175
+ * Remote Link Child (#6957): account reads come from the Home over the link, and every account
176
+ * change is refused with the Child refusal. The marker is never a URL and never reaches fetch.
177
+ */
178
+ export const LINK_HOME_BASE = "ocx-link-home:";
179
+
180
+ async function linkHomeApiJson(deps: AccountDeps, method: string, path: string): Promise<ApiResult> {
181
+ const { readLinkChildContext, linkChildReadable, linkChildRefusal, readLinkHomeJson } = await import("./link-child");
182
+ const link = readLinkChildContext();
183
+ if (!link) return { status: 503, json: { error: "The Remote Link connection changed while the command ran; retry." } };
184
+ const pathname = new URL(path, "http://cli.invalid").pathname;
185
+ if (!linkChildReadable(method, path)) {
186
+ return { status: 403, json: { error: linkChildRefusal(link, `${method} ${pathname}`) } };
187
+ }
188
+ try {
189
+ const { status, body } = await readLinkHomeJson(link, path, deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {});
190
+ return { status, json: (body && typeof body === "object" ? body : { error: String(body ?? "") }) as Record<string, unknown> };
191
+ } catch (error) {
192
+ return { status: 503, json: { error: error instanceof Error ? error.message : String(error) } };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The config account commands classify providers against. On a Remote Link Child the local
198
+ * providers are inactive, so the Home's `/api/providers` projection (presence flags only) takes
199
+ * their place. Anywhere else the local config is returned. When the Home cannot be read the
200
+ * result is an error: classifying against the inactive local providers would answer for the
201
+ * wrong machine.
202
+ */
203
+ export async function accountClassificationConfig(
204
+ deps: AccountDeps,
205
+ local: OcxConfig,
206
+ ): Promise<{ config: OcxConfig } | { error: string }> {
207
+ if (deps.baseUrl) return { config: local };
208
+ const { readLinkChildContext, readLinkHomeJson } = await import("./link-child");
209
+ const link = readLinkChildContext();
210
+ if (!link) return { config: local };
211
+ const unavailable = (detail: string) => ({ error: `Could not read the provider list from ${link.homePhrase} over Remote Link (${link.linkId}): ${detail}.` });
212
+ try {
213
+ const { status, body } = await readLinkHomeJson(link, "/api/providers", deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {});
214
+ if (status !== 200) {
215
+ // Keep the Home's own sentence (for example the upgrade instruction an older Home yields).
216
+ const message = body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
217
+ ? (body as { error: string }).error : null;
218
+ return unavailable(message ?? `the Home answered HTTP ${status}`);
219
+ }
220
+ if (!Array.isArray(body)) return unavailable("the Home returned an unreadable provider list");
221
+ const providers: Record<string, unknown> = {};
222
+ for (const row of body) {
223
+ if (!row || typeof row !== "object" || typeof (row as { name?: unknown }).name !== "string") continue;
224
+ const { name, hasApiKey, ...rest } = row as Record<string, unknown> & { name: string };
225
+ // A placeholder marks presence for classification; no credential value crosses the link.
226
+ providers[name] = { ...rest, ...(hasApiKey === true ? { apiKey: "home-credential" } : {}) };
227
+ }
228
+ return { config: { ...local, providers: providers as OcxConfig["providers"] } };
229
+ } catch (error) {
230
+ return { error: error instanceof Error ? error.message : String(error) };
231
+ }
232
+ }
233
+
169
234
  export function proxyUnreachable(transportError?: string): number {
170
235
  console.error("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
171
236
  // Naming the transport cause distinguishes "nothing is listening" from a refused
@@ -6,6 +6,7 @@ import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh";
6
6
  import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery";
7
7
  import { BROWSER_LAUNCH_FAILED_NOTICE } from "../lib/browser-launch-notice";
8
8
  import {
9
+ RuntimeApiError,
9
10
  CliUsageError,
10
11
  printData,
11
12
  readSecretLine,
@@ -440,6 +441,18 @@ async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise<void>
440
441
  printData(result, wantsJson);
441
442
  }
442
443
 
444
+ /** Codes that leave a coupon operation unconfirmed: the caller must keep its operationId. */
445
+ const UNCONFIRMED_COUPON_CODES: ReadonlySet<string> = new Set([
446
+ "attempt_unresolved", "attempt_in_progress", "attempt_reconcile_failed", "operation_state_changed",
447
+ "redeem_failed", "attempt_mark_failed", "operation_token_mismatch",
448
+ ]);
449
+ /** Pre-dispatch ledger refusals: unconfirmed when anchored to an operation, never a settled code. */
450
+ const LEDGER_REFUSAL_CODES: ReadonlySet<string> = new Set(["ledger_unavailable", "capacity"]);
451
+ /** The only codes the ledger records as a definitive outcome; any other settled code is unconfirmed. */
452
+ const DEFINITIVE_SETTLED_CODES: ReadonlySet<string> = new Set([
453
+ "redeemed", "auth_failed", "fetch_resets_failed", "no_coupons_available", "coupon_unavailable", "token_unresolved",
454
+ ]);
455
+
443
456
  async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise<void> {
444
457
  const args = [...argv];
445
458
  // The account id is optional here (the server falls back to the selected xAI
@@ -465,18 +478,37 @@ async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise<v
465
478
  }
466
479
  rejectArgs(args, USAGE);
467
480
  const accountId = rawId ? (rawId === "main" ? "__main__" : rawId) : undefined;
468
- const result = consume
469
- ? await runtimeRequest("/api/grok/reset-coupons/consume", {
470
- method: "POST",
471
- // Spread, not `operationId: undefined`: the server distinguishes an absent
472
- // key from a caller who asked for a stable idempotency identity.
473
- body: JSON.stringify({ accountId, tokenId, ...(operationId === undefined ? {} : { operationId }) }),
474
- }, deps)
475
- : await runtimeRequest(
476
- `/api/grok/reset-coupons${accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""}`,
477
- {},
478
- deps,
479
- );
481
+ const effectiveOperationId = consume ? operationId ?? crypto.randomUUID() : undefined;
482
+ const unconfirmed = () => new RuntimeApiError(`Coupon redemption outcome is unconfirmed. Preserve --operation-id ${effectiveOperationId}; reuse it to inspect this attempt and do not create a replacement operation.`, 503, null);
483
+ let result: unknown;
484
+ try {
485
+ result = consume
486
+ ? await runtimeRequest("/api/grok/reset-coupons/consume", {
487
+ method: "POST", body: JSON.stringify({ accountId, tokenId, operationId: effectiveOperationId }),
488
+ }, deps)
489
+ : await runtimeRequest(`/api/grok/reset-coupons${accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""}`, {}, deps);
490
+ } catch (error) {
491
+ const apiError = error instanceof RuntimeApiError ? error : undefined;
492
+ const body = apiError?.body as { operationId?: unknown; error?: { code?: string } } | null | undefined;
493
+ const code = body && typeof body === "object" ? body.error?.code : undefined;
494
+ const carriesOperation = !!body && typeof body === "object" && typeof body.operationId === "string";
495
+ if (consume && (!apiError || (!apiError.code && (
496
+ (!code && apiError.status >= 500) || UNCONFIRMED_COUPON_CODES.has(code ?? "")
497
+ || (LEDGER_REFUSAL_CODES.has(code ?? "") && carriesOperation)
498
+ )))) {
499
+ throw new RuntimeApiError(`Coupon redemption outcome is unconfirmed. Preserve --operation-id ${effectiveOperationId}; reuse it to inspect this attempt and do not create a replacement operation.`, apiError?.status ?? 503, apiError?.body ?? null);
500
+ }
501
+ throw error;
502
+ }
503
+ if (consume) {
504
+ // A 200 can replay a recorded refusal; only "redeemed" is a successful spend.
505
+ const settled = result && typeof result === "object" ? (result as { code?: unknown }).code : undefined;
506
+ if (typeof settled !== "string" || !DEFINITIVE_SETTLED_CODES.has(settled)) throw unconfirmed();
507
+ if (settled !== "redeemed") {
508
+ printData(result, wantsJson);
509
+ throw new RuntimeApiError(`Coupon was not redeemed (${settled}).`, 409, result);
510
+ }
511
+ }
480
512
  printData(result, wantsJson);
481
513
  }
482
514
 
@@ -21,7 +21,7 @@ import {
21
21
  cmdRoutes,
22
22
  cmdStrategy,
23
23
  } from "./account-extended";
24
- import { apiError, apiJson, classifyAccount, fetchRows, proxyUnreachable, resolveBaseUrl, type AccountDeps, type AccountRow, type AccountType, type ApiResult }
24
+ import { accountClassificationConfig, apiError, apiJson, classifyAccount, fetchRows, proxyUnreachable, resolveBaseUrl, type AccountDeps, type AccountRow, type AccountType, type ApiResult }
25
25
  from "./account-api";
26
26
 
27
27
  export { classifyAccount } from "./account-api";
@@ -199,7 +199,12 @@ async function cmdList(rest: string[], deps: AccountDeps): Promise<number> {
199
199
  console.error(ACCOUNT_USAGE);
200
200
  return 1;
201
201
  }
202
- const config = deps.loadConfigImpl?.() ?? loadConfig();
202
+ const classification = await accountClassificationConfig(deps, deps.loadConfigImpl?.() ?? loadConfig());
203
+ if ("error" in classification) {
204
+ console.error(`Error: ${classification.error}`);
205
+ return 1;
206
+ }
207
+ const config = classification.config;
203
208
  const baseUrl = await resolveBaseUrl(deps);
204
209
  if (!baseUrl) return proxyUnreachable();
205
210
 
@@ -289,7 +294,12 @@ async function cmdCurrent(rest: string[], deps: AccountDeps): Promise<number> {
289
294
  console.error(ACCOUNT_USAGE);
290
295
  return 1;
291
296
  }
292
- const config = deps.loadConfigImpl?.() ?? loadConfig();
297
+ const classification = await accountClassificationConfig(deps, deps.loadConfigImpl?.() ?? loadConfig());
298
+ if ("error" in classification) {
299
+ console.error(`Error: ${classification.error}`);
300
+ return 1;
301
+ }
302
+ const config = classification.config;
293
303
  const c = classifyAccount(config, name);
294
304
  if ("error" in c) {
295
305
  console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
@@ -332,7 +342,20 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise<number> {
332
342
  console.error(ACCOUNT_USAGE);
333
343
  return 1;
334
344
  }
335
- const config = deps.loadConfigImpl?.() ?? loadConfig();
345
+ // #6957: an account change on a link Child is refused before any Home read.
346
+ if (!deps.baseUrl) {
347
+ const refusal = (await import("./link-child")).linkChildProviderChangeRefusal("ocx account use");
348
+ if (refusal) {
349
+ console.error(`Error: ${refusal}`);
350
+ return 1;
351
+ }
352
+ }
353
+ const classification = await accountClassificationConfig(deps, deps.loadConfigImpl?.() ?? loadConfig());
354
+ if ("error" in classification) {
355
+ console.error(`Error: ${classification.error}`);
356
+ return 1;
357
+ }
358
+ const config = classification.config;
336
359
  const c = classifyAccount(config, name);
337
360
  if ("error" in c) {
338
361
  console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
@@ -392,7 +415,20 @@ async function cmdClear(rest: string[], deps: AccountDeps): Promise<number> {
392
415
  console.error(ACCOUNT_USAGE);
393
416
  return 1;
394
417
  }
395
- const config = deps.loadConfigImpl?.() ?? loadConfig();
418
+ // #6957: an account change on a link Child is refused before any Home read.
419
+ if (!deps.baseUrl) {
420
+ const refusal = (await import("./link-child")).linkChildProviderChangeRefusal("ocx account clear");
421
+ if (refusal) {
422
+ console.error(`Error: ${refusal}`);
423
+ return 1;
424
+ }
425
+ }
426
+ const classification = await accountClassificationConfig(deps, deps.loadConfigImpl?.() ?? loadConfig());
427
+ if ("error" in classification) {
428
+ console.error(`Error: ${classification.error}`);
429
+ return 1;
430
+ }
431
+ const config = classification.config;
396
432
  const c = classifyAccount(config, name);
397
433
  if ("error" in c) {
398
434
  console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
@@ -2,6 +2,14 @@ import type { Capability } from "./capability-types";
2
2
 
3
3
  /** Verified existing handlers only; local operations never claim management routes. */
4
4
  export const ACCESS_REMOTE_CAPABILITIES: readonly Capability[] = [
5
+ {
6
+ command: ["hub", "disable"], summary: "Return a hub to standalone in one validated config write.",
7
+ usage: "ocx hub disable [--revoke-clients --client-key <id> ...] [--json]",
8
+ routes: [],
9
+ flags: [{ name: "--revoke-clients", value: "boolean", summary: "Revoke only explicitly selected saved client key IDs." }, { name: "--client-key", value: "string", summary: "Exact saved key ID to revoke; repeatable, requires --revoke-clients." }, { name: "--json", value: "boolean", summary: "Emit the result and restart guidance as JSON." }],
10
+ mutates: true, json: "envelope",
11
+ details: ["Removes hub, unauthenticatedLoopbackListener, runtimeRole and hostname atomically after validation; non-hub roles refuse without changes.", "Existing keys have no verified client origin. Names never select keys; --revoke-clients requires exact --client-key IDs when keys exist. Unselected keys remain.", "Prints only safe key metadata, never plaintext keys or pending rotations. Restart advice is informational; the running proxy and external publishing are unchanged."],
12
+ },
5
13
  {
6
14
  command: ["access", "key"], summary: "Manage API access keys and inspect masked metadata.",
7
15
  usage: "ocx access key list [--json]",
@@ -26,6 +26,8 @@ export const CAPABILITIES: readonly Capability[] = [
26
26
  bannerLines: ["ocx chatgpt <sub> Experimental app-server shim: launch|restore|status (macOS)"],
27
27
  details: ["Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher."],
28
28
  },
29
+ {"command": ["link","probe"],"summary": "Probe an SSH host fingerprint and OpenCodex version without trusting it.","routes": [{"method": "POST","path": "/api/link/probe"}],"flags": [{"name": "--json","value": "boolean","summary": "Emit alias, fingerprint, key type and version as JSON."}],"mutates": true,"json": "payload","usage": "ocx link probe <alias> [--json]","details": ["Requires loopback admin authority on the Home. Stores only a temporary pending offer; does not persist host trust or issue a link key."]},
30
+ {"command": ["link","add"],"summary": "Connect an SSH Child to this Home after confirming its fingerprint.","routes": [{"method": "POST","path": "/api/link/probe"},{"method": "POST","path": "/api/link/confirm-host"},{"method": "POST","path": "/api/link/apply"}],"flags": [{"name": "--expect-fingerprint","value": "string","summary": "Exact SHA256 host fingerprint; required without an interactive TTY."},{"name": "--json","value": "boolean","summary": "Emit the verified link result as JSON."}],"mutates": true,"json": "payload","usage": "ocx link add <alias> [--expect-fingerprint SHA256:…] [--json]","details": ["Requires loopback admin authority on the Home. Without a fingerprint, requires a TTY and explicit yes after showing the offered fingerprint. Never prints the link key.","Preflights bound Child restart support and curl, connects, restarts once and verifies a fresh authenticated catalog relay before success. Post-connect failures can leave Child config; run ocx disconnect and ocx restart there before retrying."]},
29
31
  {
30
32
  command: ["link","port"],
31
33
  summary: "Allocate a free loopback port for a remote home link.",
@@ -65,6 +67,16 @@ export const CAPABILITIES: readonly Capability[] = [
65
67
  details: ["Requires a valid link ID and loopback admin authority. Explicit force additionally requires --yes.","Ordinary revoke always emits {linkId} JSON; forced revoke has human output or a JSON cleanup receipt. Already-revoked link_not_found is idempotent success.","Ordinary revoke retains its no-body and idempotent behavior. Forced success reports remoteCleanup:skipped from invocation intent; already-missing link reports unverified. Neither proves the remote client disconnected.","If forced cleanup was needed, run ocx disconnect on the remote client. No new enrollment, SSH trust or credential action is implied."],
66
68
  usage: "ocx link revoke --link-id <id> [--force --yes] [--json]",
67
69
  },
70
+ {
71
+ command: ["link","reconnect"],
72
+ summary: "Restart a Home-initiated link's SSH tunnel now.",
73
+ routes: [{"method":"POST","path":"/api/link/{id}/reconnect"}],
74
+ flags: [{"name":"--link-id","value":"string","required":true,"summary":"Home-initiated link id whose tunnel to restart."},{"name":"--json","value":"boolean","summary":"Emit the restarted link id as JSON."}],
75
+ mutates: true,
76
+ json: "envelope",
77
+ usage: "ocx link reconnect --link-id <id> [--json]",
78
+ details: ["Requires a valid link ID and loopback admin authority. Stops the current ssh child, clears any backoff or failed state and starts a fresh attempt; read ocx link status for the outcome.","A Child-initiated link is refused with link_not_home_initiated: its own computer owns and retries that tunnel.","Always emits {linkId} JSON."],
79
+ },
68
80
  {
69
81
  command: ["remote-workspace","pair"],
70
82
  summary: "Enroll this executor with one Hub using a one-time code from stdin and locally approved roots.",
@@ -717,7 +729,7 @@ export const CAPABILITIES: readonly Capability[] = [
717
729
  flags: [{"name":"--json","value":"boolean","summary":"Emit the role list, the write result or the proposals as JSON."},{"name":"--effort","value":"string","summary":"set: also write the role's reasoning effort."},{"name":"--model","value":"string","summary":"suggest: size the roles with this model instead of the Codex default model."},{"name":"--apply","value":"boolean","summary":"suggest: write every proposal through the role model write."}],
718
730
  mutates: true,
719
731
  json: "payload",
720
- details: ["A bare invocation reads and never writes.","Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles, and set and suggest are refused.","set rewrites only the root model value of $CODEX_HOME/agents/<role>.toml, plus model_reasoning_effort with --effort; omo.jsonc gets the same model and, when LazyCodex has that level, the effort as reasoning; it is skipped when absent or when it contains comments.","suggest sizes every role with one model call and prints proposals without writing; --apply writes each proposed model, and its effort when the role file already sets model_reasoning_effort."],
732
+ details: ["A bare invocation reads and never writes.","Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles, and set and suggest are refused.","set rewrites only the root model value of $CODEX_HOME/agents/<role>.toml, plus model_reasoning_effort with --effort; omo.jsonc gets the same model and, when LazyCodex has that level, the effort as reasoning; it is skipped when absent or when it contains comments.","suggest sizes every role with one model call and prints proposals without writing; --apply writes each proposed model and effort."],
721
733
  usage: "ocx agent roles [status] [--json]; ocx agent roles set <role> <model> [--effort <level>] [--json]; ocx agent roles suggest [--model <id>] [--apply] [--json]",
722
734
  },
723
735
  {