@bitkyc08/opencodex 2.50.0 → 2.52.0-preview.20260911

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 (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/qoder/adapter.ts +69 -1
  6. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  7. package/src/claude/agents-inject.ts +29 -5
  8. package/src/claude/desktop-3p.ts +31 -3
  9. package/src/claude/gateway-cache.ts +12 -21
  10. package/src/cli/capabilities.ts +28 -0
  11. package/src/cli/claude-agent-startup-sync.ts +26 -1
  12. package/src/cli/claude.ts +138 -20
  13. package/src/cli/config-command.ts +67 -1
  14. package/src/cli/connect.ts +181 -14
  15. package/src/cli/dispatch.ts +53 -9
  16. package/src/cli/doctor.ts +9 -2
  17. package/src/cli/ensure-desired-integrations.ts +10 -0
  18. package/src/cli/gui-pair-client.ts +1 -12
  19. package/src/cli/help.ts +4 -1
  20. package/src/cli/hub.ts +367 -0
  21. package/src/cli/index.ts +94 -30
  22. package/src/cli/launcher-context.ts +1 -1
  23. package/src/cli/registry.ts +43 -3
  24. package/src/cli/status.ts +325 -5
  25. package/src/cli/version-skew.ts +4 -1
  26. package/src/cli.ts +2 -2
  27. package/src/client/catalog-compatibility.ts +192 -0
  28. package/src/client/connect.ts +31 -0
  29. package/src/client/hub-client.ts +52 -0
  30. package/src/client/hub-state.ts +214 -0
  31. package/src/codex/account-usability.ts +48 -12
  32. package/src/codex/auth-api.ts +49 -5
  33. package/src/codex/catalog/effort.ts +67 -8
  34. package/src/codex/catalog/sync.ts +85 -0
  35. package/src/codex/codex-write-lock.ts +11 -2
  36. package/src/codex/desired-state.ts +47 -1
  37. package/src/codex/inject-coordination.ts +10 -5
  38. package/src/codex/inject.ts +26 -10
  39. package/src/codex/loopback-target.ts +45 -0
  40. package/src/codex/routing.ts +48 -1
  41. package/src/codex/runtime.ts +37 -3
  42. package/src/codex/sync.ts +29 -9
  43. package/src/codex/warmup.ts +21 -4
  44. package/src/config/pending-teardown.ts +1 -1
  45. package/src/config.ts +126 -12
  46. package/src/generated/compatibility-version.json +136 -76
  47. package/src/grok/status.ts +9 -1
  48. package/src/integrations/config-io.ts +54 -1
  49. package/src/lib/bun-runtime.ts +1 -1
  50. package/src/lib/gui-pair-capability.ts +27 -0
  51. package/src/lib/local-destinations.ts +162 -0
  52. package/src/lib/package-tree-integrity.ts +1 -1
  53. package/src/lib/process-control.ts +130 -20
  54. package/src/lib/service-secrets.ts +28 -0
  55. package/src/lib/test-home-guard.ts +49 -0
  56. package/src/providers/opencode-go-transport.ts +9 -1
  57. package/src/providers/quota.ts +5 -1
  58. package/src/providers/registry.ts +34 -5
  59. package/src/remote/hub-state.ts +182 -0
  60. package/src/server/auth-cors.ts +5 -0
  61. package/src/server/chat-completions.ts +6 -3
  62. package/src/server/claude-messages.ts +7 -1
  63. package/src/server/hub-state.ts +98 -0
  64. package/src/server/index.ts +124 -6
  65. package/src/server/management/api-access.ts +14 -3
  66. package/src/server/management/config-routes.ts +2 -2
  67. package/src/server/management/cursor-integration-routes.ts +13 -4
  68. package/src/server/proxy-liveness.ts +7 -1
  69. package/src/server/request-log-conversation.ts +41 -1
  70. package/src/server/responses/codex-auth-error.ts +18 -1
  71. package/src/server/responses/codex-ws-exchange.ts +36 -4
  72. package/src/server/responses/codex-ws-wire.ts +75 -4
  73. package/src/server/responses/compact.ts +20 -9
  74. package/src/server/responses/core.ts +57 -10
  75. package/src/server/responses/policy-fallback.ts +7 -1
  76. package/src/server/system-env-shell.ts +14 -2
  77. package/src/server/system-env.ts +106 -14
  78. package/src/service.ts +906 -94
  79. package/src/types/config.ts +57 -4
  80. package/src/update/badge.ts +3 -2
  81. package/src/update/index.ts +317 -64
  82. package/src/update/install-detection.d.mts +6 -0
  83. package/src/update/install-detection.mjs +73 -0
  84. package/src/update/job.ts +101 -49
  85. package/src/update/pnpm-global-install.d.mts +144 -0
  86. package/src/update/pnpm-global-install.mjs +591 -0
  87. package/src/update/pnpm-invocation.d.mts +43 -0
  88. package/src/update/pnpm-invocation.mjs +141 -0
  89. package/src/update/registry-integrity.d.mts +16 -0
  90. package/src/update/registry-integrity.mjs +37 -0
  91. package/src/update/transactional-install.d.mts +1 -1
  92. package/src/update/transactional-install.mjs +101 -7
  93. package/src/update/tray-update-plan.mjs +1 -1
  94. package/src/vision/plan.ts +13 -3
  95. package/src/vision/routed-describe.ts +51 -20
@@ -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
@@ -132,6 +132,34 @@ export const CAPABILITIES: readonly Capability[] = [
132
132
  json: "envelope",
133
133
  details: ["Reads /healthz plus local config; drives no management API route."],
134
134
  },
135
+ {
136
+ command: ["hub", "invite"],
137
+ summary: "Mint a single-use pairing code on a hub and print the exact `ocx connect` line for one more machine.",
138
+ // Deliberately empty. The command DOES drive `POST /api/gui/pairing-grants` -- the attested
139
+ // local mint route `ocx gui pair` uses, authorized by a capability HMAC'd with the running
140
+ // proxy's own attestation secret rather than by the admin token, which is why it needs
141
+ // nothing exported in the shell. That route is answered in the composition root, ahead of
142
+ // `handleManagementAPI`, so it is not in MANAGEMENT_ROUTES; declaring it here would fail the
143
+ // capability/registry reconciliation rather than inform anyone. Widening the registry's scope
144
+ // to `src/server/index.ts` is its own change.
145
+ routes: [],
146
+ flags: [
147
+ { name: "--json", value: "boolean", summary: "Emit code, expiresAt, dataUrl, managementUrl, and command." },
148
+ { name: "--data-url", value: "string", summary: "Advertise this data origin instead of hub.dataPublicOrigin or the bind address." },
149
+ { name: "--management-url", value: "string", summary: "Confirm the management origin; it must equal hub.managementPublicOrigin." },
150
+ { name: "--clients", value: "string", summary: "Pre-select codex and/or claude in the printed connect command." },
151
+ ],
152
+ mutates: true,
153
+ json: "envelope",
154
+ details: [
155
+ "Hub only: refuses when runtimeRole is not hub, and requires a running attested proxy.",
156
+ "The code is secret, single-use and short-lived; it is bound to hub.managementPublicOrigin and to the connecting machine's loopback browser origin.",
157
+ "The bound browser origin is always printed; when it is not http://localhost:10100 the warning names the port the connecting machine must use.",
158
+ "Refuses when the advertised data origin would be loopback (a loopback or wildcard bind with no hub.dataPublicOrigin and no --data-url) rather than printing a line that dials the other machine itself.",
159
+ "Prints no data-plane token. Remote machines receive their own revocable per-client key from the exchange.",
160
+ "Mints through the attested local pairing-grant route, the same one ocx gui pair uses; no admin token is read.",
161
+ ],
162
+ },
135
163
  {
136
164
  command: ["connect", "rotate"],
137
165
  summary: "Rotate the connected client's data key against the hub, with commit and abort.",
@@ -1,5 +1,6 @@
1
1
  import type { OcxConfig } from "../types";
2
2
  import { injectClaudeAgentDefs } from "../claude/agents-inject";
3
+ import { readCachedHubState } from "../client/hub-state";
3
4
  import { fetchClaudeContextWindows } from "./claude";
4
5
  import type { ReadinessGate } from "../server/readiness";
5
6
 
@@ -7,6 +8,30 @@ export interface ClaudeAgentStartupSyncDeps {
7
8
  fetchContextWindows?: typeof fetchClaudeContextWindows;
8
9
  injectAgentDefs?: typeof injectClaudeAgentDefs;
9
10
  warn?: (message: string) => void;
11
+ /** Seam for the hub roster lookup; the default reads only the on-disk cache. */
12
+ readHubRoster?: (config: OcxConfig) => readonly string[] | undefined;
13
+ }
14
+
15
+ /**
16
+ * The hub's roster for a connected client, from the CACHE only (#4236).
17
+ *
18
+ * Startup deliberately makes no network call for this. The roster is a convenience here — `ocx
19
+ * claude` does the live read on the path where it matters — and a hub round trip on every proxy
20
+ * start would put an offline hub in the way of a local launch. Undefined falls back to local
21
+ * `subagentModels`, which is what this path has always used.
22
+ */
23
+ function cachedHubRoster(config: OcxConfig): readonly string[] | undefined {
24
+ if (config.runtimeRole !== "client" || !config.client) return undefined;
25
+ try {
26
+ const cached = readCachedHubState({
27
+ serverUrl: config.client.serverUrl,
28
+ apiKeyId: config.client.apiKeyId,
29
+ connectedAt: config.client.connectedAt,
30
+ });
31
+ return cached?.state.subagentModels;
32
+ } catch {
33
+ return undefined;
34
+ }
10
35
  }
11
36
 
12
37
  /**
@@ -70,7 +95,7 @@ export async function syncClaudeAgentDefsAtProxyStartup(
70
95
  // Startup remains best-effort. The next management mutation or `ocx claude` launch can
71
96
  // restore context markers after a transient catalog/Management API failure.
72
97
  }
73
- return inject(config, windows);
98
+ return inject(config, windows, undefined, (deps.readHubRoster ?? cachedHubRoster)(config));
74
99
  } catch (error) {
75
100
  warn(`⚠ Claude agent definitions could not be synced at proxy startup: ${error instanceof Error ? error.message : String(error)}`);
76
101
  return null;