@bitkyc08/opencodex 2.50.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 (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-D7BdZpZm.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
@@ -7,6 +7,8 @@ import { PROXY_MARKER } from "../claude/auth-detect";
7
7
  import { isProxyAdmissionSecret } from "./auth-cors";
8
8
  import type { OcxConfig } from "../types";
9
9
  import { recordOwnedConfigPath } from "../lib/config-ownership";
10
+ import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
11
+ import { probeHostname } from "./proxy-liveness";
10
12
  import { providerContextCap } from "../providers/context-cap";
11
13
  import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
12
14
  export { getShellEnvFilePath, installShellHook, uninstallShellHook, claudeCodeCliInstalled, reconcileShellHook } from "./system-env-shell";
@@ -35,12 +37,35 @@ const MANAGED_SYSTEM_ENV_NAMES = new Set<string>([
35
37
 
36
38
  interface SystemEnvTracking {
37
39
  pid: number;
40
+ /** The PUBLIC port of the owning proxy: its instance identity and its /healthz port. */
38
41
  port: number;
42
+ /**
43
+ * The host `/healthz` answers on — `probeHostname(config.hostname)` at injection time (#4236).
44
+ *
45
+ * Without it the liveness probe went to `127.0.0.1:<public port>`, which does not exist on a
46
+ * tailnet-bound hub: every probe failed, the record was reverted on every start, and the
47
+ * "another instance owns env" guard below could never fire. Absent (⇒ 127.0.0.1) in records
48
+ * written by a build that only ever bound loopback.
49
+ */
50
+ bindHost?: string;
51
+ /**
52
+ * The exact ANTHROPIC_BASE_URL that was injected, when it is not `http://127.0.0.1:<port>`.
53
+ *
54
+ * The injected destination and the proxy's own `/healthz` address separated the day local
55
+ * clients started honoring the unauthenticated loopback listener, and separated again for a
56
+ * non-loopback bind. Ownership on revert is proven against THIS value — it is what was
57
+ * written — while liveness is probed on `bindHost`/`port`, because the loopback listener
58
+ * serves no `/healthz` and probing the injected port would declare a live proxy stale.
59
+ */
60
+ clientBaseUrl?: string;
39
61
  injectedAt: string;
40
62
  /** Keys that were actually set by injection (revert only unsets these). */
41
63
  injectedKeys?: string[];
42
64
  }
43
65
 
66
+ /** Bounded host shape: this value is interpolated into a probe URL, so it is validated on read. */
67
+ const BIND_HOST_PATTERN = /^[A-Za-z0-9.\-_]{1,253}$|^\[[0-9A-Fa-f:.]{2,45}\]$/;
68
+
44
69
  type SystemEnvResult = { injected: boolean; reason?: string };
45
70
  type RevertResult = { reverted: boolean; reason?: string };
46
71
  type CleanupResult = { cleaned: boolean; reason?: string };
@@ -70,7 +95,13 @@ function readTracking(): SystemEnvTracking | undefined {
70
95
  (name): name is string => typeof name === "string" && MANAGED_SYSTEM_ENV_NAMES.has(name),
71
96
  ))]
72
97
  : undefined;
73
- return { ...tracking, injectedKeys } as SystemEnvTracking;
98
+ const bindHost = typeof tracking.bindHost === "string" && BIND_HOST_PATTERN.test(tracking.bindHost)
99
+ ? tracking.bindHost
100
+ : undefined;
101
+ const clientBaseUrl = typeof tracking.clientBaseUrl === "string" && /^https?:\/\/[^\s/]+$/.test(tracking.clientBaseUrl)
102
+ ? tracking.clientBaseUrl
103
+ : undefined;
104
+ return { ...tracking, bindHost, clientBaseUrl, injectedKeys } as SystemEnvTracking;
74
105
  } catch {
75
106
  return undefined;
76
107
  }
@@ -88,18 +119,40 @@ function ownedBaseUrl(port: number): string {
88
119
  return `http://127.0.0.1:${port}`;
89
120
  }
90
121
 
