@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -538,6 +538,65 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknow
538
538
  return changed ? { ...body, input: repaired } : body;
539
539
  }
540
540
 
541
+ /**
542
+ * Make unambiguous Responses tool pairs adjacent for upstream parsers that require it.
543
+ *
544
+ * [Decision Log]
545
+ * - 목적과 의도: Keep Codex hook-injected developer context without letting it make a strict upstream reject the matching tool result.
546
+ * - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence; globally reordering valid history would change tolerant providers unnecessarily.
547
+ * - 검토한 주요 대안: Reorder every Responses request, drop the intervening message, or gate a lossless reorder behind provider capability metadata.
548
+ * - 선택한 방식: Reorder only unique call/result pairs for providers that explicitly require adjacency, preserving every intervening item immediately after the result.
549
+ * - 다른 대안 대신 이 방식을 선택한 이유: The provider gate limits semantic blast radius, while refusing ambiguous duplicate ids avoids guessing which result belongs to which call.
550
+ * - 장점, 단점 및 영향: DeepSeek receives the adjacency its parser requires; tolerant providers stay byte/order equivalent. Ambiguous duplicate ids still fail upstream rather than being silently rewritten.
551
+ */
552
+ function normalizeResponsesToolResultAdjacency(body: unknown): unknown {
553
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
554
+ const input = body.input;
555
+ const calls = new Map<string, number[]>();
556
+ const outputs = new Map<string, number[]>();
557
+
558
+ const appendIndex = (map: Map<string, number[]>, key: string, index: number): void => {
559
+ const existing = map.get(key);
560
+ if (existing) existing.push(index);
561
+ else map.set(key, [index]);
562
+ };
563
+
564
+ for (let index = 0; index < input.length; index += 1) {
565
+ const item = input[index];
566
+ if (!isPlainObject(item) || typeof item.call_id !== "string" || item.call_id.length === 0) continue;
567
+ if (item.type === "function_call" || item.type === "local_shell_call") {
568
+ appendIndex(calls, `function:${item.call_id}`, index);
569
+ } else if (item.type === "custom_tool_call") {
570
+ appendIndex(calls, `custom:${item.call_id}`, index);
571
+ } else if (item.type === "function_call_output") {
572
+ appendIndex(outputs, `function:${item.call_id}`, index);
573
+ } else if (item.type === "custom_tool_call_output") {
574
+ appendIndex(outputs, `custom:${item.call_id}`, index);
575
+ }
576
+ }
577
+
578
+ const movedOutputIndices = new Set<number>();
579
+ const outputAfterCall = new Map<number, unknown>();
580
+ for (const [key, callIndices] of calls) {
581
+ const outputIndices = outputs.get(key);
582
+ if (callIndices.length !== 1 || outputIndices?.length !== 1) continue;
583
+ const callIndex = callIndices[0]!;
584
+ const outputIndex = outputIndices[0]!;
585
+ if (outputIndex === callIndex + 1) continue;
586
+ movedOutputIndices.add(outputIndex);
587
+ outputAfterCall.set(callIndex, input[outputIndex]);
588
+ }
589
+ if (movedOutputIndices.size === 0) return body;
590
+
591
+ const normalized: unknown[] = [];
592
+ for (let index = 0; index < input.length; index += 1) {
593
+ if (movedOutputIndices.has(index)) continue;
594
+ normalized.push(input[index]);
595
+ if (outputAfterCall.has(index)) normalized.push(outputAfterCall.get(index));
596
+ }
597
+ return { ...body, input: normalized };
598
+ }
599
+
541
600
  /**
542
601
  * Remove `previous_response_id` before forwarding. Two triggers:
543
602
  * - the proxy expanded the request into a full input replay (the id is now redundant), or
@@ -1158,6 +1217,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1158
1217
  if (forward || stateless) {
1159
1218
  outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
1160
1219
  }
1220
+ if (provider.requiresAdjacentResponsesToolResults === true) {
1221
+ outBody = normalizeResponsesToolResultAdjacency(outBody);
1222
+ }
1161
1223
  if (forward) {
1162
1224
  outBody = stripUnsupportedForwardParams(outBody);
1163
1225
  } else {
@@ -1,8 +1,6 @@
1
1
  import {
2
- isAllowedToolChoice,
3
2
  namespacedToolName,
4
- toolAllowedByChoice,
5
- toolChoiceAliases,
3
+ toolChoiceToolPredicate,
6
4
  type OcxRequestOptions,
7
5
  type OcxTool,
8
6
  type OcxProviderConfig,
@@ -18,13 +16,6 @@ function uniqueNames(names: readonly string[]): string[] {
18
16
  return [...new Set(names.filter(name => name.trim().length > 0))];
19
17
  }
20
18
 
21
- function toolChoiceAllows(tool: Pick<OcxTool, "namespace" | "name">, toolChoice: OcxRequestOptions["toolChoice"] | undefined): boolean {
22
- if (!toolChoice || toolChoice === "auto" || toolChoice === "required") return true;
23
- if (toolChoice === "none") return false;
24
- if (isAllowedToolChoice(toolChoice)) return toolAllowedByChoice(tool, new Set(toolChoice.allowedTools));
25
- return toolChoiceAliases(tool).includes(toolChoice.name);
26
- }
27
-
28
19
  function isOpenAIOrChatGPTHost(hostname: string): boolean {
29
20
  return hostname === "openai.com"
30
21
  || hostname.endsWith(".openai.com")
@@ -65,7 +56,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools(
65
56
  toWireName: (tool: Pick<OcxTool, "namespace" | "name">) => string = tool => namespacedToolName(tool.namespace, tool.name),
66
57
  ): string | undefined {
67
58
  const visibleNames = tools
68
- ?.filter(tool => toolChoiceAllows(tool, toolChoice))
59
+ ?.filter(toolChoiceToolPredicate(toolChoice))
69
60
  .map(toWireName);
70
61
  return buildNonOpenAIToolCatalogNudgeFromNames(visibleNames);
71
62
  }
@@ -9,6 +9,7 @@
9
9
  * - message_delta.usage is cumulative; message_start embeds a full message snapshot.
10
10
  * - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200.
11
11
  */
