@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
package/src/cli/hub.ts ADDED
@@ -0,0 +1,367 @@
1
+ import type { OcxConfig, OcxConnectedClientId } from "../types";
2
+ import { canonicalGuiBrowserOrigin, canonicalHttpOrigin } from "../lib/gui-pair-capability";
3
+ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
4
+ import {
5
+ requestBoundGuiPairingGrant,
6
+ type GuiPairClientDeps,
7
+ type GuiPairRequestResult,
8
+ } from "./gui-pair-client";
9
+ import type { RuntimeApiDeps } from "./runtime-api";
10
+
11
+ export const HUB_USAGE =
12
+ "ocx hub invite [--json] [--data-url <origin>] [--management-url <origin>] [--clients codex,claude]";
13
+
14
+ const PAIRING_WARNING = "Pairing codes are secret, single-use, and expire quickly. Do not save them.";
15
+
16
+ /**
17
+ * The default browser origin a connecting machine presents.
18
+ *
19
+ * `ocx connect --pairing-code-stdin` exchanges the code with `Origin:` set by `localGuiOrigin()`
20
+ * in `src/client/connect.ts` — `http://localhost:<that machine's configured port>`, which on a
21
+ * fresh client is the default 10100. The hub cannot observe the other machine's port, so the
22
+ * grant is bound to this origin unless `corsAllowOrigins` names a different loopback one.
23
+ */
24
+ const DEFAULT_CLIENT_BROWSER_ORIGIN = "http://localhost:10100";
25
+
26
+ export interface HubCommandDeps extends RuntimeApiDeps {
27
+ loadConfig: () => OcxConfig;
28
+ findLiveProxy?: () => Promise<LiveProxy | null>;
29
+ requestPairingGrant?: (
30
+ target: LiveProxy,
31
+ browserOrigin: string,
32
+ deps?: GuiPairClientDeps,
33
+ ) => Promise<GuiPairRequestResult>;
34
+ }
35
+
36
+ export interface HubInviteOptions {
37
+ json: boolean;
38
+ dataUrl?: string;
39
+ managementUrl?: string;
40
+ clients?: string;
41
+ }
42
+
43
+ export interface HubInvitePayload {
44
+ code: string;
45
+ expiresAt: string;
46
+ dataUrl: string;
47
+ managementUrl: string;
48
+ command: string;
49
+ }
50
+
51
+ function isLoopbackOrigin(origin: string): boolean {
52
+ try {
53
+ const host = new URL(origin).hostname.toLowerCase();
54
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** Loopback or HTTPS, the rule `consumeGuiPairingGrant` enforces on the hub side. */
61
+ export function pairingOriginUsable(origin: string): boolean {
62
+ try {
63
+ return new URL(origin).protocol === "https:" || isLoopbackOrigin(origin);
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ export function parseHubInviteArgs(args: string[]): HubInviteOptions | null {
70
+ const options: HubInviteOptions = { json: false };
71
+ for (let index = 0; index < args.length; index++) {
72
+ const arg = args[index]!;
73
+ if (arg === "--json" && !options.json) {
74
+ options.json = true;
75
+ continue;
76
+ }
77
+ if (arg === "--data-url" || arg === "--management-url" || arg === "--clients") {
78
+ const value = args[++index];
79
+ if (!value || value.startsWith("--")) return null;
80
+ const key = arg === "--data-url" ? "dataUrl" : arg === "--management-url" ? "managementUrl" : "clients";
81
+ if (options[key] !== undefined) return null;
82
+ options[key] = value;
83
+ continue;
84
+ }
85
+ return null;
86
+ }
87
+ return options;
88
+ }
89
+
90
+ export function parseInviteClients(raw: string | undefined): OcxConnectedClientId[] | null {
91
+ if (raw === undefined) return [];
92
+ const values = raw.split(",").map(value => value.trim()).filter(Boolean);
93
+ if (values.length < 1 || values.some(value => value !== "codex" && value !== "claude")) return null;
94
+ return values as OcxConnectedClientId[];
95
+ }
96
+
97
+ /**
98
+ * The browser origin the grant is bound to, or null when the hub admits none.
99
+ *
100
+ * `createGuiPairingGrant` accepts only `hub.managementPublicOrigin` itself or an entry of
101
+ * `corsAllowOrigins`, so this picks from exactly that set rather than guessing: the default
102
+ * client origin when it is admitted, otherwise the first admitted loopback origin. A hub whose
103
+ * allow-list names no loopback origin cannot pair a remote `ocx connect` at all, and saying so
104
+ * here is better than minting a code the exchange will reject.
105
+ */
106
+ export function selectInviteBrowserOrigin(config: OcxConfig): string | null {
107
+ const allowed = [
108
+ canonicalGuiBrowserOrigin(config.hub?.managementPublicOrigin ?? ""),
109
+ ...(config.corsAllowOrigins ?? []).map(value => canonicalGuiBrowserOrigin(value)),
110
+ ].filter((value): value is string => Boolean(value));
111
+ if (allowed.includes(DEFAULT_CLIENT_BROWSER_ORIGIN)) return DEFAULT_CLIENT_BROWSER_ORIGIN;
112
+ return allowed.find(origin => isLoopbackOrigin(origin)) ?? null;
113
+ }
114
+
115
+ /** `http://<bind address>:<port>` — right for a plain tailnet/LAN bind with no TLS frontend. */
116
+ export function derivedHubDataOrigin(hostname: string | undefined, port: number): string {
117
+ const host = probeHostname(hostname);
118
+ return `http://${host === "127.0.0.1" ? "localhost" : host}:${port}`;
119
+ }
120
+
121
+ /**
122
+ * The `ocx config set` lines that will actually work on THIS config.
123
+ *
124
+ * `setPath` in `src/cli/config-command.ts` walks only parents that already exist, so
125
+ * `ocx config set hub.<field> …` exits with `config parent path not found: hub` on a config
126
+ * that has no `hub` object yet — which is exactly the config that needs the advice. Create the
127
+ * parent first, the way `guides/remote-hub.md` does, and only when it is actually missing, so
128
+ * the operator can paste the lines verbatim either way.
129
+ */
130
+ export function configSetHubLines(
131
+ config: Pick<OcxConfig, "hub">,
132
+ field: "managementPublicOrigin" | "dataPublicOrigin",
133
+ example: string,
134
+ ): string[] {
135
+ const set = `ocx config set hub.${field} '${JSON.stringify(example)}'`;
136
+ return config.hub ? [set] : ["ocx config set hub '{}'", set];
137
+ }
138
+
139
+ /**
140
+ * A `corsAllowOrigins` line that ADDS an origin instead of replacing the list.
141
+ *
142
+ * `ocx config set corsAllowOrigins '[…]'` overwrites the array, so printing a one-element
143
+ * literal tells an operator with an existing allow-list to delete it. The already-configured
144
+ * entries are known here, so the suggested value carries them.
145
+ */
146
+ export function appendCorsAllowOriginsCommand(
147
+ config: Pick<OcxConfig, "corsAllowOrigins">,
148
+ origin: string,
149
+ ): string {
150
+ const current = config.corsAllowOrigins ?? [];
151
+ const next = current.includes(origin) ? current : [...current, origin];
152
+ return `ocx config set corsAllowOrigins '${JSON.stringify(next)}'`;
153
+ }
154
+
155
+ export type HubDataOriginResolution =
156
+ | { kind: "usable"; dataUrl: string; source: "flag" | "config" | "derived" }
157
+ /** The bind address can only be spelled as loopback, so there is nothing to advertise. */
158
+ | { kind: "loopback-derived"; dataUrl: string; bindHostname: string };
159
+
160
+ /**
161
+ * The data origin to advertise, or a refusal.
162
+ *
163
+ * `derivedHubDataOrigin` maps a loopback bind AND every wildcard spelling to
164
+ * `http://localhost:<port>` (via `probeHostname`), which on the other machine means "dial
165
+ * yourself". Printing it burns the single-use code on a connect that cannot succeed, so a
166
+ * derived loopback origin is a refusal rather than a value. A wildcard bind is refused the same
167
+ * way on purpose: nothing in this repo derives a tailnet or LAN address, and guessing one from
168
+ * `os.networkInterfaces()` would advertise an interface the operator never chose.
169
+ *
170
+ * An explicit `--data-url` or `hub.dataPublicOrigin` is never second-guessed — a loopback data
171
+ * origin is legitimate when the "other machine" is reached through an SSH tunnel.
172
+ */
173
+ export function resolveHubDataOrigin(
174
+ override: string | null,
175
+ configured: string | undefined,
176
+ bindHostname: string | undefined,
177
+ port: number,
178
+ ): HubDataOriginResolution {
179
+ if (override) return { kind: "usable", dataUrl: override, source: "flag" };
180
+ const fromConfig = canonicalHttpOrigin(configured);
181
+ if (fromConfig) return { kind: "usable", dataUrl: fromConfig, source: "config" };
182
+ const derived = derivedHubDataOrigin(bindHostname, port);
183
+ if (!isLoopbackOrigin(derived)) return { kind: "usable", dataUrl: derived, source: "derived" };
184
+ const trimmed = (bindHostname ?? "").trim();
185
+ return { kind: "loopback-derived", dataUrl: derived, bindHostname: trimmed || "127.0.0.1" };
186
+ }
187
+
188
+ /** Wildcards and loopback fail for different reasons; say which one this hub has. */
189
+ function bindAddressPhrase(bindHostname: string): string {
190
+ return bindHostname === "0.0.0.0" || bindHostname === "::" || bindHostname === "[::]"
191
+ ? `the bind address ${bindHostname} is a wildcard, which names no address another machine can dial`
192
+ : `the bind address ${bindHostname} is loopback-only`;
193
+ }
194
+
195
+ /**
196
+ * What an operator has to know about the origin the grant actually got bound to.
197
+ *
198
+ * `selectInviteBrowserOrigin` falls back to the first admitted loopback origin when
199
+ * `http://localhost:10100` is not admitted, and a remote `ocx connect` sends
200
+ * `Origin: http://localhost:<its own configured port>` — so a grant bound to anything else is
201
+ * refused at the exchange and the single-use code is spent with nothing printed to explain it.
202
+ * Always stating the bound origin, and naming the port the client needs when it differs, is the
203
+ * difference between a fixable failure and a mystery.
204
+ */
205
+ export function inviteBoundOriginNotes(
206
+ browserOrigin: string,
207
+ config: Pick<OcxConfig, "corsAllowOrigins">,
208
+ ): string[] {
209
+ const notes = [`Bound browser origin: ${browserOrigin} — the connecting machine must present exactly this.`];
210
+ if (browserOrigin === DEFAULT_CLIENT_BROWSER_ORIGIN) return notes;
211
+ const parsed = new URL(browserOrigin);
212
+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
213
+ notes.push(
214
+ `That is NOT ${DEFAULT_CLIENT_BROWSER_ORIGIN}, which is what an unconfigured client sends: the other machine `
215
+ + `must already be running on port ${port} ('ocx config set port ${port}' there) before it runs the line `
216
+ + "below, or the hub refuses the exchange and the code is spent.",
217
+ );
218
+ notes.push(
219
+ "To accept a default client instead, admit its origin on this hub: "
220
+ + appendCorsAllowOriginsCommand(config, DEFAULT_CLIENT_BROWSER_ORIGIN),
221
+ );
222
+ return notes;
223
+ }
224
+
225
+ export function hubInviteCommand(
226
+ code: string,
227
+ dataUrl: string,
228
+ managementUrl: string,
229
+ clients: OcxConnectedClientId[],
230
+ ): string {
231
+ const clientsFlag = clients.length > 0 ? ` --clients ${clients.join(",")}` : "";
232
+ return `echo '${code}' | ocx connect ${dataUrl} --management-url ${managementUrl}${clientsFlag} --pairing-code-stdin`;
233
+ }
234
+
235
+ async function runInvite(args: string[], deps: HubCommandDeps): Promise<number> {
236
+ const options = parseHubInviteArgs(args);
237
+ if (!options) {
238
+ console.error(`Usage: ${HUB_USAGE}`);
239
+ return 1;
240
+ }
241
+ const clients = parseInviteClients(options.clients);
242
+ if (!clients) {
243
+ console.error("--clients must contain codex and/or claude.");
244
+ return 1;
245
+ }
246
+ const config = deps.loadConfig();
247
+ if (config.runtimeRole !== "hub") {
248
+ console.error(
249
+ `ocx hub invite runs on a hub; this machine's runtimeRole is "${config.runtimeRole ?? "standalone"}". `
250
+ + "A client machine runs 'ocx connect' with the code its hub printed.",
251
+ );
252
+ return 1;
253
+ }
254
+ // The grant's server origin IS hub.managementPublicOrigin (createGuiPairingGrant reads it,
255
+ // and the exchange compares the request's management origin against it), so an invite that
256
+ // advertised anything else would hand out a code the hub then refuses.
257
+ const managementPublic = canonicalHttpOrigin(config.hub?.managementPublicOrigin);
258
+ if (!managementPublic) {
259
+ console.error(
260
+ "hub.managementPublicOrigin is not set, so there is no origin to pair against. Set the exact "
261
+ + "browser-visible HTTPS origin:",
262
+ );
263
+ for (const line of configSetHubLines(config, "managementPublicOrigin", "https://hub.tailnet.ts.net")) {
264
+ console.error(` ${line}`);
265
+ }
266
+ return 1;
267
+ }
268
+ const managementOverride = options.managementUrl === undefined ? null : canonicalHttpOrigin(options.managementUrl);
269
+ if (options.managementUrl !== undefined && !managementOverride) {
270
+ console.error("--management-url must be a bare http(s) origin with no path, query, or credentials.");
271
+ return 1;
272
+ }
273
+ if (managementOverride && managementOverride !== managementPublic) {
274
+ console.error(
275
+ `--management-url ${managementOverride} does not match hub.managementPublicOrigin ${managementPublic}. `
276
+ + "The pairing code is bound to the configured origin, so the other machine would be refused.",
277
+ );
278
+ return 1;
279
+ }
280
+ if (!pairingOriginUsable(managementPublic)) {
281
+ console.error(
282
+ `hub.managementPublicOrigin ${managementPublic} is non-loopback plain HTTP, which cannot carry a `
283
+ + "pairing code. Put management behind an HTTPS frontend (Tailscale Serve) and set that origin.",
284
+ );
285
+ return 1;
286
+ }
287
+ const dataOverride = options.dataUrl === undefined ? null : canonicalHttpOrigin(options.dataUrl);
288
+ if (options.dataUrl !== undefined && !dataOverride) {
289
+ console.error("--data-url must be a bare http(s) origin with no path, query, or credentials.");
290
+ return 1;
291
+ }
292
+ const browserOrigin = selectInviteBrowserOrigin(config);
293
+ if (!browserOrigin) {
294
+ // `ocx config set corsAllowOrigins` REPLACES the array, so the suggested value carries the
295
+ // entries this hub already has -- a one-element literal would tell the operator to drop them.
296
+ console.error(
297
+ "No loopback browser origin is admitted for pairing. Add the connecting machine's local origin "
298
+ + "(this keeps the entries already configured; 'ocx config get corsAllowOrigins' shows them): "
299
+ + appendCorsAllowOriginsCommand(config, DEFAULT_CLIENT_BROWSER_ORIGIN),
300
+ );
301
+ return 1;
302
+ }
303
+ const target = await (deps.findLiveProxy ?? findLiveProxy)();
304
+ if (!target) {
305
+ console.error("No running attested OpenCodex hub was found. Check 'ocx service status', then 'ocx service repair'.");
306
+ return 1;
307
+ }
308
+ const resolved = resolveHubDataOrigin(
309
+ dataOverride,
310
+ config.hub?.dataPublicOrigin,
311
+ target.hostname ?? config.hostname,
312
+ target.port,
313
+ );
314
+ if (resolved.kind === "loopback-derived") {
315
+ console.error(
316
+ `The advertised data origin would be ${resolved.dataUrl} — this machine's own loopback — because `
317
+ + `${bindAddressPhrase(resolved.bindHostname)}, and nothing here guesses a tailnet or LAN address. `
318
+ + "The other machine would dial itself and the single-use code would be spent for nothing. Name the "
319
+ + "origin remote machines reach this hub's data plane on:",
320
+ );
321
+ for (const line of configSetHubLines(config, "dataPublicOrigin", "https://hub.tailnet.ts.net:8443")) {
322
+ console.error(` ${line}`);
323
+ }
324
+ console.error(" ...or, for this invite only: ocx hub invite --data-url https://hub.tailnet.ts.net:8443");
325
+ return 1;
326
+ }
327
+ const dataUrl = resolved.dataUrl;
328
+ const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, browserOrigin, {
329
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
330
+ });
331
+ if (result.kind !== "created") {
332
+ console.error(`Minting a pairing code failed (${result.reason}).`);
333
+ return 1;
334
+ }
335
+ const payload: HubInvitePayload = {
336
+ code: result.grant,
337
+ expiresAt: new Date(result.expiresAt).toISOString(),
338
+ dataUrl,
339
+ managementUrl: managementPublic,
340
+ command: hubInviteCommand(result.grant, dataUrl, managementPublic, clients),
341
+ };
342
+ // Always, in both modes: the grant is bound to ONE browser origin and the operator cannot
343
+ // see which from the printed command (#4236 review).
344
+ for (const note of inviteBoundOriginNotes(browserOrigin, config)) console.error(note);
345
+ if (options.json) {
346
+ console.log(JSON.stringify(payload));
347
+ console.error(PAIRING_WARNING);
348
+ return 0;
349
+ }
350
+ // Remaining time, not the constant TTL: the number an operator reads has to be the one
351
+ // they actually have left by the time the line is printed.
352
+ const ttlSeconds = Math.max(0, Math.round((result.expiresAt - Date.now()) / 1000));
353
+ console.log(`Pairing code for one machine — single-use, expires in ${ttlSeconds}s (${payload.expiresAt}).`);
354
+ console.log("");
355
+ console.log("# Run on the other machine:");
356
+ console.log(payload.command);
357
+ console.error(PAIRING_WARNING);
358
+ return 0;
359
+ }
360
+
361
+ export async function runHubCommand(args: string[], deps: HubCommandDeps): Promise<number> {
362
+ if (args[0] !== "invite") {
363
+ console.error(`Usage: ${HUB_USAGE}`);
364
+ return 1;
365
+ }
366
+ return runInvite(args.slice(1), deps);
367
+ }
package/src/cli/index.ts CHANGED
@@ -48,7 +48,7 @@ import {
48
48
  pendingTeardownPathFor,
49
49
  quarantinePendingTeardown,
50
50
  } from "../config/pending-teardown";
51
- import { collectStatus, unusedProxyWarningLines } from "./status";
51
+ import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status";
52
52
  import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
53
53
  import { takeFlag } from "./runtime-api";
54
54
 
@@ -66,10 +66,11 @@ import { dispatchCommand , decideStartWithLiveOwner } from "./dispatch";
66
66
  import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
67
67
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
68
68
  import { createReadinessGate } from "../server/readiness";
69
+ import { isApiAuthRequired } from "../server/auth-cors";
69
70
  import { runReady, type ReadyArgs } from "./ready";
70
71
  import { runCli } from "./root";
71
- import { isProcessAlive, ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control";
72
- import { loadServiceTokenFromFile } from "../lib/service-secrets";
72
+ import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control";
73
+ import { startupDataPlaneToken } from "../lib/service-secrets";
73
74
  import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service";
74
75
  import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
75
76
  import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env";
@@ -81,8 +82,11 @@ import { scheduleCatalogPrewarm } from "./catalog-prewarm";
81
82
  import { maybeShowUpdatePrompt } from "../update/notify";
82
83
  import { syncModelsToCodex } from "../codex/sync";
83
84
  import {
85
+ HUB_GATED_SKIP_MESSAGE,
86
+ localClientSkipReason,
84
87
  shouldSyncGrokOnStart,
85
88
  syncCodexOnStartIfEnabled,
89
+ type LocalClientSkipReason,
86
90
  } from "../codex/desired-state";
87
91
  import {
88
92
  reconcileClientStartupBeforeReady,
@@ -174,6 +178,19 @@ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
174
178
  return null;
175
179
  }
176
180
 
181
+ /**
182
+ * The one startup line for "nothing was written to Codex".
183
+ *
184
+ * Two very different facts reached it: the user's own OFF switch, and a hub declining to
185
+ * rewrite its own local clients. Printing the toggle's wording for the gate is what made
186
+ * operators hunt for a switch they never set (#4236).
187
+ */
188
+ function startupLeftCodexNativeLine(reason: LocalClientSkipReason): string {
189
+ return reason === "hub-gated"
190
+ ? ` ${HUB_GATED_SKIP_MESSAGE} Startup left Codex native.`
191
+ : " Codex integration OFF; startup left Codex native.";
192
+ }
193
+
177
194
  /** Argv for detached `start`, optionally hard-pinning the listen port. */
178
195
  function startArgv(port?: number): string[] {
179
196
  const args = ["start"];
@@ -190,6 +207,10 @@ async function chooseListenPort(
190
207
  const config = loadConfig();
191
208
  const preferred = requestedPort ?? config.port ?? 10100;
192
209
  const hardPin = requestedPort !== undefined && requestedPort > 0;
210
+ // Only an EXPLICITLY ported listener reserves a port. The companion form (`{enabled:true}`
211
+ // with no port) deliberately shares the public port on 127.0.0.1, so treating it as a
212
+ // reservation — via `effectiveLoopbackListenerPort` — would refuse every start on a
213
+ // one-port hub and hop the public listener onto an ephemeral port (#4236).
193
214
  const reservedLoopbackPort = config.unauthenticatedLoopbackListener?.enabled
194
215
  ? config.unauthenticatedLoopbackListener.port
195
216
  : undefined;
@@ -271,10 +292,16 @@ async function findProxyOwnerBeforeJournalRecovery(
271
292
  }
272
293
 
273
294
  async function handleStart(options: { block?: boolean } = {}) {
274
- // Native (WinSW) service mode has no batch wrapper to read the service token file
275
- // into the environment, so the app loads it here before the server binds. The server
295
+ // Native (WinSW) service mode has no batch wrapper to read the service token file into
296
+ // the environment, and a FOREGROUND `ocx start` has no wrapper at all — so the app loads
297
+ // the token here, before the server binds, with the same precedence the launchd plist and
298
+ // the systemd unit use when they cat the file into the environment. Without the second
299
+ // source, `ocx start` refused to bind a non-loopback hostname (assertServerAuthConfig)
300
+ // that the installed service on the same machine was serving happily (#4236). The server
276
301
  // auth path reads OPENCODEX_API_AUTH_TOKEN from the environment.
277
- const serviceToken = loadServiceTokenFromFile(process.env);
302
+ const serviceToken = startupDataPlaneToken(process.env, {
303
+ authRequired: isApiAuthRequired(loadConfig()),
304
+ });
278
305
  if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken;
279
306
  // The service wrapper (and WinSW via OCX_API_TOKEN_FILE) can still export a colliding
280
307
  // token that install now refuses to write. Refuse it here too, before bind, so an
@@ -513,7 +540,7 @@ async function handleStart(options: { block?: boolean } = {}) {
513
540
  }
514
541
  },
515
542
  );
516
- if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native.");
543
+ if (!startupSync.ran) console.log(startupLeftCodexNativeLine(localClientSkipReason(config)));
517
544
  await refreshOwnedRaycastCatalog(config, port);
518
545
  // #1046: one warning per startup, after BOTH writes. The server's cache
519
546
  // invalidation happens first and the catalog sync second, so the mtime is only
@@ -579,7 +606,9 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
579
606
  console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
580
607
  return null;
581
608
  });
582
- if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
609
+ if (synced?.status === "skipped") {
610
+ console.log(startupLeftCodexNativeLine(synced.skippedReason ?? "desired_disabled"));
611
+ }
583
612
  // Do not refresh Raycast from saved config here: live bind/admission and
584
613
  // secondary-listener settings may differ. Explicit sync or server startup
585
614
  // owns catalog refresh; ensure must not overwrite a working destination.
@@ -626,7 +655,9 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
626
655
  console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
627
656
  return null;
628
657
  });
629
- if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
658
+ if (synced?.status === "skipped") {
659
+ console.log(startupLeftCodexNativeLine(synced.skippedReason ?? "desired_disabled"));
660
+ }
630
661
  // The child performs Raycast refresh with its actual startup config. The
631
662
  // parent's pre-spawn snapshot is not authoritative for a client-file write.
632
663
  // The child opens /healthz before its best-effort roster reconcile. Await the same idempotent
@@ -952,7 +983,12 @@ async function handleStop() {
952
983
  if (detail) console.error(` ${detail}`);
953
984
  if (err instanceof ProxyOwnershipRefusedError) {
954
985
  ownershipBlocked = true;
955
- console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running.");
986
+ // Every refusal `POST /api/stop` produces is written for an API client, so it
987
+ // recommends `ocx stop` — the command printing it. Following that advice returns
988
+ // the operator to this exact message, which is the loop #4169 was filed for. The
989
+ // service manager was already asked to stop above, so name what is actually left.
990
+ console.error(` ${refusalNextStep(err.code)}`);
991
+ console.error(" Skipping shared teardown (native Codex restore, Grok config): the refusing proxy is still running.");
956
992
  }
957
993
  }
958
994
  } else {
@@ -979,7 +1015,9 @@ async function handleStop() {
979
1015
  if (detail) console.error(` ${detail}`);
980
1016
  if (err instanceof ProxyOwnershipRefusedError) {
981
1017
  ownershipBlocked = true;
982
- console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running.");
1018
+ // Same loop as the tracked-pid path above: the refusal recommends this command.
1019
+ console.error(` ${refusalNextStep(err.code)}`);
1020
+ console.error(" Skipping shared teardown (native Codex restore, Grok config): the refusing proxy is still running.");
983
1021
  }
984
1022
  }
985
1023
  } else if (live) {
@@ -1345,12 +1383,22 @@ async function handleStatus() {
1345
1383
  return;
1346
1384
  }
1347
1385
 
1386
+ // First line of the report, above the proxy line, deliberately (#4236): on a connected client
1387
+ // the provider/login/model lines describe the HUB, and the lines that describe this machine
1388
+ // are tagged `(local)`. A reader who sees neither draws the wrong conclusion from a correct
1389
+ // report — an agent read `xai ✗ not logged in` off a client and decided the hub could not
1390
+ // serve grok.
1391
+ const remoteHubBanner = remoteHubBannerLine(status.json.remoteHub);
1392
+ if (remoteHubBanner) console.log(remoteHubBanner);
1393
+ // `(local)` only while connected: on a standalone install every line is local, and tagging
1394
+ // them all would be noise that trains the reader to skip the tag.
1395
+ const local = status.json.remoteHub.connected ? " (local)" : "";
1348
1396
  if (status.json.proxy.pid || status.json.proxy.health.ok) {
1349
- console.log(`✅ Proxy: ${status.proxyLabel}`);
1397
+ console.log(`✅ Proxy: ${status.proxyLabel}${local}`);
1350
1398
  } else {
1351
- console.log(`❌ Proxy: ${status.proxyLabel}`);
1399
+ console.log(`❌ Proxy: ${status.proxyLabel}${local}`);
1352
1400
  }
1353
- console.log(` Health: ${status.healthLabel}`);
1401
+ console.log(` Health: ${status.healthLabel}${local}`);
1354
1402
  if (status.json.claudeDesktop.desiredEnabled && !status.json.claudeDesktop.policy.ok) {
1355
1403
  console.log(` ⚠️ Claude Desktop 3P health: ${status.json.claudeDesktop.policy.status}`);
1356
1404
  console.log(` ${status.json.claudeDesktop.policy.message}`);
@@ -1390,25 +1438,31 @@ async function handleStatus() {
1390
1438
  ? " Restart with 'ocx start', or refresh the installed service: 'ocx service repair'."
1391
1439
  : " Restart with 'ocx start', or install the persistent service: 'ocx service install'.");
1392
1440
  }
1393
- console.log(` Dashboard: ${status.json.dashboard.url}`);
1394
- console.log(` Config: ${status.json.paths.config}`);
1395
- console.log(` PID file: ${status.json.paths.pid}`);
1396
- console.log(` Runtime: ${status.json.paths.runtime}`);
1397
- console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}`);
1398
- console.log(` Default provider: ${status.json.defaultProvider}`);
1441
+ console.log(` Dashboard: ${status.json.dashboard.url}${local}`);
1442
+ console.log(` Config: ${status.json.paths.config}${local}`);
1443
+ console.log(` PID file: ${status.json.paths.pid}${local}`);
1444
+ console.log(` Runtime: ${status.json.paths.runtime}${local}`);
1445
+ console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}${local}`);
1446
+ // On a client this is the local default, which routing does not use — the hub applies its own.
1447
+ console.log(` Default provider: ${status.json.defaultProvider}${local}`);
1448
+ // One block rather than six scattered lines, and only on a hub: `hubStatusLines` owns the
1449
+ // sentences so they are testable without spawning the CLI. It prints no token value.
1450
+ if (status.json.hub) {
1451
+ for (const line of hubStatusLines(status.json.hub)) console.log(` ${line}`);
1452
+ }
1399
1453
  console.log(` Remote hub: ${status.json.connection.state}${status.json.connection.serverUrl ? ` (${status.json.connection.serverUrl})` : ""}`);
