@bitkyc08/opencodex 2.49.0 → 2.51.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BtyONQrZ.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-D7BdZpZm.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-BoBRSehJ.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.49.0",
3
+ "version": "2.51.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort";
4
4
  import { buildSystemPrompt } from "../coding-agent/protocol";
5
5
  import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn";
6
6
  import { QODER_PROFILES, type QoderProfile } from "./profiles";
7
+ import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard";
7
8
 
8
9
  export type QoderAdapterDeps = CodingAgentDeps;
9
10
 
@@ -31,6 +32,73 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo
31
32
  return args;
32
33
  }
33
34
 
35
+ /**
36
+ * Wrap the turn's outbound channel with the scaffolding guard (#4190).
37
+ *
38
+ * The vendor CLI can put its own agent layer into the text channel despite being launched
39
+ * with tools and MCP disabled, and the shared stream-json parser forwards a text delta
40
+ * without inspecting it. This is the last point that is still qoder-specific, so the guard
41
+ * sits here rather than in the parser every coding-agent CLI shares.
42
+ *
43
+ * A terminal event flushes both channels first. The held tail is text the filter could not
44
+ * yet prove was not the start of a marker; dropping it would truncate a legitimate answer,
45
+ * and swallowing an entire response before forwarding a "done" reads downstream as an empty
46
+ * completion rather than as the refusal it is.
47
+ */
48
+ export function guardQoderScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void {
49
+ const textFilter = new QoderScaffoldFilter();
50
+ const thinkingFilter = new QoderScaffoldFilter();
51
+ let closed = false;
52
+
53
+ const refuse = (reason: string): void => {
54
+ if (closed) return;
55
+ closed = true;
56
+ emit({
57
+ type: "error",
58
+ message: qoderScaffoldErrorMessage(reason),
59
+ status: 502,
60
+ errorType: "upstream_error",
61
+ code: QODER_SCAFFOLD_ERROR_CODE,
62
+ // Intermittent, but a silent retry spends the operator's vendor credits on a
63
+ // contract violation the proxy cannot influence. Surface it instead.
64
+ retryable: false,
65
+ });
66
+ };
67
+
68
+ return (event: AdapterEvent): void => {
69
+ if (closed) return;
70
+ if (event.type === "text_delta") {
71
+ const cleaned = textFilter.push(event.text);
72
+ if (cleaned.text) emit({ ...event, text: cleaned.text });
73
+ if (cleaned.fail) refuse(cleaned.fail);
74
+ return;
75
+ }
76
+ if (event.type === "thinking_delta") {
77
+ const cleaned = thinkingFilter.push(event.thinking);
78
+ if (cleaned.text) emit({ ...event, thinking: cleaned.text });
79
+ if (cleaned.fail) refuse(cleaned.fail);
80
+ return;
81
+ }
82
+ if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
83
+ const tail = textFilter.flush();
84
+ const reasoning = thinkingFilter.flush();
85
+ if (tail.text) emit({ type: "text_delta", text: tail.text });
86
+ if (reasoning.text) emit({ type: "thinking_delta", thinking: reasoning.text });
87
+ const fail = tail.fail ?? reasoning.fail;
88
+ // A vendor error already carries the better explanation for why the turn ended;
89
+ // only a terminal that claims success is replaced.
90
+ if (fail && event.type !== "error") {
91
+ refuse(fail);
92
+ return;
93
+ }
94
+ closed = true;
95
+ emit(event);
96
+ return;
97
+ }
98
+ emit(event);
99
+ };
100
+ }
101
+
34
102
  export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapterDeps = {}): ProviderAdapter {
35
103
  return {
36
104
  name: "qoder",
@@ -60,7 +128,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt
60
128
  provider,
61
129
  parsed,
62
130
  incoming,
63
- emit,
131
+ emit: guardQoderScaffolding(emit),
64
132
  buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov),
65
133
  buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey),