12
+ import { createHash } from "node:crypto";
12
13
  import { isTransientUpstreamStatus } from "../lib/upstream-retry";
13
14
  import {
14
15
  isTranslatorBudgetExceededError,
@@ -24,6 +25,27 @@ function isRec(v: unknown): v is Rec {
24
25
  return !!v && typeof v === "object" && !Array.isArray(v);
25
26
  }
26
27
 
28
+ function reasoningIdentityDigest(value: string): string {
29
+ return createHash("sha256").update(value).digest("base64url");
30
+ }
31
+
32
+ /** Fixed-size identity that preserves protocol boundaries without retaining upstream strings. */
33
+ function boundedReasoningIdentity(value: unknown): string {
34
+ if (typeof value === "number") {
35
+ if (Number.isSafeInteger(value) && value >= 0) return `n${value}`;
36
+ if (Number.isFinite(value)) return `d${value}`;
37
+ return Number.isNaN(value) ? "dnan" : value > 0 ? "dinf" : "d-inf";
38
+ }
39
+ if (typeof value === "string") {
40
+ return `s${reasoningIdentityDigest(value)}`;
41
+ }
42
+ if (value === null) return "z";
43
+ if (typeof value === "boolean") return value ? "b1" : "b0";
44
+ if (Array.isArray(value)) return `a${reasoningIdentityDigest(JSON.stringify(value) ?? "[]")}`;
45
+ // Preserve the prior String(record) category semantics without serializing untrusted trees.
46
+ return typeof value === "object" ? "o" : "u";
47
+ }
48
+
27
49
  function uuid(): string {
28
50
  return crypto.randomUUID().replace(/-/g, "");
29
51
  }
@@ -188,7 +210,7 @@ interface OpenBlock {
188
210
  argsBufBytes?: number;
189
211
  webSearchArgsEmitted?: boolean;
190
212
  callId?: string;
191
- /** Last reasoning part identity (item + summary/content index) seen by this thinking block. */
213
+ /** Last fixed-size reasoning identity (item + summary/content index) seen by this block. */
192
214
  reasoningPartKey?: string;
193
215
  }
194
216
 
@@ -245,14 +267,13 @@ export function responsesSseToAnthropicSse(
245
267
  emit("message_start", { type: "message_start", message: messageSnapshot(model) });
246
268
  emit("ping", { type: "ping" });
247
269
  };
248
- // Idle keepalive (devlog 100): real Anthropic streams may interleave pings anywhere;
249
- // synthesizing one during upstream silence protects remote deployments behind
250
- // LB/NAT idle timeouts and covers slow first tokens. Cheap and spec-legal.
270
+ // Once a semantic Anthropic message has started, keepalive pings protect remote
271
+ // deployments behind LB/NAT idle timeouts. Transport-only Responses prelude frames
272
+ // must not manufacture a message before a possible initial error.
251
273
  if (pingIntervalMs > 0) {
252
274
  pingTimer = setInterval(() => {
253
- if (terminated) return;
275
+ if (terminated || !started) return;
254
276
  try {
255
- ensureStarted();
256
277
  emit("ping", { type: "ping" });
257
278
  } catch { /* controller torn down; the read loop is ending anyway */ }
258
279
  }, pingIntervalMs);
@@ -328,20 +349,24 @@ export function responsesSseToAnthropicSse(
328
349
  ))));