1400
1454
  if (status.json.connection.state === "invalid" || status.json.connection.state === "mismatched") {
1401
1455
  console.log(` ⚠️ ${status.json.connection.reason}`);
1402
1456
  }
1403
- console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`);
1404
- console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`);
1405
- console.log(` ${formatStartupRoutingDetail(status.json.startup)}`);
1406
- console.log(` Service: ${status.json.service.summary}`);
1407
- console.log(` ${status.json.codexShim.summary}`);
1408
- console.log(` Codex runtime: ${status.json.codexRuntime.path}`);
1409
- console.log(` Codex version: ${status.json.codexRuntime.version ?? "unknown"}`);
1410
- console.log(` Codex source: ${status.json.codexRuntime.source}`);
1411
- console.log(` Codex home: ${status.json.codexHome.effectiveCodexHome}`);
1457
+ console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}${local}`);
1458
+ console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}${local}`);
1459
+ console.log(` ${formatStartupRoutingDetail(status.json.startup)}${local}`);
1460
+ console.log(` Service: ${status.json.service.summary}${local}`);
1461
+ console.log(` ${status.json.codexShim.summary}${local}`);
1462
+ console.log(` Codex runtime: ${status.json.codexRuntime.path}${local}`);
1463
+ console.log(` Codex version: ${status.json.codexRuntime.version ?? "unknown"}${local}`);
1464
+ console.log(` Codex source: ${status.json.codexRuntime.source}${local}`);
1465
+ console.log(` Codex home: ${status.json.codexHome.effectiveCodexHome}${local}`);
1412
1466
  if (status.json.codexHome.warning) {
1413
1467
  console.log(` ⚠️ ${status.json.codexHome.warning}`);
1414
1468
  console.log(` Action: ${status.json.codexHome.action}`);
@@ -1428,9 +1482,23 @@ async function handleStatus() {
1428
1482
  }
1429
1483
  }