66
134
  deps,
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Vendor-scaffolding guard for the qoder route (#4190).
3
+ *
4
+ * The qoder route is contractually a text and reasoning surface: the CLI is spawned with
5
+ * `--tools "" --strict-mcp-config --setting-sources ""`, and Codex keeps tool ownership.
6
+ * The vendor CLI does not always honour that. It has been observed emitting its own agent
7
+ * layer into the assistant text channel — an MCP lazy-loading `<system-reminder>` block
8
+ * listing the local machine's configured MCP servers, and framework tool-call markup with a
9
+ * mismatched closer. Both reached the client verbatim, because the shared stream-json parser
10
+ * forwards a text delta without inspecting it.
11
+ *
12
+ * Two shapes, two answers. A complete `<system-reminder>` block is recognizable and
13
+ * self-delimiting, so it is removed and the surrounding answer survives. Anything else that
14
+ * carries a scaffolding signature is not repairable by guesswork — a partial tool-call block
15
+ * has no reliable end, and the text around it may already be the vendor's own agent
16
+ * narration rather than the model's answer — so the turn fails closed instead.
17
+ *
18
+ * The filter is a stream, not a regex over a finished string: a marker can be split across
19
+ * deltas, so a tail that is still a possible marker prefix is held back rather than emitted.
20
+ * Callers must therefore `flush()` before forwarding a terminal event, or a legitimate
21
+ * answer ending in "<" would lose its last character.
22
+ */
23
+
24
+ /** Error code for a turn refused because vendor scaffolding reached the text channel. */
25
+ export const QODER_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected";
26
+
27
+ const REMINDER_OPEN = "<system-reminder";
28
+ const REMINDER_CLOSE = "</system-reminder>";
29
+
30
+ /**
31
+ * Scaffolding signatures that are never repaired.
32
+ *
33
+ * `<functions.` and the invoke pair are the vendor runtime's tool-call markup. The reported
34
+ * leak carried `<functions.exec>` opened and `</invoke>` closed — mismatched, which is what
35
+ * a model emitting remembered markup looks like, and exactly why reconstructing the intended
36
+ * text is not possible. A stray `</system-reminder>` with no opener is in the same class:
37
+ * the block it belonged to was already partly forwarded, or never existed.
38
+ */
39
+ const UNREPAIRABLE_MARKERS = ["<functions.", "<invoke name=", "<invoke>", "</invoke>", REMINDER_CLOSE] as const;
40
+
41
+ /** Markers that end a block rather than start one; their prefix is never an answer. */
42
+ const CLOSING_MARKERS = new Set<string>(["</invoke>", REMINDER_CLOSE]);
43
+
44
+ /** Every marker the scanner must be able to recognize mid-split. */
45
+ const ALL_MARKERS = [REMINDER_OPEN, ...UNREPAIRABLE_MARKERS] as const;
46
+
47
+ const MAX_MARKER_LENGTH = Math.max(...ALL_MARKERS.map(marker => marker.length));
48
+
49
+ /**
50
+ * True when `<system-reminder` at `at` is the tag rather than the start of a longer word.
51
+ *
52
+ * The opener is matched without its `>` so a truncated or self-closed tag still suppresses,
53
+ * which means `<system-reminders>` in an ordinary answer would otherwise open a block and
54
+ * refuse the turn. A stem running to the end of the buffer still counts: more text may be
55
+ * arriving, and reading it as prose is the one reading that could release the block body.
56
+ */
57
+ function reminderOpensHere(lowered: string, at: number): boolean {
58
+ const after = lowered[at + REMINDER_OPEN.length];
59
+ return after === undefined || /[\s/>]/.test(after);
60
+ }
61
+
62
+ /**
63
+ * Ceiling on a suppressed block before it is treated as unterminated.
64
+ *
65
+ * The block itself is discarded as it arrives, so this is not a memory bound — only the
66
+ * trailing bytes needed to spot a split closer are retained. It bounds how much of a turn a
67
+ * single unclosed reminder is allowed to swallow silently before the turn is refused.
68
+ */
69
+ const MAX_SUPPRESSED_CHARS = 64 * 1024;
70
+
71
+ /** Result of feeding one chunk: the text safe to forward, and a refusal reason once tripped. */
72
+ export interface ScaffoldFilterResult {
73
+ /** Text cleared for the client. Empty when everything in the chunk was held or dropped. */
74
+ text: string;
75
+ /** Non-null exactly once, on the chunk that trips the guard. */
76
+ fail: string | null;
77
+ }
78
+
79
+ /** Longest suffix of `text` that could still grow into one of the markers. */
80
+ function heldSuffixLength(text: string): number {
81
+ const limit = Math.min(MAX_MARKER_LENGTH - 1, text.length);
82
+ for (let length = limit; length > 0; length--) {
83
+ const suffix = text.slice(text.length - length).toLowerCase();
84
+ for (const marker of ALL_MARKERS) {
85
+ if (marker.length > length && marker.startsWith(suffix)) return length;
86
+ }
87
+ }
88
+ return 0;
89
+ }
90
+
91
+ /**
92
+ * Streaming scaffolding filter for one channel (text or reasoning).
93
+ *
94
+ * One instance per channel: the two never share suppression state, so a reminder block
95
+ * opened in reasoning cannot swallow the answer text.
96
+ */
97
+ export class QoderScaffoldFilter {
98
+ private mode: "pass" | "suppress" = "pass";
99
+ private pending = "";
100
+ private suppressedTail = "";
101
+ private suppressedChars = 0;
102
+ private failed = false;
103
+ /** True once a reminder block has been suppressed on this channel. */
104
+ private suppressedBlock = false;
105
+ /** Open reminder blocks; only the closer that unwinds the last one ends suppression. */
106
+ private suppressDepth = 0;
107
+
108
+ push(chunk: string): ScaffoldFilterResult {
109
+ if (this.failed || !chunk) return { text: "", fail: null };
110
+ let cleared = "";
111
+ let buffer = this.mode === "pass" ? this.pending + chunk : chunk;
112
+ this.pending = "";
113
+
114
+ for (;;) {
115
+ if (this.mode === "suppress") {
116
+ const scan = this.suppressedTail + buffer;
117
+ const scanned = scan.toLowerCase();
118
+ // Unwind nesting rather than ending at the first closer. A reminder containing another
119
+ // reminder would otherwise hand the outer block's remaining body — the MCP server list
120
+ // in the reported leak — to the client as the model's answer, with a successful
121
+ // terminal and nothing to signal that anything had gone wrong.
122
+ let cursor = 0;
123
+ let close = -1;
124
+ for (;;) {
125
+ const nextClose = scanned.indexOf(REMINDER_CLOSE, cursor);
126
+ if (nextClose < 0) break;
127
+ let nextOpen = scanned.indexOf(REMINDER_OPEN, cursor);
128
+ while (nextOpen >= 0 && !reminderOpensHere(scanned, nextOpen)) {
129
+ nextOpen = scanned.indexOf(REMINDER_OPEN, nextOpen + 1);
130
+ }
131
+ if (nextOpen >= 0 && nextOpen < nextClose) {
132
+ this.suppressDepth += 1;
133
+ cursor = nextOpen + REMINDER_OPEN.length;
134
+ continue;
135
+ }
136
+ this.suppressDepth -= 1;
137
+ cursor = nextClose + REMINDER_CLOSE.length;
138
+ if (this.suppressDepth === 0) {
139
+ close = nextClose;
140
+ break;
141
+ }
142
+ }
143
+ if (close < 0) {
144
+ this.suppressedChars += buffer.length;
145
+ if (this.suppressedChars > MAX_SUPPRESSED_CHARS) {
146
+ return this.fail(cleared, `an unterminated ${REMINDER_OPEN}> block`);
147
+ }
148
+ // The block is discarded as it arrives; only enough tail to spot a split closer is kept.
149
+ // The tail must cover a split opener too, now that nesting is counted.
150
+ this.suppressedTail = scan.slice(Math.max(0, scan.length - (MAX_MARKER_LENGTH - 1)));
151
+ return { text: cleared, fail: null };
152
+ }
153
+ buffer = scan.slice(close + REMINDER_CLOSE.length);
154
+ this.mode = "pass";
155
+ this.suppressedTail = "";
156
+ this.suppressedChars = 0;
157
+ continue;
158
+ }
159
+
160
+ let earliest = -1;
161
+ let found = "";
162
+ const lowered = buffer.toLowerCase();
163
+ for (const marker of ALL_MARKERS) {
164
+ let at = lowered.indexOf(marker);
165
+ while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(lowered, at)) {
166
+ at = lowered.indexOf(marker, at + 1);
167
+ }
168
+ if (at < 0) continue;
169
+ // A closer sitting exactly where an opener starts cannot happen, so ties are impossible.
170
+ if (earliest < 0 || at < earliest) {
171
+ earliest = at;
172
+ found = marker;
173
+ }
174
+ }
175
+
176
+ if (earliest < 0) {
177
+ const held = heldSuffixLength(buffer);
178
+ cleared += held > 0 ? buffer.slice(0, buffer.length - held) : buffer;
179
+ this.pending = held > 0 ? buffer.slice(buffer.length - held) : "";
180
+ return { text: cleared, fail: null };
181
+ }
182
+
183
+ // Text produced before the scaffolding is the model's own answer, and it is kept — but
184
+ // only while this channel has not already suppressed a block. Once it has, the text
185
+ // between that block and an unrepairable marker is not an answer that happens to
186
+ // precede a leak; it is the region the vendor was narrating in, and in the reported
187
+ // case it carries the MCP server list. Forwarding it on the way to a refusal would
188
+ // publish exactly what the refusal exists to contain.
189
+ // A closer with no opener never keeps its prefix either: the block it belonged to was
190
+ // already partly forwarded or never existed, so the text ahead of it is that body.
191
+ if (!CLOSING_MARKERS.has(found) && (found === REMINDER_OPEN || !this.suppressedBlock)) {
192
+ cleared += buffer.slice(0, earliest);
193
+ }
194
+ if (found !== REMINDER_OPEN) return this.fail(cleared, `vendor tool-call markup (${found})`);
195
+ this.suppressedBlock = true;
196
+ this.mode = "suppress";
197
+ this.suppressDepth = 1;
198
+ this.suppressedTail = "";
199
+ this.suppressedChars = 0;
200
+ buffer = buffer.slice(earliest + REMINDER_OPEN.length);
201
+ }
202
+ }
203
+
204
+ /** Release the held tail. Call before forwarding a terminal event, never mid-stream. */
205
+ flush(): ScaffoldFilterResult {
206
+ if (this.failed) return { text: "", fail: null };
207
+ if (this.mode === "suppress") return this.fail("", `an unterminated ${REMINDER_OPEN}> block`);
208
+ const text = this.pending;
209
+ this.pending = "";
210
+ return { text, fail: null };
211
+ }
212
+
213
+ private fail(cleared: string, reason: string): ScaffoldFilterResult {
214
+ this.failed = true;
215
+ this.pending = "";
216
+ this.suppressedTail = "";
217
+ return { text: cleared, fail: reason };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Message for a refused turn.
223
+ *
224
+ * It names the marker class and nothing else. The leaked reminder in the report enumerated
225
+ * the operator's own MCP servers, so echoing the offending text back — into an error the
226
+ * client renders, and that a user may paste into an issue — would publish the thing this
227
+ * guard exists to contain.
228
+ */
229
+ export function qoderScaffoldErrorMessage(reason: string): string {
230
+ return `Qoder CLI emitted ${reason} in the assistant text channel. This route runs the CLI with`
231
+ + " its own tools and MCP servers disabled and Codex owns tool control, so the turn was refused"
232
+ + " rather than forwarding vendor agent scaffolding to the client.";
233
+ }
@@ -94,7 +94,23 @@ function entryParts(entry: string, config: OcxConfig): { alias: string; id: stri
94
94
  return { alias: claudeCodeNativeAlias(entry), id: entry, provider: "native" };
95
95
  }
96
96
 
97
- export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string, number>, configDir = claudeConfigDir()): ClaudeAgentDef[] {
97
+ export function buildClaudeAgentDefs(
98
+ config: OcxConfig,
99
+ windows: Record<string, number>,
100
+ configDir = claudeConfigDir(),
101
+ /**
102
+ * The roster to generate defs from, overriding local `config.subagentModels` (#4236).
103
+ *
104
+ * A connected client's local roster is whatever it had before it joined the hub — on a fresh
105
+ * client, the five native defaults — while the hub's featured roster is the list that
106
+ * actually routes. Passing it in keeps this function pure and keeps the override visible at
107
+ * the call site instead of hidden behind a config read.
108
+ *
109
+ * Undefined preserves today's behaviour exactly, including "unset means the defaults, an
110
+ * explicit `[]` means none".
111
+ */
112
+ rosterOverride?: readonly string[],
113
+ ): ClaudeAgentDef[] {
98
114
  const blockedSkills = effectiveBlockedSkillNames(config.claudeCode);
99
115
  const blockedSkillsFor = (model: string): readonly string[] => {
100
116
  const unmarked = stripOneMillionMarker(model);
@@ -137,7 +153,8 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string,
137
153
 
138
154
  // Default roster applies only when the field is UNSET — an explicit [] is
139
155
  // respected (audit 071 #6: an upgraded config must not lose the default five).
140
- const roster = config.subagentModels === undefined ? DEFAULT_SUBAGENT_MODELS : config.subagentModels;
156
+ const roster = rosterOverride
157
+ ?? (config.subagentModels === undefined ? DEFAULT_SUBAGENT_MODELS : config.subagentModels);
141
158
  for (const entry of roster.slice(0, 5)) {
142
159
  if (typeof entry !== "string" || entry.trim() === "") continue;
143
160
  const { alias, id, provider } = entryParts(entry.trim(), config);
@@ -260,13 +277,20 @@ export function syncClaudeAgentDefs(defs: readonly ClaudeAgentDef[], configDir =
260
277
  }
261
278
 
262
279
  /** Launch-time hook: gate + build + sync in one call (used by ocx claude and systemEnv). */
263
- export function injectClaudeAgentDefs(config: OcxConfig, windows: Record<string, number>, configDir?: string): string[] | null {
280
+ export function injectClaudeAgentDefs(
281
+ config: OcxConfig,
282
+ windows: Record<string, number>,
283
+ configDir?: string,
284
+ /** Hub-sourced roster on a connected client; see `buildClaudeAgentDefs`. */
285
+ rosterOverride?: readonly string[],
286
+ ): string[] | null {
264
287
  if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
265
288
  // Disabled: prune verified-owned files so stale definitions stop loading
266
- // in future sessions (audit 071 #3).
289
+ // in future sessions (audit 071 #3). The roster override is irrelevant here by
290
+ // construction: there is nothing to build.
267
291
  return syncClaudeAgentDefs([], configDir);
268
292
  }
269
- return syncClaudeAgentDefs(buildClaudeAgentDefs(config, windows, configDir), configDir);
293
+ return syncClaudeAgentDefs(buildClaudeAgentDefs(config, windows, configDir, rosterOverride), configDir);
270
294
  }
271
295
  /**
272
296
  * Dispatcher directive appended to every ocx-* description. The ocx-route body
@@ -22,6 +22,7 @@ import {
22
22
  type DesktopProfileModel,
23
23
  } from "./desktop-profile";
24
24
  import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog";
25
+ import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
25
26
  import { assertDesktop3pModelsValid } from "./desktop-3p-guard";
26
27
 
27
28
  export interface Desktop3pModelEntry {
@@ -322,9 +323,14 @@ export function activeDesktop3pAlias(provider: string, modelId: string): string
322
323
  * channel for supports1m/tier pins and it overrides discovery anyway (no merge), so
323
324
  * discovery stays off for determinism. supports1m makes Desktop offer a separate 1M
324
325
  * row; selecting it sends the bare id + `anthropic-beta: context-1m-2025-08-07`.
326
+ *
327
+ * `portOrOrigin` is the LOCAL destination Desktop should dial, already resolved by the caller
328
+ * (see `writeDesktop3pConfig`): on a hub that is the unauthenticated loopback listener, and with
329
+ * no listener the bind address — which is why an ORIGIN is accepted and not only a port. A bare
330
+ * port keeps meaning `http://127.0.0.1:<port>`, so every existing caller and test is unchanged.
325
331
  */
326
332
  export function generateDesktop3pConfig(
327
- port: number,
333
+ portOrOrigin: number | string,
328
334
  nativeSlugs: string[],
329
335
  routedModels: Array<Desktop3pRoutedModel>,
330
336
  apiKey = "ocx",
@@ -335,7 +341,7 @@ export function generateDesktop3pConfig(
335
341
  const base = {
336
342
  inferenceProvider: "gateway",
337
343
  inferenceCredentialKind: "static",
338
- inferenceGatewayBaseUrl: `http://127.0.0.1:${port}`,
344
+ inferenceGatewayBaseUrl: typeof portOrOrigin === "number" ? `http://127.0.0.1:${portOrOrigin}` : portOrOrigin,
339
345
  inferenceGatewayApiKey: apiKey,
340
346
  };
341
347
  if (mode === "discovery") {
@@ -620,8 +626,30 @@ export function writeDesktop3pConfig(
620
626
  if (connection.kind === "connected" || inspectRemoteDesktopCleanup().kind !== "absent") {
621
627
  return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_remote_store_active" };
622
628
  }
629
+ // Claude Desktop runs on this machine, so it dials the unauthenticated loopback listener
630
+ // when one is enabled — on such a hub that is the only credential-free local socket
631
+ // (#4236) — and otherwise the bind address, which answers but demands data-plane
632
+ // admission. Resolved here, from the config this write already re-read, rather than in
633
+ // the pure generator: `latest.config` is the freshest answer any caller could pass in.
634
+ const destination = localInferenceDestination(latest.config, port);
635
+ // Desktop can carry a credential, so it does: the key the caller passed (the first
636
+ // configured `apiKeys` entry), else the env token / hardened service token file. This is
637
+ // the DATA-PLANE secret only — an admin token must never enter an exported client
638
+ // configuration (reviewer constraint on #4236).
639
+ const gatewayKey = destination.requiresAdmissionToken
640
+ ? apiKey ?? localAdmissionToken(latest.config)
641
+ : apiKey;
642
+ if (destination.requiresAdmissionToken && !gatewayKey) {
643
+ // The placeholder the generator defaults to would 401 on this bind. Write the profile
644
+ // anyway — a reachable URL with a visible auth failure beats a dead socket — but say so.
645
+ console.error(
646
+ `⚠ Claude Desktop will dial ${destination.origin}, which requires an opencodex data-plane `
647
+ + "credential that could not be resolved. Configure an API key or enable "
648
+ + "`unauthenticatedLoopbackListener`.",
649
+ );
650
+ }
623
651
  return writeDesktop3pConfigWithGenerator(() => (
624
- generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap)
652
+ generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap)
625
653
  ));
626
654
  }), lifecycleLockDeps);
627
655
  } catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; }
@@ -13,7 +13,7 @@
13
13
  import { mkdirSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
- import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
16
+ import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
17
17
  import type { OcxConfig } from "../types";
18
18
 
19
19
  export interface GatewayModelRow {
@@ -24,7 +24,12 @@ export interface GatewayModelRow {
24
24
  export interface GatewayModelCacheRefreshOptions {
25
25
  timeoutMs?: number;
26
26
  configDir?: string;
27
- admissionConfig?: Pick<OcxConfig, "apiKeys">;
27
+ /**
28
+ * Admission credential source AND local destination source: the cache file's `baseUrl` must
29
+ * equal the `ANTHROPIC_BASE_URL` the CLI is launched with or Claude Code ignores the whole
30
+ * cache, so this has to resolve the same loopback listener `buildClaudeEnv` resolves (#4236).
31
+ */
32
+ admissionConfig?: Pick<OcxConfig, "apiKeys" | "hostname" | "unauthenticatedLoopbackListener">;
28
33
  env?: NodeJS.ProcessEnv;
29
34
  fetchImpl?: typeof fetch;
30
35
  }
@@ -60,19 +65,6 @@ export function writeGatewayModelCache(baseUrl: string, models: readonly Gateway
60
65
  }
61
66
  }
62
67
 
63
- /**
64
- * Hardened service-token file, the same precedence `ocx opencode` uses. A service
65
- * install writes the admission token to disk rather than the interactive environment,
66
- * so an interactive `ocx claude` with neither env token nor configured key would
67
- * otherwise still get a 401 and keep a stale picker list.
68
- */
69
- function serviceFileToken(env: NodeJS.ProcessEnv): string | null {
70
- const lookup = env.OCX_API_TOKEN_FILE?.trim()
71
- ? env
72
- : { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() };
73
- return loadServiceTokenFromFile(lookup as Record<string, string | undefined>);
74
- }
75
-
76
68
  /** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
77
69
  export async function refreshGatewayModelCacheFromProxy(
78
70
  port: number,
@@ -92,17 +84,16 @@ export async function refreshGatewayModelCacheFromProxy(
92
84
  // request sent to its local 127.0.0.1 address. Reuse the same dedicated
93
85
  // credential domain as /v1/models admission; never place it in Authorization,
94
86
  // which can belong to an upstream provider on other data-plane surfaces.
95
- const envToken = (options.env ?? process.env).OPENCODEX_API_AUTH_TOKEN?.trim();
96
- const configuredToken = options.admissionConfig?.apiKeys
97
- ?.find(entry => entry.key.trim().length > 0)
98
- ?.key.trim();
87
+ // Env token, then the hardened service token file (a service install writes the admission
88
+ // token to disk rather than the interactive environment), then a configured key — one
89
+ // shared ladder, so this cannot drift from what `buildClaudeEnv` puts in the launch env.
99
90
  const admissionToken = typeof portOrTarget === "number"
100
- ? envToken || serviceFileToken(options.env ?? process.env) || configuredToken
91
+ ? localAdmissionToken(options.admissionConfig, options.env ?? process.env)
101
92
  : portOrTarget.admissionToken;
102
93
  if (admissionToken) headers.set("x-opencodex-api-key", admissionToken);
103
94
 
104
95
  const baseUrl = typeof portOrTarget === "number"
105
- ? `http://127.0.0.1:${portOrTarget}`
96
+ ? localInferenceDestination(options.admissionConfig, portOrTarget).origin
106
97
  : new URL(portOrTarget.baseUrl).origin;
107
98
 
108
99
  // ?ids=cli pins the readable claude-ocx id family deterministically (audit 051
@@ -150,10 +150,17 @@ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly str
150
150
 
151
151
  /**
152
152
  * Claude Code (observed 2026-07-11, real CLI smoke) sends `role:"system"` entries in
153
- * `messages` despite the published API having no system role. Map them to Responses
154
- * instructions text: the native ChatGPT backend rejects system message items in
155
- * `input` ("System messages are not allowed", verified live), so folding into
156
- * `instructions` is the only shape that works on every route.
153
+ * `messages` despite the published API having no system role. They are emitted as
154
+ * chronological `role:"developer"` input items, which keeps the timeline intact and
155
+ * leaves `instructions` owned solely by the top-level Anthropic `system` field.
156
+ *
157
+ * The original mapping folded them into `instructions` because the native ChatGPT
158
+ * backend rejects `role:"system"` items in `input` ("System messages are not allowed",
159
+ * verified live). That constraint is real and still respected — but it only rules out
160
+ * `system`, not `developer`, which every Responses route accepts. Folding meant each
161
+ * mid-conversation reminder mutated the prompt head, invalidating the upstream KV
162
+ * prefix and rotating the Desktop `prompt_cache_key` fallback below on every turn
163
+ * (#4148).
157
164
  */
158
165
  function systemMessageText(content: unknown): string {
159
166
  if (typeof content === "string") return content;
@@ -333,7 +340,12 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef
333
340
  else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, budget);
334
341
  else if (msg.role === "system") {
335
342
  const text = systemMessageText(msg.content);
336
- if (text.length > 0) systemParts.push(text);
343
+ // Keep it where the client put it. `developer` is first-class in the Responses
344
+ // schema and survives parseRequest as a chronological message, where `system`
345
+ // would be re-hoisted back onto the system prompt and defeat the point.
346
+ if (text.length > 0) {
347
+ input.push({ type: "message", role: "developer", content: [{ type: "input_text", text }] });
348
+ }
337
349
  }
338
350
  else throw new AnthropicRequestError(`unsupported message role: ${String(msg.role)}`);
339
351
  }
@@ -18,10 +18,16 @@ export interface AccountRow {
18
18
  id: string;
19
19
  label?: string;
20
20
  email?: string;
21
- plan?: string;
21
+ /**
22
+ * Subscription tier. `null` means this version looked and the provider did not report one;
23
+ * an absent key means the row's surface does not carry a tier at all (#3777).
24
+ */
25
+ plan?: string | null;
22
26
  masked?: string;
23
27
  active: boolean;
24
28
  needsReauth?: boolean;
29
+ /** Registered credential that is still excluded from routing until validation completes. */
30
+ validationPending?: boolean;
25
31
  /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
26
32
  priority?: number;
27
33
  quota?: CodexQuotaDto | null;
@@ -237,6 +243,7 @@ interface CodexAccountDto {
237
243
  plan?: string;
238
244
  isMain?: boolean;
239
245
  needsReauth?: boolean;
246
+ health?: { reason?: string };
240
247
  priority?: number;
241
248
  quota?: CodexQuotaDto | null;
242
249
  quotaRefresh?: unknown;
@@ -263,10 +270,12 @@ export async function fetchCodexRows(
263
270
  baseUrl: string,
264
271
  forceRefresh = false,
265
272
  includeQuota = forceRefresh,
273
+ options: { refreshAction?: boolean } = {},
266
274
  ): Promise<FamilyRows> {
267
- const accountsPath = `/api/codex-auth/accounts${forceRefresh ? "?refresh=1" : ""}`;
275
+ const refreshAction = options.refreshAction === true;
276
+ const accountsPath = `/api/codex-auth/accounts${refreshAction ? "/refresh" : forceRefresh ? "?refresh=1" : ""}`;
268
277
  const [accountsRes, activeRes] = await Promise.all([
269
- apiJson(deps, baseUrl, "GET", accountsPath),
278
+ apiJson(deps, baseUrl, refreshAction ? "POST" : "GET", accountsPath),
270
279
  apiJson(deps, baseUrl, "GET", "/api/codex-auth/active"),
271
280
  ]);
272
281
  if (accountsRes.status !== 0 && accountsRes.status !== 200) {
@@ -300,6 +309,7 @@ export async function fetchCodexRows(
300
309
  plan: a.plan,
301
310
  active: a.id === activeId,
302
311
  needsReauth: a.needsReauth,
312
+ ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}),
303
313
  priority: typeof a.priority === "number" ? a.priority : 0,
304
314
  paused: a.paused === true,
305
315
  ...(includeQuota ? {
@@ -316,6 +326,8 @@ interface OAuthAccountDto {
316
326
  email?: string;
317
327
  active?: boolean;
318
328
  needsReauth?: boolean;
329
+ /** Always sent by the management route; explicitly `null` when the tier is unknown. */
330
+ plan?: string | null;
319
331
  quota?: CodexQuotaDto | null;
320
332
  quotaUnavailable?: boolean;
321
333
  }
@@ -346,6 +358,9 @@ async function fetchOAuthRows(
346
358
  email: a.email,
347
359
  active: a.active ?? a.id === activeId,
348
360
  needsReauth: a.needsReauth,
361
+ // Forward the server's answer verbatim, including `null`. Collapsing null to "absent" here
362
+ // would destroy the one distinction this field exists to make.
363
+ plan: a.plan ?? null,
349
364
  ...(a.quota !== undefined ? { quota: a.quota } : {}),
350
365
  ...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}),
351
366
  }));
@@ -154,7 +154,14 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
154
154
  {}, deps,
155
155
  );
156
156
  if (state.status === "done") {
157
- printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]);
157
+ if (state.validationPending === true) {
158
+ printData({ ...state, recoveryCommand: "ocx gui", recoveryAction: "After quota recovers, click Refresh quotas in the dashboard Codex account pool." }, wantsJson, [
159
+ "Account registered; validation pending (routing disabled).",
160
+ "After quota recovers, open 'ocx gui' and click Refresh quotas to complete validation.",
161
+ ]);
162
+ } else {
163
+ printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]);
164
+ }
158
165
  if (!wantsJson) warnIfCodexCatalogRefreshPending(state);
159
166
  return;
160
167
  }