329
350
  return;
330
351
  }
331
- ensureStarted();
332
- closeOpenBlock();
333
352
  const type = upstreamDerived && isTransientUpstreamStatus(status) ? "overloaded_error" : undefined;
353
+ if (!started) {
354
+ // An initial upstream failure is an Anthropic error stream, not a partial message.
355
+ // Do not manufacture message_start/ping before the terminal error.
356
+ emit("error", anthropicErrorBody(status, message, type, code));
357
+ return;
358
+ }
359
+ closeOpenBlock();
334
360
  emit("error", anthropicErrorBody(status, message, type, code));
335
361
  };
336
362
 
337
363
  const handleFrame = (eventName: string, data: Rec) => {
338
364
  switch (eventName) {
339
365
  case "response.created":
340
- ensureStarted();
366
+ // Transport prelude only. Start Anthropic framing on semantic output or completion.
341
367
  break;
342
368
  case "response.heartbeat":
343
- ensureStarted();
344
- emit("ping", { type: "ping" });
369
+ if (started) emit("ping", { type: "ping" });
345
370
  break;
346
371
  case "response.output_text.delta": {
347
372
  if (typeof data.delta !== "string" || data.delta.length === 0) break;
@@ -361,9 +386,12 @@ export function responsesSseToAnthropicSse(
361
386
  // so multi-part summaries do not glue into one run-on paragraph. Frames
362
387
  // without part indices produce a constant key and never get a separator.
363
388
  const slot = eventName === "response.reasoning_summary_text.delta"
364
- ? `s${String(data.summary_index)}`
365
- : `c${String(data.content_index)}`;
366
- const partKey = `${String(data.item_id)}:${slot}`;
389
+ ? `s${boundedReasoningIdentity(data.summary_index)}`
390
+ : `c${boundedReasoningIdentity(data.content_index)}`;
391
+ // Upstream string metadata can be arbitrarily large. Hash strings into fixed-size
392
+ // components while retaining item and part equality, rather than dropping item_id and
393
+ // accidentally joining distinct malformed reasoning items.
394
+ const partKey = `${boundedReasoningIdentity(data.item_id)}:${slot}`;
367
395
  if (open!.reasoningPartKey !== undefined && open!.reasoningPartKey !== partKey) {
368
396
  emit("content_block_delta", {
369
397
  type: "content_block_delta", index: open!.index,
package/src/cli/claude.ts CHANGED
@@ -12,6 +12,7 @@ import { injectClaudeAgentDefs } from "../claude/agents-inject";
12
12
  import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows";
13
13
  import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
14
14
  import { commandInvocation } from "../lib/win-exec";
15
+ import { isProxyAdmissionSecret } from "../server/auth-cors";
15
16
  import { findLiveProxy } from "../server/proxy-liveness";
16
17
  import type { OcxConfig } from "../types";
17
18
  import { configuredAdminToken } from "../lib/admin-secrets";
@@ -34,6 +35,29 @@ export type ClaudeEnvDeps = {
34
35
  preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null;
35
36
  };
36
37
 
38
+ function isClaudeLoopbackHostname(hostname: string): boolean {
39
+ const normalized = hostname.toLowerCase().replace(/\.$/, "");
40
+ return normalized === "localhost"
41
+ || normalized === "127.0.0.1"
42
+ || normalized === "::1"
43
+ || normalized === "[::1]";
44
+ }
45
+
46
+ function targetsLocalClaudeProxy(value: string | undefined, port: number): boolean {
47
+ if (!value) return false;
48
+ try {
49
+ const parsed = new URL(value);
50
+ const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
51
+ return parsed.protocol === "http:"
52
+ && isClaudeLoopbackHostname(parsed.hostname)
53
+ && effectivePort === port
54
+ && parsed.username === ""
55
+ && parsed.password === "";
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
37
61
  /**
38
62
  * Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
39
63
  * token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
@@ -54,7 +78,7 @@ export function buildClaudeEnv(
54
78
  // stale marker left in place would suppress the admission key and then be removed,
55
79
  // leaving the child with no token at all (audit R2-1). It is opencodex state, never
56
80
  // user auth, so dropping it unconditionally is safe.
57
- if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
81
+ if (env.ANTHROPIC_AUTH_TOKEN?.trim() === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
58
82
  // Step 1b — drop Anthropic credentials AND destinations that Bun may have synthesized
59
83
  // from a project `.env`/`.env.local`. The plain-Node launcher records genuine parent
60
84
  // exports before Bun starts and pairs that context with an argv proof, so with a
@@ -95,10 +119,12 @@ export function buildClaudeEnv(
95
119
  if (existingBaseUrl) {
96
120
  try {
97
121
  const parsed = new URL(existingBaseUrl);
98
- const isLoopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
99
- if (isLoopback && parsed.port !== "" && Number(parsed.port) !== port) {
122
+ const effectivePort = parsed.port === "" ? 80 : Number(parsed.port);
123
+ if (parsed.protocol === "http:"
124
+ && isClaudeLoopbackHostname(parsed.hostname)
125
+ && effectivePort !== port) {
100
126
  const replacement = `http://127.0.0.1:${port}`;
101
- console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${existingBaseUrl} with ${replacement}.`);
127
+ console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${parsed.origin} with ${replacement}.`);
102
128
  env.ANTHROPIC_BASE_URL = replacement;
103
129
  }
104
130
  } catch {
@@ -110,8 +136,26 @@ export function buildClaudeEnv(
110
136
  // the user's Claude login. Only inject a token when the proxy actually requires an
111
137
  // admission key; otherwise Claude Code keeps its own OAuth and sends it to us —
112
138
  // native claude models then pass through verbatim (see server/claude-messages.ts).
113
- if ((config.apiKeys?.length ?? 0) > 0) {
114
- setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
139
+ const ownTokens = ownAdmissionTokens(config);
140
+ const targetsLocalProxy = targetsLocalClaudeProxy(env.ANTHROPIC_BASE_URL, port);
141
+ const inheritedApiKey = env.ANTHROPIC_API_KEY;
142
+ if (typeof inheritedApiKey === "string" && isProxyAdmissionSecret(inheritedApiKey, config)) {
143
+ delete env.ANTHROPIC_API_KEY;
144
+ }
145
+ const hasUserApiKey = Boolean(env.ANTHROPIC_API_KEY?.trim());
146
+ const inheritedAuthToken = env.ANTHROPIC_AUTH_TOKEN;
147
+ const inheritedTokenIsOurs = typeof inheritedAuthToken === "string"
148
+ && isProxyAdmissionSecret(inheritedAuthToken, config);
149
+ // system-env may have injected the proxy's admission key into the parent. A
150
+ // proof-bound external BASE_URL is still user-owned, so never let our inherited
151
+ // key follow it. A user API key also wins on a local launch; remove only the token
152
+ // values recognized by the shared proxy-admission contract and preserve every
153
+ // other token.
154
+ if (inheritedTokenIsOurs && (!targetsLocalProxy || hasUserApiKey)) {
155
+ delete env.ANTHROPIC_AUTH_TOKEN;
156
+ }
157
+ if (targetsLocalProxy && !hasUserApiKey && ownTokens.length > 0) {
158
+ setDefault("ANTHROPIC_AUTH_TOKEN", ownTokens[0]);
115
159
  }
116
160
  // Detection reads the SANITIZED launch env — the exact object spawned below — so the
117
161
  // resolver and the spawned process cannot disagree. It deliberately does NOT read the
@@ -126,11 +170,19 @@ export function buildClaudeEnv(
126
170
  ...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
127
171
  ...(deps.authDetect ?? {}),
128
172
  env: () => env as NodeJS.ProcessEnv,
129
- ownTokens: ownAdmissionTokens(config),
173
+ ownTokens,
130
174
  }));
131
- if (!env.ANTHROPIC_AUTH_TOKEN && resolved.markerMode === "proxy") {
175
+ if (!env.ANTHROPIC_AUTH_TOKEN && !hasUserApiKey && targetsLocalProxy && resolved.markerMode === "proxy") {
132
176
  env.ANTHROPIC_AUTH_TOKEN = PROXY_MARKER;
133
177
  }
178
+ const finalAuthToken = env.ANTHROPIC_AUTH_TOKEN;
179
+ const hostOwnsAuthentication = targetsLocalProxy
180
+ && !hasUserApiKey
181
+ && typeof finalAuthToken === "string"
182
+ && (
183
+ finalAuthToken.trim() === PROXY_MARKER
184
+ || isProxyAdmissionSecret(finalAuthToken, config)
185
+ );
134
186
  if (resolved.origin === "auto-unknown") {
135
187
  console.error("⚠ Claude 인증을 확인하지 못했습니다 — 구독 방식으로 진행합니다. GUI에서 인증 모드를 직접 지정하면 이 판단을 덮어쓸 수 있습니다.");
136
188
  }
@@ -152,7 +204,7 @@ export function buildClaudeEnv(
152
204
  // Claude Code 2.1.206+ also treats this as a host-auth assertion. Injecting it
153
205
  // without a host token makes a valid claude.ai subscription look logged out,
154
206
  // so the guard is only safe when opencodex actually owns authentication.
155
- if (env.ANTHROPIC_AUTH_TOKEN) {
207
+ if (hostOwnsAuthentication) {
156
208
  setDefault("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1");
157
209
  }
158
210
  // Opt-in effort forcing (devlog 136 B6): opus-shaped aliases already carry
@@ -17,6 +17,8 @@ const DEFAULT_DEPS: CodexShimAutoRestoreCliDeps = {
17
17
 
18
18
  export function skipsCodexShimAutoRestore(command: string | undefined, args: string[]): boolean {
19
19
  if (command === "uninstall" || command === "remove") return true;
20
+ // `lab` is read-only inspection; it must not trigger shim side effects.
21
+ if (command === "lab") return true;
20
22
  return command === "codex-shim" && ["install", "uninstall", "remove"].includes(args[1] ?? "");
21
23
  }
22
24
 
@@ -60,9 +60,16 @@ export function codexShimReadinessWarnings(
60
60
 
61
61
  export function collectCodexShimReadinessWarnings(): string[] {
62
62
  const config = loadConfig();
63
+ let externalProvider: string | null = null;
64
+ try {
65
+ externalProvider = currentExternalCodexModelProvider();
66
+ } catch {
67
+ // Routing readiness is advisory; getCodexRoutingKind() already reports an
68
+ // unreadable config as unknown, so preserve that warning instead of failing.
69
+ }
63
70
  return codexShimReadinessWarnings({
64
71
  routingKind: getCodexRoutingKind(),
65
- externalProvider: currentExternalCodexModelProvider(),
72
+ externalProvider,
66
73
  processProxyEnvPresent: PROXY_ENV_KEYS.some(key => Boolean(process.env[key]?.trim())),
67
74
  configuredProxyResolved: Boolean(resolveEnvValue(config.proxy)?.trim()),
68
75
  });
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { clearCodexAccountPin } from "../codex/account-priority";
3
- import { getConfigPath, readConfigDiagnostics, saveConfig, validateConfigCandidate } from "../config";
3
+ import { getConfigPath, readConfigDiagnostics, sanitizeModelCostsForDisplay, saveConfig, validateConfigCandidate } from "../config";
4
4
  import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort";
5
5
  import type { OcxConfig } from "../types";
6
6
  import { normalizeVisionReasoningForModel } from "../vision/reasoning";
@@ -20,6 +20,9 @@ const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
20
20
 
21
21
  function redact(value: unknown, key = ""): unknown {
22
22
  if (SECRET_KEYS.test(key) && typeof value === "string") return value ? "********" : value;
23
+ // modelCosts rows are keyed by model id; a pasted API key in a key position
24
+ // must not be echoed back by config show/get (values are already redacted).
25
+ if (key === "modelCosts") return sanitizeModelCostsForDisplay(value);
23
26
  if (Array.isArray(value)) return value.map(item => redact(item));
24
27
  if (value && typeof value === "object") {
25
28
  return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([childKey, child]) => [childKey, redact(child, childKey)]));
package/src/cli/doctor.ts CHANGED
@@ -23,6 +23,7 @@ import { withNativeMainSharedClaim } from "../codex/native-main-claim";
23
23
  import { probeNativeProfileRecoveryState, resolveNativeProfileContext } from "../codex/native-profile-store";
24
24
  import { NativeProfileError } from "../codex/native-profile-types";
25
25
  import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
26
+ import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback";
26
27
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
27
28
  import { countPendingOpencodexHistory } from "../codex/history-provider";
28
29
  import {
@@ -942,6 +943,15 @@ export async function runDoctor(args: string[] = []): Promise<void> {
942
943
  }
943
944
  }
944
945
 
946
+ console.log("\nCodex agent role files");
947
+ const tomlFallbackRoles = scanCodexAgentRolesWithTomlModelFallback(resolveCodexHomeDirImpl());
948
+ if (tomlFallbackRoles.length === 0) {
949
+ console.log(" ok no per-role model_fallback fields in $CODEX_HOME/agents/*.toml");
950
+ } else {
951
+ console.log(` [WARN] ${tomlFallbackRoles.length} agent role file${tomlFallbackRoles.length === 1 ? "" : "s"} contain${tomlFallbackRoles.length === 1 ? "s" : ""} \`model_fallback\`: ${tomlFallbackRoles.join(", ")}`);
952
+ console.log(" Codex >= 0.146 rejects that field as unknown and skips the whole role. Move the chains to opencodex config `subagentModelFallbackByModel` (keyed by primary model) and remove the field from the TOML files.");
953
+ }
954
+
945
955
  const dual = collectWslDualInstall();
946
956
  if (dual.wsl) {
947
957
  console.log("\nWSL Codex installs");
package/src/cli/help.ts CHANGED
@@ -264,6 +264,20 @@ const helpEntries: Record<string, HelpEntry> = {
264
264
  "Invalid or unknown arguments exit 64. Not-ready, pending, failed, timeout, and unreachable exit 1.",
265
265
  ],
266
266
  },
267
+ lab: {
268
+ usage: "ocx lab <status|verdicts|subjects|subject|observations|events|event|artifacts|artifact|catalog> [options] [--json]",
269
+ summary: "Read-only Compatibility Lab projection inspection (local SQLite; no daemon).",
270
+ details: [
271
+ "status Projection availability, schema versions, and row counts.",
272
+ "verdicts Paginated derived compatibility verdicts with filters.",
273
+ "subjects List subjects; subject <id> returns one typed subject.",
274
+ "observations Paginated observation rows from the projection.",
275
+ "events Event history; event <id> returns one safe typed event.",
276
+ "artifacts Artifact metadata only (no content download).",
277
+ "catalog Packaged protocol/live scenario catalog metadata.",
278
+ "Reads never rebuild the projection, trigger probes, or require the proxy.",
279
+ ],
280
+ },
267
281
  };
268
282
 
269
283
  function packageVersion(): string {
@@ -318,6 +332,7 @@ Usage:
318
332
  ocx grok <sub> Grok Build model selection and apply
319
333
  ocx system <sub> Runtime settings, startup, sync, and updates
320
334
  ocx config <sub> Validated configuration show/get/set/import/export
335
+ ocx lab <sub> Read-only Compatibility Lab projection inspection
321
336
  ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on)
322
337
  ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile
323
338
  ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config)
package/src/cli/index.ts CHANGED
@@ -219,6 +219,22 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
219
219
  }
220
220
  }
221
221
 
222
+ async function findProxyOwnerBeforeJournalRecovery(
223
+ options: { probeConfiguredPort?: boolean } = {},
224
+ ): Promise<{ live: LiveProxy | null; pidSnapshot: number | null }> {
225
+ const pidSnapshot = readPidFileValue();
226
+ const hasRuntimeOwner = readRuntimePort() !== null;
227
+ const shouldProbe = pidSnapshot !== null || hasRuntimeOwner || options.probeConfiguredPort === true;
228
+ const live = shouldProbe ? await findLiveProxy() : null;
229
+ if (live) return { live, pidSnapshot };
230
+
231
+ // The probe established that the snapshotted owner is stale. Compare before
232
+ // deleting so a concurrent start that rewrote the PID file keeps its state.
233
+ removePidIfValueIs(pidSnapshot);
234
+ if (!currentExternalCodexModelProvider()) reconcileJournal();
235
+ return { live: null, pidSnapshot };
236
+ }
237
+
222
238
  async function handleStart(options: { block?: boolean } = {}) {
223
239
  // Native (WinSW) service mode has no batch wrapper to read the service token file
224
240
  // into the environment, so the app loads it here before the server binds. The server
@@ -226,15 +242,10 @@ async function handleStart(options: { block?: boolean } = {}) {
226
242
  const serviceToken = loadServiceTokenFromFile(process.env);
227
243
  if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken;
228
244
  const requestedPort = parsePortOption();
229
- if (!currentExternalCodexModelProvider()) reconcileJournal();
230
- const existingPid = readPid();
231
- if (existingPid) {
232
- const live = await findLiveProxy();
233
- if (live) {
234
- console.error(`⚠️ Proxy already running (PID ${live.pid ?? existingPid}, port ${live.port}). Use 'ocx stop' first.`);
235
- process.exit(1);
236
- }
237
- removePid(existingPid);
245
+ const owner = await findProxyOwnerBeforeJournalRecovery();
246
+ if (owner.live) {
247
+ console.error(`⚠️ Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}). Use 'ocx stop' first.`);
248
+ process.exit(1);
238
249
  }
239
250
 
240
251
  // Interactive-only update prompt. Must run BEFORE we bind a port / write a
@@ -442,13 +453,13 @@ function detachedStartEnvironment(): NodeJS.ProcessEnv {
442
453
  }
443
454
 
444
455
  async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Promise<boolean> {
445
- if (!currentExternalCodexModelProvider()) reconcileJournal();
456
+ const owner = await findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true });
446
457
  const config = loadConfig();
447
458
  if (!codexAutoStartEnabled(config)) {
448
459
  console.log("Codex autostart is disabled.");
449
460
  return false;
450
461
  }
451
- const live = await findLiveProxy();
462
+ const live = owner.live;
452
463
  if (live) {
453
464
  if (options.existingIsSuccess === false) {
454
465
  console.error("Proxy appeared while restart was confirming absence; no start was attempted.");
@@ -1356,6 +1367,11 @@ switch (command) {
1356
1367
  process.exitCode = await handleConfigCommand(args.slice(1));
1357
1368
  break;
1358
1369
  }
1370
+ case "lab": {
1371
+ const { handleLabCommand } = await import("./lab");
1372
+ process.exitCode = await handleLabCommand(args.slice(1));
1373
+ break;
1374
+ }
1359
1375
  case "claude": {
1360
1376
  const { cmdClaude } = await import("./claude");
1361
1377
  // "ocx claude desktop" → write Desktop 3P config