1430
1484
  const { collectOAuthHealthEntriesForCli, oauthLoginSummary } = await import("../oauth");
1485
+ const { emailMaskingEnabled } = await import("../lib/privacy");
1431
1486
  const { formatOAuthHealthForStatus } = await import("./status-oauth");
1432
- console.log(` OAuth logins:`);
1433
- for (const e of oauthLoginSummary()) {
1487
+ // On a connected client the HUB's providers and logins come first, because they are the ones
1488
+ // that decide what a request can route to. The local block still prints — an operator debugging
1489
+ // a half-migrated machine needs to see it — but under a heading that says it is not in use, and
1490
+ // below the hub's, so the hub's is what a reader (or an agent) encounters first (#4236).
1491
+ for (const line of remoteHubStatusLines(status.json.remoteHub)) console.log(` ${line}`);
1492
+ if (status.json.remoteHub.connected) {
1493
+ console.log(status.json.remoteHub.stateSource === "unavailable"
1494
+ ? " Local-only credential state (this is NOT the hub's; the hub's state could not be read):"
1495
+ : " Local-only (not used for routing while connected):");
1496
+ }
1497
+ console.log(` OAuth logins${local}:`);
1498
+ // The operator's own `privacy.maskEmails` decision applies to the CLI too: `ocx status` is not
1499
+ // the dashboard, but it reads the same stored addresses, and a flag that only moved one of the
1500
+ // two would leave the operator unable to tell which surface they had configured.
1501
+ for (const e of oauthLoginSummary(emailMaskingEnabled(loadConfig()))) {
1434
1502
  console.log(` ${e.provider.padEnd(10)} ${e.loggedIn ? `✓ logged in${e.email ? ` (${e.email})` : ""}` : "✗ not logged in"}`);
1435
1503
  }
1436
1504
  const oauthHealthBlock = formatOAuthHealthForStatus(await collectOAuthHealthEntriesForCli());
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Trusted facts captured by the plain-Node npm launcher before Bun auto-loads
2
+ * Trusted facts captured by the plain-Node package launcher before Bun auto-loads
3
3
  * project dotenv files. The random proof travels in argv while the context
4
4
  * travels in the environment, so a project `.env` cannot forge the pair during
5
5
  * an ordinary `ocx ...` invocation.
@@ -21,7 +21,7 @@ import { MAX_COST4_RATE } from "../usage/expected-prices";
21
21
  import { isValidCost4Rate } from "../usage/user-cost-overlays";
22
22
 
23
23
  const USAGE = `Usage:
24
- ocx models live [--provider <name>] [--json]
24
+ ocx models live [--provider <name>] [--free-only] [--json]
25
25
  ocx models price <provider/model> [--json]
26
26
  ocx models set-price <provider/model> --input N --output N [--cache-read N] [--cache-write N] [--json]
27
27
  ocx models set-price <provider/model> --auto [--json]
@@ -52,17 +52,22 @@ type ModelRow = {
52
52
  custom?: boolean;
53
53
  customId?: string;
54
54
  displayName?: string;
55
+ pricingStatus?: "free" | "paid";
55
56
  };
56
57
 
57
58
  async function live(argv: string[], deps: RuntimeApiDeps): Promise<void> {
58
59
  const args = [...argv];
59
60
  const wantsJson = takeFlag(args, "--json");
60
61
  const provider = takeOption(args, "--provider");
62
+ // Absent pricingStatus means the provider published no usable per-token pair, so it is
63
+ // excluded here for the same fail-closed reason the classifier omits it (#3666).
64
+ const freeOnly = takeFlag(args, "--free-only");
61
65
  rejectArgs(args, USAGE);
62
66
  const rows = await runtimeRequest<ModelRow[]>("/api/models", {}, deps);
63
- const filtered = provider ? rows.filter(row => row.provider === provider) : rows;
67
+ const byProvider = provider ? rows.filter(row => row.provider === provider) : rows;
68
+ const filtered = freeOnly ? byProvider.filter(row => row.pricingStatus === "free") : byProvider;
64
69
  printData(filtered, wantsJson, filtered.map(row => {
65
- const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.initialSelectionPending ? "initial discovery pending" : row.disabled ? "disabled" : "enabled"].filter(Boolean);
70
+ const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.pricingStatus === "free" ? "free" : "", row.initialSelectionPending ? "initial discovery pending" : row.disabled ? "disabled" : "enabled"].filter(Boolean);
66
71
  return `${row.namespaced ?? `${row.provider}/${row.id}`} [${flags.join(", ")}]`;
67
72
  }));
68
73
  }