@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -26,6 +26,12 @@ export interface GatewayModelCacheRefreshOptions {
26
26
  configDir?: string;
27
27
  admissionConfig?: Pick<OcxConfig, "apiKeys">;
28
28
  env?: NodeJS.ProcessEnv;
29
+ fetchImpl?: typeof fetch;
30
+ }
31
+
32
+ export interface GatewayModelTarget {
33
+ baseUrl: string;
34
+ admissionToken: string;
29
35
  }
30
36
 
31
37
  /** Claude Code config dir (CLAUDE_CONFIG_DIR override honored, like the CLI). */
@@ -70,6 +76,14 @@ function serviceFileToken(env: NodeJS.ProcessEnv): string | null {
70
76
  /** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
71
77
  export async function refreshGatewayModelCacheFromProxy(
72
78
  port: number,
79
+ options?: GatewayModelCacheRefreshOptions,
80
+ ): Promise<string | null>;
81
+ export async function refreshGatewayModelCacheFromProxy(
82
+ target: GatewayModelTarget,
83
+ options?: GatewayModelCacheRefreshOptions,
84
+ ): Promise<string | null>;
85
+ export async function refreshGatewayModelCacheFromProxy(
86
+ portOrTarget: number | GatewayModelTarget,
73
87
  options: GatewayModelCacheRefreshOptions = {},
74
88
  ): Promise<string | null> {
75
89
  try {
@@ -82,12 +96,18 @@ export async function refreshGatewayModelCacheFromProxy(
82
96
  const configuredToken = options.admissionConfig?.apiKeys
83
97
  ?.find(entry => entry.key.trim().length > 0)
84
98
  ?.key.trim();
85
- const admissionToken = envToken || serviceFileToken(options.env ?? process.env) || configuredToken;
99
+ const admissionToken = typeof portOrTarget === "number"
100
+ ? envToken || serviceFileToken(options.env ?? process.env) || configuredToken
101
+ : portOrTarget.admissionToken;
86
102
  if (admissionToken) headers.set("x-opencodex-api-key", admissionToken);
87
103
 
104
+ const baseUrl = typeof portOrTarget === "number"
105
+ ? `http://127.0.0.1:${portOrTarget}`
106
+ : new URL(portOrTarget.baseUrl).origin;
107
+
88
108
  // ?ids=cli pins the readable claude-ocx id family deterministically (audit 051
89
109
  // #5): the cache prewrite must not depend on UA sniffing.
90
- const res = await fetch(`http://127.0.0.1:${port}/v1/models?limit=1000&ids=cli`, {
110
+ const res = await (options.fetchImpl ?? fetch)(`${baseUrl}/v1/models?limit=1000&ids=cli`, {
91
111
  headers,
92
112
  signal: AbortSignal.timeout(options.timeoutMs ?? 3_000),
93
113
  });
@@ -100,7 +120,7 @@ export async function refreshGatewayModelCacheFromProxy(
100
120
  id: m.id as string,
101
121
  display_name: typeof m.display_name === "string" ? m.display_name : undefined,
102
122
  }));
103
- return writeGatewayModelCache(`http://127.0.0.1:${port}`, models, options.configDir);
123
+ return writeGatewayModelCache(baseUrl, models, options.configDir);
104
124
  } catch {
105
125
  return null;
106
126
  }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog";
19
19
  import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias";
20
+ import { cursorFastIdFor } from "../adapters/cursor/catalog";
20
21
  import { desktop3pAlias } from "./desktop-3p";
21
22
  import { AUTO_CONTEXT_OFF, type AutoContextMode } from "./context-windows";
22
23
 
@@ -109,6 +110,7 @@ export function buildAnthropicModelInfos(
109
110
  idStyle: AnthropicIdStyle = "desktop3p",
110
111
  aliasForRoute: (provider: string, modelId: string) => string = desktop3pAlias,
111
112
  nativeContextCap?: NativeContextLimitsInput,
113
+ fastMode?: boolean,
112
114
  ): AnthropicModelInfo[] {
113
115
  const out: AnthropicModelInfo[] = [];
114
116
  const seen = new Set<string>();
@@ -151,7 +153,16 @@ export function buildAnthropicModelInfos(
151
153
  push1mVariant(info, nativeWindow, nativeMaxInput);
152
154
  }
153
155
  for (const m of routedModels) {
154
- const id = idStyle === "readable" ? claudeCodeAlias(m.provider, m.id) : aliasForRoute(m.provider, m.id);
156
+ // Global Fast has no toggle on this surface, so the fast identity is what gets listed —
157
+ // a client here can only pick a listed id. Limited to the readable CLI style: Desktop 3P
158
+ // ids are hashed from the model name, so rewriting them would strand a saved selection.
159
+ const fastModelId = fastMode === true && m.provider === "cursor" && idStyle === "readable"
160
+ ? cursorFastIdFor(m.id)
161
+ : undefined;
162
+ const listedModelId = fastModelId ?? m.id;
163
+ const id = idStyle === "readable"
164
+ ? claudeCodeAlias(m.provider, listedModelId)
165
+ : aliasForRoute(m.provider, m.id);
155
166
  if (seen.has(id)) continue;
156
167
  seen.add(id);
157
168
  const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : [];
@@ -164,7 +175,7 @@ export function buildAnthropicModelInfos(
164
175
  ? Math.min(m.maxInputTokens, m.contextWindow)
165
176
  : m.maxInputTokens)
166
177
  : undefined;
167
- const info = modelInfo(id, `${m.id} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow);
178
+ const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow);
168
179
  out.push(info);
169
180
  // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude
170
181
  // routes — only a genuine >=1M window earns the variant row there.
package/src/cli/access.ts CHANGED
@@ -12,6 +12,9 @@ import {
12
12
  const USAGE = `Usage:
13
13
  ocx access key [list] [--json]
14
14
  ocx access key create [name] [--json]
15
+ ocx access key rotate <id> [--json]
16
+ ocx access key rotate commit <id> <rotation-id> [--json]
17
+ ocx access key rotate abort <id> <rotation-id> [--json]
15
18
  ocx access key remove <id> --yes [--json]
16
19
  ocx access endpoints [--json]
17
20
  ocx access models [--json]
@@ -87,6 +90,33 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
87
90
  ]);
88
91
  return;
89
92
  }
93
+ if (action === "rotate") {
94
+ const operation = args[0] === "commit" || args[0] === "abort" ? args.shift()! : "start";
95
+ const id = args.shift();
96
+ if (!id) throw new CliUsageError("key id is required", USAGE);
97
+ if (operation === "start") {
98
+ rejectArgs(args, USAGE);
99
+ const result = await runtimeRequest<Record<string, unknown>>("/api/keys/rotate", {
100
+ method: "POST",
101
+ body: JSON.stringify({ id }),
102
+ }, deps);
103
+ printData(result, wantsJson, [
104
+ `Started rotation for API key ${id}.`,
105
+ `New key (shown once): ${String(result.key ?? "")}`,
106
+ `After the client accepts it, commit with rotation id ${String(result.rotationId ?? "")}.`,
107
+ ]);
108
+ return;
109
+ }
110
+ const rotationId = args.shift();
111
+ if (!rotationId) throw new CliUsageError("rotation id is required", USAGE);
112
+ rejectArgs(args, USAGE);
113
+ const result = await runtimeRequest(operation === "commit" ? "/api/keys/rotate/commit" : "/api/keys/rotate", {
114
+ method: operation === "commit" ? "POST" : "DELETE",
115
+ body: JSON.stringify({ id, rotationId }),
116
+ }, deps);
117
+ printData(result, wantsJson, [`${operation === "commit" ? "Committed" : "Aborted"} rotation for API key ${id}.`]);
118
+ return;
119
+ }
90
120
  if (action === "remove" || action === "delete") {
91
121
  const id = args.shift();
92
122
  const yes = takeFlag(args, "--yes");
@@ -347,9 +347,12 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
347
347
  const action = args.shift();
348
348
  if (!name || !action) return usage();
349
349
  const classified = configAndType(deps, name);
350
- if ("error" in classified || classified.type !== "codex") {
351
- return usage("Error: auto-switch only applies to the openai Codex account pool");
350
+ // Anthropic keeps its threshold on its own pool contract; generic OAuth providers (#695)
351
+ // and the Codex pool are accepted here.
352
+ if ("error" in classified || classified.type === "api-key" || name === "anthropic") {
353
+ return usage("Error: auto-switch only applies to the openai Codex account pool or a generic OAuth provider pool");
352
354
  }
355
+ const genericPool = classified.type === "oauth";
353
356
  let threshold: number | undefined;
354
357
  if (action === "on" && args.length === 0) threshold = 80;
355
358
  else if (action === "off" && args.length === 0) threshold = 0;
@@ -361,14 +364,19 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
361
364
  const baseUrl = await resolveBaseUrl(deps);
362
365
  if (!baseUrl) return proxyUnreachable();
363
366
  if (action === "status") {
364
- const response = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
367
+ const response = await apiJson(
368
+ deps, baseUrl, "GET",
369
+ genericPool ? `/api/oauth/accounts/pool?provider=${encodeURIComponent(name)}` : "/api/codex-auth/active",
370
+ );
365
371
  if (response.status === 0) return proxyUnreachable(response.transportError);
366
- if (response.status !== 200 || typeof response.json.autoSwitchThreshold !== "number") {
372
+ if (response.status !== 200 || (!genericPool && typeof response.json.autoSwitchThreshold !== "number")) {
367
373
  return apiError(response.json, "failed to read auto-switch status", response.status);
368
374
  }
369
- threshold = response.json.autoSwitchThreshold;
375
+ threshold = typeof response.json.autoSwitchThreshold === "number" ? response.json.autoSwitchThreshold : 0;
370
376
  } else {
371
- const response = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold });
377
+ const response = genericPool
378
+ ? await apiJson(deps, baseUrl, "PUT", "/api/oauth/accounts/pool", { provider: name, autoSwitchThreshold: threshold })
379
+ : await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold });
372
380
  if (response.status === 0) return proxyUnreachable(response.transportError);
373
381
  if (response.status !== 200) return apiError(response.json, "failed to update auto-switch", response.status);
374
382
  }
@@ -846,16 +854,17 @@ function anthropicPoolTransport(provider: string): PoolTransport {
846
854
  }
847
855
 
848
856
  /**
849
- * The pool-config route supports `anthropic` only and says so with a 400. Any other OAuth
850
- * provider is refused here with the same wording rather than spending a round-trip to learn it.
857
+ * Codex and Anthropic keep their own pool transports; every other OAuth provider speaks the
858
+ * generic pool-settings contract on the same `/api/oauth/accounts/pool` route (#695).
859
+ * API-key providers have no pool and are refused here without a round-trip.
851
860
  */
852
861
  function poolTransportFor(
853
862
  classified: { type: "codex" | "oauth" | "api-key" },
854
863
  name: string,
855
864
  ): PoolTransport | string {
856
865
  if (classified.type === "codex") return CODEX_POOL_TRANSPORT;
857
- if (classified.type === "oauth" && name === "anthropic") return anthropicPoolTransport(name);
858
- return `pool settings apply to the openai Codex pool and the anthropic pool, not "${name}"`;
866
+ if (classified.type === "oauth") return anthropicPoolTransport(name);
867
+ return `pool settings apply to OAuth account pools, not the API-key provider "${name}"`;
859
868
  }
860
869
 
861
870
  /**
@@ -107,6 +107,29 @@ export const CAPABILITIES: readonly Capability[] = [
107
107
  json: "envelope",
108
108
  details: ["Reads /healthz plus local config; drives no management API route."],
109
109
  },
110
+ {
111
+ command: ["connect", "rotate"],
112
+ summary: "Rotate the connected client's data key against the hub, with commit and abort.",
113
+ // One command drives all three: start returns the new secret once, commit promotes it,
114
+ // and abort unwinds a rotation that could not be confirmed. They are not separate verbs
115
+ // because a half-rotation is not a state an operator should be able to leave behind.
116
+ routes: [
117
+ { method: "POST", path: "/api/keys/rotate" },
118
+ { method: "POST", path: "/api/keys/rotate/commit" },
119
+ { method: "DELETE", path: "/api/keys/rotate" },
120
+ ],
121
+ flags: [
122
+ { name: "--pairing-code-stdin", value: "boolean", summary: "Read a one-time pairing code from stdin as the rotation authority." },
123
+ { name: "--admin-token-stdin", value: "boolean", summary: "Read the hub admin token from stdin as the rotation authority." },
124
+ { name: "--json", value: "boolean", summary: "Emit the rotation result as JSON." },
125
+ ],
126
+ mutates: true,
127
+ json: "payload",
128
+ details: [
129
+ "Requires transient authority on stdin; the credential is never persisted or echoed.",
130
+ "A rotation left pending by a crash is resumed here — startup and status stop rather than guess which key generation is live.",
131
+ ],
132
+ },
110
133
  {
111
134
  command: ["capabilities"],
112
135
  summary: "List the declared CLI capabilities and the management routes they drive.",
@@ -130,6 +153,21 @@ export const CAPABILITIES: readonly Capability[] = [
130
153
  json: "envelope",
131
154
  details: ["Reads local config; drives no management API route."],
132
155
  },
156
+ {
157
+ command: ["provider", "keychain"],
158
+ summary: "Move a provider's API key into the OS keychain, restore it, or report where it lives.",
159
+ routes: [
160
+ { method: "GET", path: "/api/providers/keychain" },
161
+ { method: "POST", path: "/api/providers/keychain" },
162
+ ],
163
+ flags: [{ name: "--json", value: "boolean", summary: "Emit the keychain status or result as JSON." }],
164
+ mutates: true,
165
+ json: "payload",
166
+ details: [
167
+ "`store` verifies every keychain write by read-back before config.json is rewritten with keychain: references; an unavailable keychain refuses with 503 and leaves the file untouched.",
168
+ "Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.",
169
+ ],
170
+ },
133
171
  {
134
172
  command: ["account", "list"],
135
173
  summary: "Codex OAuth accounts with pool priority and pause state.",
@@ -460,13 +498,14 @@ export const CAPABILITIES: readonly Capability[] = [
460
498
  },
461
499
  {
462
500
  command: ["integration", "native"],
463
- summary: "Show or toggle the native Claude, Claude Desktop, Codex, and Grok integrations.",
501
+ summary: "Show or toggle the native Claude, Claude Desktop, Codex, and Grok integrations, and read the Cursor status (which builds are installed, gateway values, last request seen).",
464
502
  routes: [
465
503
  { method: "GET", path: "/api/native-integrations" },
466
504
  { method: "PUT", path: "/api/native-integrations/claude" },
467
505
  { method: "PUT", path: "/api/native-integrations/claude-desktop" },
468
506
  { method: "PUT", path: "/api/native-integrations/codex" },
469
507
  { method: "PUT", path: "/api/native-integrations/grok" },
508
+ { method: "GET", path: "/api/native-integrations/cursor" },
470
509
  ],
471
510
  flags: [{ name: "--json", value: "boolean", summary: "Emit the client rows or toggle result as JSON." }],
472
511
  mutates: true,
@@ -54,6 +54,9 @@ export async function syncClaudeAgentDefsAtProxyStartup(
54
54
  const warn = deps.warn ?? (message => console.warn(message));
55
55
 
56
56
  try {
57
+ // Hub role: never rewrite this host's ~/.claude roster on startup (same rule as
58
+ // shouldSyncCodexOnStart / shouldSyncGrokOnStart — the hub serves other machines).
59
+ if (config.runtimeRole === "hub") return null;
57
60
  if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
58
61
  return inject(config, {});
59
62
  }
package/src/cli/claude.ts CHANGED
@@ -21,11 +21,22 @@ import { resolveClaudeAuthMode } from "../claude/auth-mode";
21
21
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
22
22
  import { selfLaunchArgv } from "../lib/self-launch-argv";
23
23
  import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context";
24
+ import { readClientConnectionState } from "../client/state";
25
+ import { readServiceApiTokenState } from "../lib/service-secrets";
26
+ import { DEFAULT_CATALOG_PATH } from "../codex/paths";
27
+ import { readFileSync } from "node:fs";
28
+ import { aliasForNative, aliasForRoute } from "../claude/alias";
29
+ import { desktop3pAlias } from "../claude/desktop-3p";
24
30
 
25
31
  export interface ClaudeLaunchEnv {
26
32
  [key: string]: string | undefined;
27
33
  }
28
34
 
35
+ export interface ClaudeRoutingTarget {
36
+ baseUrl: string;
37
+ admissionToken: string;
38
+ }
39
+
29
40
  /**
30
41
  * Injectable IO for tests. `env` is deliberately NOT injectable: it is bound to the
31
42
  * launch base so detection and the spawned process can never disagree (audit R3-3).
@@ -61,6 +72,20 @@ function targetsLocalClaudeProxy(value: string | undefined, port: number): boole
61
72
  }
62
73
  }
63
74
 
75
+ function targetsClaudeRoutingTarget(value: string | undefined, target: ClaudeRoutingTarget): boolean {
76
+ if (!value) return false;
77
+ try {
78
+ const actual = new URL(value);
79
+ const expected = new URL(target.baseUrl);
80
+ return actual.origin === expected.origin
81
+ && (actual.pathname === "/" || actual.pathname === "")
82
+ && !actual.username
83
+ && !actual.password;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
64
89
  /**
65
90
  * Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
66
91
  * token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
@@ -70,11 +95,14 @@ function targetsLocalClaudeProxy(value: string | undefined, port: number): boole
70
95
  */
71
96
  export function buildClaudeEnv(
72
97
  config: OcxConfig,
73
- port: number,
98
+ portOrTarget: number | ClaudeRoutingTarget,
74
99
  base: ClaudeLaunchEnv,
75
100
  contextWindows: Record<string, number> = {},
76
101
  deps: ClaudeEnvDeps = {},
77
102
  ): ClaudeLaunchEnv {
103
+ const explicitTarget = typeof portOrTarget === "number" ? null : portOrTarget;
104
+ const port = typeof portOrTarget === "number" ? portOrTarget : null;
105
+ const managedBaseUrl = explicitTarget ? new URL(explicitTarget.baseUrl).origin : `http://127.0.0.1:${port}`;
78
106
  const env: ClaudeLaunchEnv = { ...base };
79
107
  // Step 1 — strip OUR OWN dummy from the inherited environment before anything reads
80
108
  // or writes the token slot. setDefault below preserves any non-empty value, so a
@@ -120,9 +148,9 @@ export function buildClaudeEnv(
120
148
  if (deps.allowRootSkipPermissions === true) {
121
149
  setDefault("IS_SANDBOX", "1");
122
150
  }
123
- setDefault("ANTHROPIC_BASE_URL", `http://127.0.0.1:${port}`);
151
+ setDefault("ANTHROPIC_BASE_URL", managedBaseUrl);
124
152
  const existingBaseUrl = env.ANTHROPIC_BASE_URL;
125
- if (existingBaseUrl) {
153
+ if (existingBaseUrl && port !== null) {
126
154
  try {
127
155
  const parsed = new URL(existingBaseUrl);
128
156
  const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
@@ -151,19 +179,23 @@ export function buildClaudeEnv(
151
179
  }
152
180
  // Subscription-preserving default (teamclaude --no-mitm / Vercel gateway pattern):
153
181
  // setting ANTHROPIC_AUTH_TOKEN/API_KEY disables claude.ai connectors and overrides
154
- // the user's Claude login. Only inject a token when the proxy actually requires an
155
- // admission key; otherwise Claude Code keeps its own OAuth and sends it to us
156
- // native claude models then pass through verbatim (see server/claude-messages.ts).
157
- const ownTokens = ownAdmissionTokens(config);
158
- const targetsLocalProxy = targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port);
182
+ // the user's Claude login. Resolve the mode before adding any proxy-owned credential:
183
+ // subscription launches must keep their OAuth, while proxy launches may use the
184
+ // admission key or dummy marker (see server/claude-messages.ts).
185
+ const ownTokens = explicitTarget ? [explicitTarget.admissionToken] : ownAdmissionTokens(config);
186
+ const targetsLocalProxy = explicitTarget
187
+ ? targetsClaudeRoutingTarget(env.ANTHROPIC_BASE_URL, explicitTarget)
188
+ : targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port!);
189
+ const isOwnAdmissionToken = (value: string): boolean =>
190
+ ownTokens.includes(value) || isProxyAdmissionSecret(value, config);
159
191
  const inheritedApiKey = env.ANTHROPIC_API_KEY;
160
- if (typeof inheritedApiKey === "string" && isProxyAdmissionSecret(inheritedApiKey, config)) {
192
+ if (typeof inheritedApiKey === "string" && isOwnAdmissionToken(inheritedApiKey)) {
161
193
  delete env.ANTHROPIC_API_KEY;
162
194
  }
163
195
  const hasUserApiKey = Boolean(env.ANTHROPIC_API_KEY?.trim());
164
196
  const inheritedAuthToken = env.ANTHROPIC_AUTH_TOKEN;
165
197
  const inheritedTokenIsOurs = typeof inheritedAuthToken === "string"
166
- && isProxyAdmissionSecret(inheritedAuthToken, config);
198
+ && isOwnAdmissionToken(inheritedAuthToken);
167
199
  // system-env may have injected the proxy's admission key into the parent. A
168
200
  // proof-bound external BASE_URL is still user-owned, so never let our inherited
169
201
  // key follow it. A user API key also wins on a local launch; remove only the token
@@ -172,24 +204,30 @@ export function buildClaudeEnv(
172
204
  if (inheritedTokenIsOurs && (!targetsLocalProxy || hasUserApiKey)) {
173
205
  delete env.ANTHROPIC_AUTH_TOKEN;
174
206
  }
175
- if (targetsLocalProxy && !hasUserApiKey && ownTokens.length > 0) {
176
- setDefault("ANTHROPIC_AUTH_TOKEN", ownTokens[0]);
177
- }
178
- // Detection reads the SANITIZED launch env — the exact object spawned below — so the
179
- // resolver and the spawned process cannot disagree. It deliberately does NOT read the
180
- // raw base: the provenance strip above already removed dotenv-only credentials, and
181
- // letting a value the child never receives decide the marker left an auto-mode user
182
- // with neither the credential NOR the proxy marker (#701 audit round 2). Injected deps
183
- // are spread FIRST and `env` bound LAST, and the injection type excludes `env`, so a
184
- // test fake cannot break that. `ownTokens` is bound last for the same reason: it is
185
- // config-derived, and a fake that replaced it could make our own admission key look
186
- // like user auth.
207
+ // Detection reads the sanitized launch env before proxy-owned credentials are added.
208
+ // The provenance strip above removed dotenv-only credentials, and ownTokens keeps a
209
+ // configured admission key from being mistaken for user auth (#701 audit round 2).
187
210
  const resolved = resolveClaudeAuthMode(config, detectClaudeAuth({
188
211
  ...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
189
212
  ...(deps.authDetect ?? {}),
190
213
  env: () => env as NodeJS.ProcessEnv,
191
214
  ownTokens,
192
215
  }));
216
+ // An explicit connected target is not a subscription launch. The caller named a hub and
217
+ // handed us the client admission token for it, so auth-mode detection - which reads the
218
+ // local environment - has no bearing on whether that token belongs in the child env.
219
+ // Without this, a machine whose environment reads as subscription strips the very
220
+ // credential the connected launch was constructed with (#3148 carry).
221
+ if (resolved.markerMode === "subscription" && !explicitTarget) {
222
+ // A prior system-env snapshot may have left our admission key in the inherited
223
+ // environment. It belongs to the proxy data plane, not Claude subscription OAuth.
224
+ const token = env.ANTHROPIC_AUTH_TOKEN?.trim();
225
+ if (token && (token === PROXY_MARKER || isProxyAdmissionSecret(token, config))) {
226
+ delete env.ANTHROPIC_AUTH_TOKEN;
227
+ }
228
+ } else if (targetsLocalProxy && !hasUserApiKey && ownTokens.length > 0) {
229
+ setDefault("ANTHROPIC_AUTH_TOKEN", ownTokens[0]);
230
+ }
193
231
  if (!env.ANTHROPIC_AUTH_TOKEN && !hasUserApiKey && targetsLocalProxy && resolved.markerMode === "proxy") {
194
232
  env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER;
195
233
  }
@@ -199,7 +237,7 @@ export function buildClaudeEnv(
199
237
  && typeof finalAuthToken === "string"
200
238
  && (
201
239
  finalAuthToken.trim() === PROXY_MARKER
202
- || isProxyAdmissionSecret(finalAuthToken, config)
240
+ || isOwnAdmissionToken(finalAuthToken)
203
241
  );
204
242
  if (resolved.origin === "auto-unknown") {
205
243
  console.error("⚠ Claude 인증을 확인하지 못했습니다 — 구독 방식으로 진행합니다. GUI에서 인증 모드를 직접 지정하면 이 판단을 덮어쓸 수 있습니다.");
@@ -282,6 +320,40 @@ export async function fetchClaudeContextWindows(config: OcxConfig, port: number,
282
320
  }
283
321
  }
284
322
 
323
+ export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): Record<string, number> {
324
+ try {
325
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { models?: unknown };
326
+ if (!Array.isArray(parsed.models)) return {};
327
+ const out: Record<string, number> = {};
328
+ const put = (key: string, value: number) => { if (out[key] === undefined) out[key] = value; };
329
+ for (const row of parsed.models) {
330
+ if (!row || typeof row !== "object" || Array.isArray(row)) continue;
331
+ const entry = row as Record<string, unknown>;
332
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
333
+ const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0
334
+ ? entry.context_window
335
+ : undefined;
336
+ if (!slug || contextWindow === undefined) continue;
337
+ put(slug, contextWindow);
338
+ const slash = slug.indexOf("/");
339
+ if (slash > 0 && slash < slug.length - 1) {
340
+ const provider = slug.slice(0, slash);
341
+ const id = slug.slice(slash + 1);
342
+ const routeAlias = aliasForRoute(provider, id);
343
+ if (routeAlias) put(routeAlias, contextWindow);
344
+ put(desktop3pAlias(provider, id), contextWindow);
345
+ } else {
346
+ const nativeAlias = aliasForNative(slug);
347
+ if (nativeAlias) put(nativeAlias, contextWindow);
348
+ put(desktop3pAlias("native", slug), contextWindow);
349
+ }
350
+ }
351
+ return out;
352
+ } catch {
353
+ return {};
354
+ }
355
+ }
356
+
285
357
  async function ensureProxyForClaude(): Promise<number | null> {
286
358
  const live = await findLiveProxy();
287
359
  if (live) return live.port;
@@ -340,21 +412,45 @@ export async function cmdClaude(args: string[]): Promise<number> {
340
412
  console.error("Claude inbound is disabled (config.claudeCode.enabled=false — flip the Claude ON toggle in the GUI or edit config).");
341
413
  return 1;
342
414
  }
343
- const port = await ensureProxyForClaude();
344
- if (!port) {
345
- console.error("❌ Proxy did not become healthy after starting.");
415
+ const clientState = readClientConnectionState();
416
+ if (clientState.kind === "invalid" || clientState.kind === "mismatched") {
417
+ console.error(`Client state is ${clientState.kind}: ${clientState.reason}`);
346
418
  return 1;
347
419
  }
348
- const contextWindows = await fetchClaudeContextWindows(config, port);
420
+ let route: number | ClaudeRoutingTarget;
421
+ let contextWindows: Record<string, number>;
422
+ if (clientState.kind === "connected") {
423
+ if (!clientState.value.selectedClients.includes("claude")) {
424
+ console.error("Claude is not selected for this remote hub connection.");
425
+ return 1;
426
+ }
427
+ const token = readServiceApiTokenState();
428
+ if (token.kind !== "present" || token.fingerprint !== clientState.value.tokenFingerprint) {
429
+ console.error(token.kind === "absent" ? "Connected service token is missing." : "Connected service token ownership changed.");
430
+ return 1;
431
+ }
432
+ route = { baseUrl: clientState.value.serverUrl, admissionToken: token.token };
433
+ contextWindows = readConnectedClaudeContextWindows();
434
+ } else {
435
+ const port = await ensureProxyForClaude();
436
+ if (!port) {
437
+ console.error("❌ Proxy did not become healthy after starting.");
438
+ return 1;
439
+ }
440
+ route = port;
441
+ contextWindows = await fetchClaudeContextWindows(config, port);
442
+ }
349
443
  const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args);
350
- const env = buildClaudeEnv(config, port, process.env, contextWindows, { allowRootSkipPermissions });
444
+ const env = buildClaudeEnv(config, route, process.env, contextWindows, { allowRootSkipPermissions });
351
445
  if (allowRootSkipPermissions) {
352
446
  console.error(rootSkipPermissionsNotice(env));
353
447
  }
354
448
  // Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI
355
449
  // never refreshes it, so the picker would keep showing yesterday's aliases.
356
450
  try {
357
- const cachePath = await refreshGatewayModelCacheFromProxy(port, { admissionConfig: config });
451
+ const cachePath = typeof route === "number"
452
+ ? await refreshGatewayModelCacheFromProxy(route, { admissionConfig: config })
453
+ : await refreshGatewayModelCacheFromProxy(route, { admissionConfig: config });
358
454
  if (cachePath === null) {
359
455
  console.error("⚠ Gateway model cache could not be refreshed; the model picker may be stale.");
360
456
  }
@@ -363,14 +459,16 @@ export async function cmdClaude(args: string[]): Promise<number> {
363
459
  console.error(`⚠ Gateway model cache could not be refreshed: ${message}`);
364
460
  }
365
461
  // Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md.
366
- try {
367
- const written = injectClaudeAgentDefs(config, contextWindows);
368
- if (written === null) {
369
- console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
462
+ if (typeof route === "number") {
463
+ try {
464
+ const written = injectClaudeAgentDefs(config, contextWindows);
465
+ if (written === null) {
466
+ console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
467
+ }
468
+ } catch (error) {
469
+ const message = error instanceof Error ? error.message : String(error);
470
+ console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
370
471
  }
371
- } catch (error) {
372
- const message = error instanceof Error ? error.message : String(error);
373
- console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
374
472
  }
375
473
  return await new Promise<number>(resolve => {
376
474
  const inv = commandInvocation("claude", args);