91
- function writeTracking(port: number, injectedKeys: string[]): void {
122
+ /** What the tracking record has to remember beyond the public port, or nothing on a pure loopback install. */
123
+ interface TrackedDestination {
124
+ /** `probeHostname(config.hostname)` — where `/healthz` answers. */
125
+ bindHost: string;
126
+ /** The injected ANTHROPIC_BASE_URL. */
127
+ clientBaseUrl: string;
128
+ }
129
+
130
+ function writeTracking(port: number, injectedKeys: string[], tracked?: TrackedDestination): void {
92
131
  recordOwnedConfigPath(getConfigDir(), getSystemEnvTrackingPath());
93
132
  mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
94
133
  writeFileSync(getSystemEnvTrackingPath(), JSON.stringify({
95
134
  pid: process.pid,
96
135
  port,
136
+ // Both fields are omitted when they carry no information beyond `port`, so a plain
137
+ // loopback install keeps writing the byte-identical record it always did.
138
+ ...(tracked && tracked.bindHost !== "127.0.0.1" ? { bindHost: tracked.bindHost } : {}),
139
+ ...(tracked && tracked.clientBaseUrl !== ownedBaseUrl(port) ? { clientBaseUrl: tracked.clientBaseUrl } : {}),
97
140
  injectedAt: new Date().toISOString(),
98
141
  injectedKeys,
99
142
  }), { encoding: "utf8", mode: 0o600 });
100
143
  }
101
144
 
102
- function rollbackInjectedKeys(port: number, injectedKeys: string[]): void {
145
+ /** The base URL that was injected: the loopback listener or the bind address, not always loopback (#4236). */
146
+ function trackedBaseUrl(tracking: Pick<SystemEnvTracking, "port" | "clientBaseUrl">): string {
147
+ return tracking.clientBaseUrl ?? ownedBaseUrl(tracking.port);
148
+ }
149
+
150
+ /** Where the owning proxy's `/healthz` answers — the BIND address, never the injected port (#4236). */
151
+ function trackedHealthzOrigin(tracking: Pick<SystemEnvTracking, "port" | "bindHost">): string {
152
+ return `http://${tracking.bindHost ?? "127.0.0.1"}:${tracking.port}`;
153
+ }
154
+
155
+ function rollbackInjectedKeys(port: number, injectedKeys: string[], tracked?: TrackedDestination): void {
103
156
  const rollbackFailed: string[] = [];
104
157
  for (const name of [...injectedKeys].reverse()) {
105
158
  try {
@@ -110,7 +163,7 @@ function rollbackInjectedKeys(port: number, injectedKeys: string[]): void {
110
163
  }
111
164
 
112
165
  if (rollbackFailed.length > 0) {
113
- writeTracking(port, rollbackFailed);
166
+ writeTracking(port, rollbackFailed, tracked);
114
167
  return;
115
168
  }
116
169
 
@@ -150,8 +203,39 @@ export async function injectSystemEnv(
150
203
 
151
204
  await cleanStaleSystemEnv();
152
205
 
206
+ // A launchd-started `claude` is a LOCAL client: it dials the unauthenticated loopback
207
+ // listener when one is enabled, because on a tailnet-bound hub nothing answers on
208
+ // 127.0.0.1:<public port> (#4236); with no listener the destination is the BIND address,
209
+ // which answers but demands data-plane admission. `port` stays the instance identity.
210
+ const destination = localInferenceDestination(config, port);
211
+ const tracked: TrackedDestination = {
212
+ bindHost: probeHostname(config.hostname),
213
+ clientBaseUrl: destination.origin,
214
+ };
215
+ const markerMode = systemEnvMarkerMode(config, deps);
216
+ // The data-plane credential this host can present to its own public listener: env token,
217
+ // hardened service token file, then a configured key. Never the admin token.
218
+ const hostAdmissionToken = destination.requiresAdmissionToken ? localAdmissionToken(config) : undefined;
219
+ // The launchd domain is machine-wide, so injecting a destination that will 401 every plain
220
+ // `claude` is worse than not injecting at all. Degrade out loud instead: a subscription
221
+ // launch cannot carry a host token (asserting one logs a claude.ai subscriber out, #253),
222
+ // and with no resolvable credential there is nothing to carry either way.
223
+ if (destination.requiresAdmissionToken && (markerMode !== "proxy" || !hostAdmissionToken)) {
224
+ console.error(
225
+ `⚠ Skipping system-environment injection: ${destination.origin} requires an opencodex data-plane `
226
+ + "credential that this launch cannot supply. Enable `unauthenticatedLoopbackListener` or bind the "
227
+ + "proxy to loopback.",
228
+ );
229
+ return { injected: false, reason: "local inference requires a data-plane credential" };
230
+ }
231
+
153
232
  const currentBaseUrl = launchctlGetenv("ANTHROPIC_BASE_URL");
154
- if (currentBaseUrl && !/^http:\/\/127\.0\.0\.1:\d+$/.test(currentBaseUrl)) {
233
+ // Ours is loopback in every topology but one: with the listener off and a non-loopback bind
234
+ // the value WE wrote names the bind address, and rejecting it as "user has custom" would make
235
+ // every subsequent start refuse to refresh its own injection.
236
+ if (currentBaseUrl
237
+ && !/^http:\/\/127\.0\.0\.1:\d+$/.test(currentBaseUrl)
238
+ && currentBaseUrl !== destination.origin) {
155
239
  return { injected: false, reason: "user has custom ANTHROPIC_BASE_URL" };
156
240
  }
157
241
  // After stale cleanup, if a tracking file still exists with a DIFFERENT port,
@@ -167,15 +251,17 @@ export async function injectSystemEnv(
167
251
  const inject = (name: string, value: string) => {
168
252
  setLaunchctlEnv(name, value);
169
253
  if (!injectedKeys.includes(name)) injectedKeys.push(name);
170
- writeTracking(port, injectedKeys);
254
+ writeTracking(port, injectedKeys, tracked);
171
255
  };
172
256
 
173
257
  try {
174
- inject("ANTHROPIC_BASE_URL", ownedBaseUrl(port));
258
+ inject("ANTHROPIC_BASE_URL", destination.origin);
175
259
  inject("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
176
- const markerMode = systemEnvMarkerMode(config, deps);
177
260
  if (markerMode === "proxy") {
178
- if (config.apiKeys?.length) {
261
+ if (hostAdmissionToken) {
262
+ // The guard above proved this exists whenever the destination demands one.
263
+ inject("ANTHROPIC_AUTH_TOKEN", hostAdmissionToken);
264
+ } else if (config.apiKeys?.length) {
179
265
  inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
180
266
  } else if (launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
181
267
  inject("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER);
@@ -190,7 +276,7 @@ export async function injectSystemEnv(
190
276
  unsetLaunchctlEnv("ANTHROPIC_AUTH_TOKEN");
191
277
  const tokenIdx = injectedKeys.indexOf("ANTHROPIC_AUTH_TOKEN");
192
278
  if (tokenIdx >= 0) injectedKeys.splice(tokenIdx, 1);
193
- writeTracking(port, injectedKeys);
279
+ writeTracking(port, injectedKeys, tracked);
194
280
  }
195
281
  }
196
282
  // Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
@@ -242,9 +328,9 @@ export async function injectSystemEnv(
242
328
  injectClaudeAgentDefs(config, windows);
243
329
  } catch { /* best-effort */ }
244
330
 
245
- writeTracking(port, injectedKeys);
331
+ writeTracking(port, injectedKeys, tracked);
246
332
  } catch (error) {
247
- rollbackInjectedKeys(port, injectedKeys);
333
+ rollbackInjectedKeys(port, injectedKeys, tracked);
248
334
  removeShellEnvFile();
249
335
  console.error("Failed to inject system environment; rolled back launchctl changes:", error);
250
336
  throw error;
@@ -266,7 +352,7 @@ export function revertSystemEnv(): RevertResult {
266
352
 
267
353
  try {
268
354
  const tracksBaseUrl = tracking.injectedKeys?.includes("ANTHROPIC_BASE_URL") ?? true;
269
- if (tracksBaseUrl && launchctlGetenv("ANTHROPIC_BASE_URL") !== ownedBaseUrl(tracking.port)) {
355
+ if (tracksBaseUrl && launchctlGetenv("ANTHROPIC_BASE_URL") !== trackedBaseUrl(tracking)) {
270
356
  return { reverted: false, reason: "ownership mismatch" };
271
357
  }
272
358
 
@@ -296,7 +382,13 @@ export async function cleanStaleSystemEnv(): Promise<CleanupResult> {
296
382
  if (!tracking) return { cleaned: false, reason: "no tracking file" };
297
383
 
298
384
  try {
299
- const response = await fetch(`${ownedBaseUrl(tracking.port)}/healthz`, {
385
+ // The BIND address and `tracking.port`, never the injected destination: `/healthz` is not
386
+ // on the unauthenticated loopback listener's allowlist, so probing that port would 404 and
387
+ // revert a live proxy's environment (#4236). Nor is it always 127.0.0.1 — a tailnet-bound
388
+ // hub has no loopback socket at all, and probing one there failed every single time, which
389
+ // reverted the record on every start and made the "another instance owns env" guard above
390
+ // unreachable.
391
+ const response = await fetch(`${trackedHealthzOrigin(tracking)}/healthz`, {
300
392
  signal: AbortSignal.timeout(1_000),
301
393
  });
302
394
  if (response.ok) return { cleaned: false, reason: "proxy still alive" };