@bitkyc08/opencodex 2.20.0 → 2.22.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 (64) hide show
  1. package/AGENTS_INSTALL.md +32 -0
  2. package/README.md +1 -1
  3. package/gui/dist/assets/{index-DSK3S5HY.js → index-ClEcVlFO.js} +43 -17
  4. package/gui/dist/assets/index-DQsMZzI5.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic.ts +2 -28
  8. package/src/adapters/google.ts +31 -3
  9. package/src/adapters/openai-chat.ts +25 -8
  10. package/src/adapters/responses-tool-schema.ts +67 -0
  11. package/src/bridge.ts +15 -2
  12. package/src/claude/agents-inject.ts +2 -2
  13. package/src/claude/gateway-cache.ts +41 -4
  14. package/src/cli/claude.ts +1 -1
  15. package/src/cli/codex-log-guard-doctor.ts +103 -0
  16. package/src/cli/dispatch.ts +7 -1
  17. package/src/cli/help.ts +1 -1
  18. package/src/cli/models.ts +16 -6
  19. package/src/cli/observe.ts +38 -2
  20. package/src/cli/registry.ts +2 -1
  21. package/src/cli/v2.ts +34 -1
  22. package/src/codex/app-server-processes.ts +46 -26
  23. package/src/codex/catalog/effort.ts +49 -1
  24. package/src/codex/catalog/parsing.ts +64 -4
  25. package/src/codex/catalog/provider-fetch.ts +12 -0
  26. package/src/codex/catalog/sync.ts +14 -1
  27. package/src/codex/convergence.ts +2 -0
  28. package/src/codex/inject.ts +3 -3
  29. package/src/codex/log-guard/inspect.ts +506 -0
  30. package/src/codex/log-guard/lock.ts +150 -0
  31. package/src/codex/log-guard/maintenance.ts +403 -0
  32. package/src/codex/log-guard/path-safety.ts +39 -0
  33. package/src/codex/log-guard/policy.ts +44 -0
  34. package/src/codex/log-guard/processes.ts +205 -0
  35. package/src/codex/log-guard/protection.ts +489 -0
  36. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  37. package/src/codex/paths.ts +5 -0
  38. package/src/codex/plugins-doctor.ts +1 -1
  39. package/src/codex/project-config-warnings.ts +2 -2
  40. package/src/generated/compatibility-version.json +93 -45
  41. package/src/images/loop.ts +15 -5
  42. package/src/providers/antigravity-models.ts +11 -1
  43. package/src/providers/model-discovery.ts +94 -6
  44. package/src/providers/quota.ts +159 -0
  45. package/src/providers/registry.ts +20 -1
  46. package/src/providers/slug-codec.ts +29 -0
  47. package/src/responses/custom-tool-compat.ts +4 -1
  48. package/src/responses/parser.ts +7 -1
  49. package/src/responses/provider-opaque-metadata.ts +73 -0
  50. package/src/responses/schema.ts +6 -0
  51. package/src/router.ts +12 -4
  52. package/src/routing/capability.ts +32 -17
  53. package/src/server/auth-cors.ts +42 -6
  54. package/src/server/index.ts +1 -0
  55. package/src/server/management/agent-settings-routes.ts +20 -2
  56. package/src/server/management/context.ts +15 -0
  57. package/src/server/management/model-routes.ts +12 -3
  58. package/src/server/management/storage-log-guard-routes.ts +186 -0
  59. package/src/server/management-api.ts +3 -1
  60. package/src/server/responses/core.ts +13 -0
  61. package/src/server/system-env.ts +1 -1
  62. package/src/types.ts +24 -2
  63. package/src/web-search/loop.ts +21 -5
  64. package/gui/dist/assets/index-DF_UFrGS.css +0 -1
@@ -14,6 +14,7 @@ import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalo
14
14
  import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
15
15
  import { canSerializeServiceTierForChatModel } from "../providers/service-tier";
16
16
  import { openaiChatCompletionsUrl } from "./openai-chat-url";
17
+ import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
17
18
  import {
18
19
  isTranslatorBudgetExceededError,
19
20
  retainTranslatedEventBatch,
@@ -332,6 +333,16 @@ type InvalidToolCallReason =
332
333
  | "tool_call_function_name_blank"
333
334
  | "tool_call_function_arguments_invalid";
334
335
 
336
+ /**
337
+ * Streamed string fields are absent when null or undefined (#1731): OpenAI-compatible
338
+ * streamers repeat already-sent `id`/`name`/`arguments` as null on continuation deltas.
339
+ * The accumulator and this diagnostic share this predicate so they cannot disagree about
340
+ * which delta was the invalid one.
341
+ */
342
+ function isInvalidStreamStringField(value: unknown): boolean {
343
+ return value != null && typeof value !== "string";
344
+ }
345
+
335
346
  /**
336
347
  * Explain only the rejected wire shape, never its values. This diagnostic exists so provider
337
348
  * compatibility can be tightened from evidence without retaining tool arguments or credentials.
@@ -358,6 +369,10 @@ function diagnoseInvalidToolCalls(
358
369
  // Blank names are caught later at flush, not here, so they are not diagnosed on this
359
370
  // branch. Describe exactly that boundary rather than tightening compatibility in a
360
371
  // diagnostic change.
372
+ // #1731: "present" means the same thing here as in the accumulator — null and undefined
373
+ // are both absent, because some OpenAI-compatible streamers repeat already-sent fields
374
+ // as null on continuation deltas. A separate predicate here would diagnose accepted
375
+ // padding as the failure and point compatibility work at the wrong delta.
361
376
  const streamFunction = (rawToolCall as { function?: unknown }).function;
362
377
  if (streamFunction !== undefined && streamFunction !== null) {
363
378
  if (!isRecord(streamFunction)) {
@@ -367,14 +382,14 @@ function diagnoseInvalidToolCalls(
367
382
  valueType: Array.isArray(streamFunction) ? "array" : typeof streamFunction,
368
383
  };
369
384
  }
370
- if (streamFunction.name !== undefined && typeof streamFunction.name !== "string") {
385
+ if (isInvalidStreamStringField(streamFunction.name)) {
371
386
  return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof streamFunction.name };
372
387
  }
373
- if (streamFunction.arguments !== undefined && typeof streamFunction.arguments !== "string") {
388
+ if (isInvalidStreamStringField(streamFunction.arguments)) {
374
389
  return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof streamFunction.arguments };
375
390
  }
376
391
  }
377
- if (rawToolCall.id !== undefined && typeof rawToolCall.id !== "string") {
392
+ if (isInvalidStreamStringField(rawToolCall.id)) {
378
393
  return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
379
394
  }
380
395
  continue;
@@ -1077,9 +1092,9 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
1077
1092
  if (tools.length === 0) return undefined;
1078
1093
  const xaiTarget = isXaiSchemaTarget(provider);
1079
1094
  const formatted = tools.flatMap(t => {
1080
- const parameters = xaiTarget
1095
+ const parameters = stripResponsesOnlyEncryptedMarker(xaiTarget
1081
1096
  ? normalizeXaiToolParameters(t.parameters)
1082
- : ensureRootObjectType(t.parameters);
1097
+ : ensureRootObjectType(t.parameters));
1083
1098
 
1084
1099
  if (parameters === undefined) return [];
1085
1100
  return [{
@@ -1488,13 +1503,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1488
1503
  }
1489
1504
  const rawName = rawFunction.name;
1490
1505
  const rawArguments = rawFunction.arguments;
1491
- if ((rawName !== undefined && typeof rawName !== "string")
1492
- || (rawArguments !== undefined && typeof rawArguments !== "string")) {
1506
+ // Some OpenAI-compatible streamers repeat already-sent fields as null on
1507
+ // continuation deltas. Treat only null/undefined as absent; every other
1508
+ // non-string value still fails closed before entering the accumulator.
1509
+ if (isInvalidStreamStringField(rawName) || isInvalidStreamStringField(rawArguments)) {
1493
1510
  logInvalidToolCalls("stream", rawToolCalls);
1494
1511
  return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1495
1512
  }
1496
1513
  }
1497
- if (tc.id !== undefined && typeof tc.id !== "string") {
1514
+ if (isInvalidStreamStringField(tc.id)) {
1498
1515
  logInvalidToolCalls("stream", rawToolCalls);
1499
1516
  return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
1500
1517
  }
@@ -0,0 +1,67 @@
1
+ // Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on
2
+ // collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an
3
+ // annotation for the ChatGPT backend only, so translated provider schemas must
4
+ // drop it without removing properties or definitions literally named `encrypted`.
5
+ const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set([
6
+ "properties",
7
+ "patternProperties",
8
+ "$defs",
9
+ "definitions",
10
+ "dependencies",
11
+ "dependentSchemas",
12
+ "dependentRequired",
13
+ ]);
14
+ const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]);
15
+
16
+ /**
17
+ * The schema is caller-supplied, so its nesting depth is attacker-influenced. Native recursion
18
+ * would turn a deep schema into a stack overflow that takes down the request path, so this walks
19
+ * an explicit stack instead: depth costs heap, which is bounded and recoverable.
20
+ */
21
+ export function stripResponsesOnlyEncryptedMarker(node: unknown, inNameBag = false): unknown {
22
+ type Assign = (value: unknown) => void;
23
+ interface Frame { node: unknown; inNameBag: boolean; assign: Assign }
24
+
25
+ let result: unknown;
26
+ const stack: Frame[] = [{ node, inNameBag, assign: value => { result = value; } }];
27
+
28
+ while (stack.length > 0) {
29
+ const frame = stack.pop()!;
30
+ const current = frame.node;
31
+
32
+ if (Array.isArray(current)) {
33
+ const out: unknown[] = new Array(current.length);
34
+ frame.assign(out);
35
+ // Array items are schemas in their own right, never a name bag.
36
+ for (let i = current.length - 1; i >= 0; i--) {
37
+ stack.push({ node: current[i], inNameBag: false, assign: value => { out[i] = value; } });
38
+ }
39
+ continue;
40
+ }
41
+ if (!current || typeof current !== "object") {
42
+ frame.assign(current);
43
+ continue;
44
+ }
45
+
46
+ // A schema name may be `__proto__`; a null-prototype record keeps it as data.
47
+ const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
48
+ frame.assign(out);
49
+
50
+ for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
51
+ if (frame.inNameBag) {
52
+ // Inside a name bag every key is a caller-chosen name, so `encrypted` here is data.
53
+ stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } });
54
+ } else if (key !== "encrypted") {
55
+ if (ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)) {
56
+ // Literal payloads are values, not schemas: an `encrypted` key inside them is data.
57
+ out[key] = value;
58
+ } else {
59
+ const childInNameBag = ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key);
60
+ stack.push({ node: value, inNameBag: childInNameBag, assign: v => { out[key] = v; } });
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return result;
67
+ }
package/src/bridge.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  AdapterEvent,
3
3
  OcxMessagePhase,
4
4
  OcxProviderContinuationState,
5
+ OcxProviderOpaqueToolCallMetadata,
5
6
  OcxReasoningReplayScopeRef,
6
7
  OcxUsage,
7
8
  } from "./types";
@@ -10,6 +11,7 @@ import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCy
10
11
  import { encodeCompactionSummary } from "./responses/compaction";
11
12
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
12
13
  import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
14
+ import { responsesExtraContentFromProviderMetadata } from "./responses/provider-opaque-metadata";
13
15
  import { resolveStallTimeoutSec } from "./stall-timeout";
14
16
  import { usageDisplayTotalTokens } from "./usage/totals";
15
17
  import {
@@ -496,7 +498,7 @@ export function bridgeToResponsesSSE(
496
498
  // synthetic compaction item's payload on done.
497
499
  let compactionText = "";
498
500
  let compactionTextBytes = 0;
499
- let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null;
501
+ let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
500
502
  // Open native web-search cell (between begin and end). Holds the output index allocated on
501
503
  // begin so the matching done reuses it; closed as `failed` if the stream terminates early.
502
504
  let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null;
@@ -621,6 +623,9 @@ export function bridgeToResponsesSSE(
621
623
  call_id: currentToolCall.callId, name: currentToolCall.name,
622
624
  arguments: argsStr, status: "completed",
623
625
  ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
626
+ // Provider-opaque metadata (issue #1735) rides the item so a client that replays
627
+ // this history can hand the signature back on the part it belongs to.
628
+ ...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}),
624
629
  };
625
630
  emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
626
631
  retainFinishedItem(item as OutputItem);
@@ -655,6 +660,10 @@ export function bridgeToResponsesSSE(
655
660
  call_id: currentToolCall.callId, name: currentToolCall.name,
656
661
  arguments: argsStr, status: "incomplete",
657
662
  ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
663
+ // An incomplete call can still be persisted and replayed (max_output_tokens), so it
664
+ // carries the same metadata as the completed item — otherwise SSE and buffered JSON
665
+ // would disagree about whether the signature survives.
666
+ ...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}),
658
667
  };
659
668
  emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
660
669
  retainFinishedItem(item as OutputItem);
@@ -1013,7 +1022,7 @@ export function bridgeToResponsesSSE(
1013
1022
  ? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, input: "", status: "in_progress" }
1014
1023
  : { type: "function_call", id: itemId, call_id: event.id, name: realName, arguments: "", status: "in_progress", ...(ns ? { namespace: ns } : {}) };
1015
1024
  emit("response.output_item.added", { output_index: outputIndex, item });
1016
- currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch };
1025
+ currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch, providerMetadata: event.providerMetadata };
1017
1026
  budget?.openCall(event.id);
1018
1027
  break;
1019
1028
  }
@@ -1476,6 +1485,7 @@ function buildResponseJSONWithBudget(
1476
1485
  let currentToolCallId = "";
1477
1486
  let currentToolCallName = "";
1478
1487
  let currentToolCallArgs = "";
1488
+ let currentToolCallProviderMetadata: OcxProviderOpaqueToolCallMetadata | undefined;
1479
1489
  let currentToolCallArgsBytes = 0;
1480
1490
  // Web-search citations awaiting the next assistant message (attached as url_citation annotations).
1481
1491
  let pendingWebSources: { url: string; title?: string }[] = [];
@@ -1584,11 +1594,13 @@ function buildResponseJSONWithBudget(
1584
1594
  call_id: currentToolCallId, name: realName,
1585
1595
  arguments: coercedArgs || "{}", status,
1586
1596
  ...(ns ? { namespace: ns } : {}),
1597
+ ...(responsesExtraContentFromProviderMetadata(currentToolCallProviderMetadata) ?? {}),
1587
1598
  });
1588
1599
  }
1589
1600
  budget?.closeCall(currentToolCallId);
1590
1601
  currentToolCallId = "";
1591
1602
  currentToolCallName = "";
1603
+ currentToolCallProviderMetadata = undefined;
1592
1604
  currentToolCallArgs = "";
1593
1605
  currentToolCallArgsBytes = 0;
1594
1606
  };
@@ -1703,6 +1715,7 @@ function buildResponseJSONWithBudget(
1703
1715
  currentToolCallName = e.name;
1704
1716
  currentToolCallArgs = "";
1705
1717
  currentToolCallArgsBytes = 0;
1718
+ currentToolCallProviderMetadata = e.providerMetadata;
1706
1719
  break;
1707
1720
  case "tool_call_delta":
1708
1721
  {
@@ -20,7 +20,7 @@ import { claudeConfigDir } from "./gateway-cache";
20
20
  import { DEFAULT_SUBAGENT_MODELS, hasOwnProvider } from "../config";
21
21
  import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound";
22
22
  import { knownModelIdsForProvider } from "../router";
23
- import { decodeRoutedModelId } from "../providers/slug-codec";
23
+ import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec";
24
24
 
25
25
  export interface ClaudeAgentDef {
26
26
  file: string;
@@ -85,7 +85,7 @@ function entryParts(entry: string, config: OcxConfig): { alias: string; id: stri
85
85
  const provider = entry.slice(0, slash);
86
86
  const prov = hasOwnProvider(config.providers, provider) ? config.providers[provider] : undefined;
87
87
  const id = prov
88
- ? decodeRoutedModelId(entry.slice(slash + 1), knownModelIdsForProvider(provider, prov))
88
+ ? decodeRoutedModelIdOrThrow(entry.slice(slash + 1), knownModelIdsForProvider(provider, prov, config))
89
89
  : entry.slice(slash + 1);
90
90
  return { alias: claudeCodeAlias(provider, id), id, provider };
91
91
  }
@@ -13,12 +13,21 @@
13
13
  import { mkdirSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
+ import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
17
+ import type { OcxConfig } from "../types";
16
18
 
17
19
  export interface GatewayModelRow {
18
20
  id: string;
19
21
  display_name?: string;
20
22
  }
21
23
 
24
+ export interface GatewayModelCacheRefreshOptions {
25
+ timeoutMs?: number;
26
+ configDir?: string;
27
+ admissionConfig?: Pick<OcxConfig, "apiKeys">;
28
+ env?: NodeJS.ProcessEnv;
29
+ }
30
+
22
31
  /** Claude Code config dir (CLAUDE_CONFIG_DIR override honored, like the CLI). */
23
32
  export function claudeConfigDir(): string {
24
33
  const custom = process.env.CLAUDE_CONFIG_DIR;
@@ -45,14 +54,42 @@ export function writeGatewayModelCache(baseUrl: string, models: readonly Gateway
45
54
  }
46
55
  }
47
56
 
57
+ /**
58
+ * Hardened service-token file, the same precedence `ocx opencode` uses. A service
59
+ * install writes the admission token to disk rather than the interactive environment,
60
+ * so an interactive `ocx claude` with neither env token nor configured key would
61
+ * otherwise still get a 401 and keep a stale picker list.
62
+ */
63
+ function serviceFileToken(env: NodeJS.ProcessEnv): string | null {
64
+ const lookup = env.OCX_API_TOKEN_FILE?.trim()
65
+ ? env
66
+ : { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() };
67
+ return loadServiceTokenFromFile(lookup as Record<string, string | undefined>);
68
+ }
69
+
48
70
  /** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
49
- export async function refreshGatewayModelCacheFromProxy(port: number, timeoutMs = 3_000, configDir?: string): Promise<string | null> {
71
+ export async function refreshGatewayModelCacheFromProxy(
72
+ port: number,
73
+ options: GatewayModelCacheRefreshOptions = {},
74
+ ): Promise<string | null> {
50
75
  try {
76
+ const headers = new Headers({ "anthropic-version": "2023-06-01" });
77
+ // A wildcard/non-loopback listener requires data-plane admission even for a
78
+ // request sent to its local 127.0.0.1 address. Reuse the same dedicated
79
+ // credential domain as /v1/models admission; never place it in Authorization,
80
+ // which can belong to an upstream provider on other data-plane surfaces.
81
+ const envToken = (options.env ?? process.env).OPENCODEX_API_AUTH_TOKEN?.trim();
82
+ const configuredToken = options.admissionConfig?.apiKeys
83
+ ?.find(entry => entry.key.trim().length > 0)
84
+ ?.key.trim();
85
+ const admissionToken = envToken || serviceFileToken(options.env ?? process.env) || configuredToken;
86
+ if (admissionToken) headers.set("x-opencodex-api-key", admissionToken);
87
+
51
88
  // ?ids=cli pins the readable claude-ocx id family deterministically (audit 051
52
89
  // #5): the cache prewrite must not depend on UA sniffing.
53
90
  const res = await fetch(`http://127.0.0.1:${port}/v1/models?limit=1000&ids=cli`, {
54
- headers: { "anthropic-version": "2023-06-01" },
55
- signal: AbortSignal.timeout(timeoutMs),
91
+ headers,
92
+ signal: AbortSignal.timeout(options.timeoutMs ?? 3_000),
56
93
  });
57
94
  if (!res.ok) return null;
58
95
  const body = await res.json() as { data?: unknown };
@@ -63,7 +100,7 @@ export async function refreshGatewayModelCacheFromProxy(port: number, timeoutMs
63
100
  id: m.id as string,
64
101
  display_name: typeof m.display_name === "string" ? m.display_name : undefined,
65
102
  }));
66
- return writeGatewayModelCache(`http://127.0.0.1:${port}`, models, configDir);
103
+ return writeGatewayModelCache(`http://127.0.0.1:${port}`, models, options.configDir);
67
104
  } catch {
68
105
  return null;
69
106
  }
package/src/cli/claude.ts CHANGED
@@ -317,7 +317,7 @@ export async function cmdClaude(args: string[]): Promise<number> {
317
317
  // Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI
318
318
  // never refreshes it, so the picker would keep showing yesterday's aliases.
319
319
  try {
320
- const cachePath = await refreshGatewayModelCacheFromProxy(port);
320
+ const cachePath = await refreshGatewayModelCacheFromProxy(port, { admissionConfig: config });
321
321
  if (cachePath === null) {
322
322
  console.error("⚠ Gateway model cache could not be refreshed; the model picker may be stale.");
323
323
  }
@@ -0,0 +1,103 @@
1
+ import { inspectCodexLogs, type CodexLogGuardInspection } from "../codex/log-guard/inspect";
2
+ import {
3
+ getCodexLogGuardProtectionStatus,
4
+ type CodexLogGuardStatus,
5
+ } from "../codex/log-guard/protection";
6
+
7
+ function kib(bytes: number): string {
8
+ return `${(bytes / 1024).toFixed(1)} KiB`;
9
+ }
10
+
11
+ function fileMetadataLines(report: CodexLogGuardInspection): string[] {
12
+ if (report.externalSqliteHome === null) return [];
13
+ const location = report.externalSqliteHome ? "external sqlite_home" : "CODEX_HOME sqlite_home";
14
+ return [
15
+ ` ${location}; DB ${kib(report.files.databaseBytes)}, WAL ${kib(report.files.walBytes)}, SHM ${kib(report.files.shmBytes)}`,
16
+ ];
17
+ }
18
+
19
+ type DoctorReport = CodexLogGuardInspection | CodexLogGuardStatus;
20
+
21
+ function protectionLines(report: DoctorReport): string[] {
22
+ if (!("protection" in report)) return [];
23
+ const protection = report.protection;
24
+ switch (protection.state) {
25
+ case "active":
26
+ return [` ok protection active (${protection.desiredMode})`];
27
+ case "off":
28
+ return [" -- protection off"];
29
+ case "drifted":
30
+ return [
31
+ ` WARN protection drifted (desired ${protection.desiredMode}; observed ${protection.observedMode})`,
32
+ " Action: ocx storage codex-logs repair",
33
+ ];
34
+ case "unsupported":
35
+ return [" -- protection unavailable for this schema"];
36
+ case "unknown":
37
+ return [" WARN protection state unknown; inspect reserved Log Guard triggers before changing mode"];
38
+ }
39
+ }
40
+
41
+ export function formatCodexLogGuardDoctor(report: DoctorReport): string[] {
42
+ const lines = ["Codex diagnostic logs"];
43
+ lines.push(...protectionLines(report));
44
+
45
+ if (report.schema.state === "unavailable") {
46
+ lines.push(" -- inspection unavailable");
47
+ return lines;
48
+ }
49
+ if (report.schema.state === "missing") {
50
+ lines.push(" -- logs_2.sqlite is not present");
51
+ return lines;
52
+ }
53
+ if (report.schema.state === "unreadable") {
54
+ lines.push(" -- logs_2.sqlite is unreadable; inspection metadata only");
55
+ lines.push(...fileMetadataLines(report));
56
+ lines.push(" checkpointed read-only snapshot; activity rate not measured");
57
+ return lines;
58
+ }
59
+ if (report.schema.state === "unsupported") {
60
+ lines.push(" -- unknown schema; inspection only");
61
+ } else {
62
+ lines.push(" ok schema compatible");
63
+ }
64
+
65
+ lines.push(...fileMetadataLines(report));
66
+
67
+ if (report.metrics) {
68
+ lines.push(
69
+ ` ${report.metrics.totalRows} rows; TRACE ${(report.metrics.traceShare * 100).toFixed(1)}%; reclaimable ${kib(report.metrics.reclaimableBytes)}`,
70
+ );
71
+ const top = report.metrics.topTargets[0];
72
+ if (top) lines.push(` top target ${top.target} (${top.rows} rows)`);
73
+ }
74
+
75
+ lines.push(" checkpointed read-only snapshot; activity rate not measured");
76
+ return lines;
77
+ }
78
+
79
+ export interface CodexLogGuardDoctorDeps {
80
+ inspect?: () => DoctorReport;
81
+ log?: (line: string) => void;
82
+ }
83
+
84
+ /** Observe-only doctor section. Inspection failures are reported without mutating or failing doctor. */
85
+ export function printCodexLogGuardDoctor(deps: CodexLogGuardDoctorDeps = {}): void {
86
+ const inspect = deps.inspect ?? getCodexLogGuardProtectionStatus;
87
+ const log = deps.log ?? console.log;
88
+ try {
89
+ for (const line of formatCodexLogGuardDoctor(inspect())) log(line);
90
+ } catch (error) {
91
+ // Production can still fall back to PR 1's simpler inspector if the enriched
92
+ // protection lookup fails. An injected inspector is a test/caller boundary:
93
+ // never escape that boundary and touch the real Codex home behind its back.
94
+ if (!deps.inspect) {
95
+ try {
96
+ for (const line of formatCodexLogGuardDoctor(inspectCodexLogs())) log(line);
97
+ return;
98
+ } catch { /* report the original failure below */ }
99
+ }
100
+ log("Codex diagnostic logs");
101
+ log(" -- inspection unavailable");
102
+ }
103
+ }
@@ -171,8 +171,14 @@ const commandRunners: Record<string, CommandRunner> = {
171
171
  return Number(process.exitCode ?? 0);
172
172
  },
173
173
  doctor: async deps => {
174
+ const doctorArgs = deps.args.slice(1);
174
175
  const { runDoctor } = await import("./doctor");
175
- await runDoctor(deps.args.slice(1));
176
+ await runDoctor(doctorArgs);
177
+ if (!doctorArgs.includes("--fix-codex-runtime")) {
178
+ console.log("");
179
+ const { printCodexLogGuardDoctor } = await import("./codex-log-guard-doctor");
180
+ printCodexLogGuardDoctor();
181
+ }
176
182
  return 0;
177
183
  },
178
184
  debug: async deps => {
package/src/cli/help.ts CHANGED
@@ -42,7 +42,7 @@ Usage:
42
42
  ocx gui Open the opencodex dashboard
43
43
  ocx update [--tag <tag>] Update opencodex (keeps preview installs on @preview)
44
44
  ocx restart Stop and restart the proxy
45
- ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|threads|mode-hint)
45
+ ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|keep-native-v1|threads|mode-hint)
46
46
  ocx health [--json] Check proxy health (exit 0=healthy, 1=not)
47
47
  ocx ready [--json] [--wait [--timeout <s>]] Check post-sync readiness (exit 0 only when ready)
48
48
  ocx provider <sub> Providers, connectivity, quota, and selected models
package/src/cli/models.ts CHANGED
@@ -6,7 +6,8 @@ import { createInterface } from "node:readline/promises";
6
6
  import { syncModelsToCodex } from "../codex/sync";
7
7
  import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
8
8
  import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort";
9
- import { routedSlug } from "../providers/slug-codec";
9
+ import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec";
10
+ import { knownModelIdsForProvider } from "../router";
10
11
  import { findLiveProxy } from "../server/proxy-liveness";
11
12
  import type { OcxConfig, OcxCustomModel } from "../types";
12
13
 
@@ -180,7 +181,6 @@ async function handleCustomAdd(args: string[]): Promise<void> {
180
181
 
181
182
  if (!provider || !modelId) fail("provider and modelId are required", ADD_USAGE);
182
183
  if (!isValidProviderName(provider)) fail(`invalid provider name "${provider}"`);
183
- if (modelId.includes("/")) fail("modelId must not contain /");
184
184
 
185
185
  const config = loadConfig();
186
186
  if (!hasOwnProvider(config.providers, provider)) {
@@ -216,6 +216,10 @@ async function handleCustomAdd(args: string[]): Promise<void> {
216
216
  if (existing.some(model => routedSlug(model.provider, model.modelId) === slug)) {
217
217
  fail(`custom model "${slug}" already exists`);
218
218
  }
219
+ const known = knownModelIdsForProvider(provider, config.providers[provider], config);
220
+ if (encodedModelIdCollides(modelId, known)) {
221
+ fail(`custom model "${slug}" is ambiguous; it encodes to an existing model id`);
222
+ }
219
223
 
220
224
  const entry: OcxCustomModel = {
221
225
  id: randomUUID(),
@@ -256,10 +260,16 @@ async function handleCustomRemove(args: string[]): Promise<void> {
256
260
 
257
261
  const config = loadConfig();
258
262
  const existing = config.customModels ?? [];
259
- const index = target.includes("/")
260
- ? existing.findIndex(model => routedSlug(model.provider, model.modelId) === target)
261
- : existing.findIndex(model => model.id === target);
262
- if (index === -1) fail(`custom model "${target}" not found`);
263
+ const matchingIndexes = existing.flatMap((model, index) => (
264
+ target.includes("/")
265
+ ? slugEquals(target, model.provider, model.modelId)
266
+ : model.id === target
267
+ ) ? [index] : []);
268
+ if (matchingIndexes.length === 0) fail(`custom model "${target}" not found`);
269
+ if (matchingIndexes.length > 1) {
270
+ fail(`custom model selector "${target}" is ambiguous; use the custom model id`);
271
+ }
272
+ const index = matchingIndexes[0]!;
263
273
 
264
274
  const model = existing[index];
265
275
  if (!confirmed && !(await confirmCustomRemoval(model))) {
@@ -18,7 +18,7 @@ const USAGE = `Usage:
18
18
  ocx logs rebuild-index
19
19
  ocx logs index-status
20
20
  ocx observe usage [--range <7d|30d|all>] [--surface <all|codex|claude|grok>] [--json]
21
- ocx observe storage [--json]
21
+ ocx observe storage [codex-logs [status|protect|unprotect|repair|compact] [--mode <compat|quiet>]] [--json]
22
22
  ocx observe memory [--json]
23
23
  ocx observe debug [--json]
24
24
  ocx observe claude-inbound [--limit <n>] [--json]
@@ -147,6 +147,42 @@ async function simple(path: string, argv: string[], deps: RuntimeApiDeps): Promi
147
147
  printData(result, wantsJson, summaryLines(result));
148
148
  }
149
149
 
150
+ async function storage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
151
+ if (argv[0] !== "codex-logs") {
152
+ await simple("/api/storage", argv, deps);
153
+ return;
154
+ }
155
+
156
+ const args = argv.slice(1);
157
+ const action = args[0] && !args[0].startsWith("-") ? args.shift()! : "status";
158
+ const wantsJson = takeFlag(args, "--json");
159
+ const mode = takeOption(args, "--mode");
160
+ rejectArgs(args, USAGE);
161
+
162
+ let result: unknown;
163
+ if (action === "status") {
164
+ if (mode !== undefined) throw new CliUsageError("--mode is only valid with codex-logs protect", USAGE);
165
+ result = await runtimeRequest("/api/storage/codex-logs", {}, deps);
166
+ } else if (action === "protect") {
167
+ const requestedMode = mode ?? "compat";
168
+ if (requestedMode !== "compat" && requestedMode !== "quiet") {
169
+ throw new CliUsageError("--mode must be compat or quiet", USAGE);
170
+ }
171
+ result = await runtimeRequest("/api/storage/codex-logs/protect", {
172
+ method: "POST",
173
+ headers: { "content-type": "application/json" },
174
+ body: JSON.stringify({ mode: requestedMode }),
175
+ }, deps);
176
+ } else if (action === "unprotect" || action === "repair" || action === "compact") {
177
+ if (mode !== undefined) throw new CliUsageError("--mode is only valid with codex-logs protect", USAGE);
178
+ result = await runtimeRequest(`/api/storage/codex-logs/${action}`, { method: "POST" }, deps);
179
+ } else {
180
+ throw new CliUsageError(`unknown codex-logs action ${action}`, USAGE);
181
+ }
182
+
183
+ printData(result, wantsJson, summaryLines(result));
184
+ }
185
+
150
186
  export async function handleObserveCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
151
187
  return runCliAction(async () => {
152
188
  const [sub = "logs", ...rest] = argv;
@@ -158,7 +194,7 @@ export async function handleObserveCommand(argv: string[], deps: RuntimeApiDeps
158
194
  else await logs(rest, deps);
159
195
  }
160
196
  else if (sub === "usage") await usage(rest, deps);
161
- else if (sub === "storage") await simple("/api/storage", rest, deps);
197
+ else if (sub === "storage") await storage(rest, deps);
162
198
  else if (sub === "memory") await simple("/api/system/memory", rest, deps);
163
199
  else if (sub === "debug") await simple("/api/debug", rest, deps);
164
200
  else if (sub === "claude-inbound") await simple("/api/claude/inbound-debug", rest, deps);
@@ -309,12 +309,13 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
309
309
  },
310
310
  {
311
311
  name: "v2",
312
- usage: "ocx v2 <status|on|off|mode <v1|default|v2>|threads <n>>",
312
+ usage: "ocx v2 <status|on|off|mode <v1|default|v2>|keep-native-v1 <on|off>|threads <n>>",
313
313
  summary: "Toggle the Codex multi_agent_v2 feature (multi-agent surface).",
314
314
  details: [
315
315
  "status Show flag, multi-agent mode, and thread limit.",
316
316
  "on | off Enable/disable multi_agent_v2 (catalog resyncs).",
317
317
  "mode <v1|default|v2> Force all models to one surface, or respect upstream pins.",
318
+ "keep-native-v1 <on|off> Under mode v2, keep ChatGPT-native models on v1.",
318
319
  "threads <n> Set max_concurrent_threads_per_session (integer >= 1).",
319
320
  "Flips preserve the active thread limit while moving between v1/v2 modes.",
320
321
  ],
package/src/cli/v2.ts CHANGED
@@ -110,6 +110,9 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
110
110
  log.log(v2StatusLine(isEnabled()));
111
111
  const cfg = loadConfig();
112
112
  log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default"));
113
+ log.log(cfg.keepNativeChatGptOnV1 === true
114
+ ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2"
115
+ : "keep_native_chatgpt_on_v1: OFF");
113
116
  const threads = getLogicalMaxThreads();
114
117
  log.log(`max_threads: ${threads ?? "(unset — codex default)"}`);
115
118
  const v2Active = isEnabled();
@@ -204,8 +207,38 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
204
207
  log.log("Applies to NEW sessions; running sessions keep their pinned multi-agent version.");
205
208
  return 0;
206
209
  }
210
+ if (verb === "keep-native-v1") {
211
+ const flag = (args[1] ?? "").trim().toLowerCase();
212
+ if (flag !== "on" && flag !== "off") {
213
+ log.error("v2 keep-native-v1: expected on|off");
214
+ return 1;
215
+ }
216
+ const cfg = loadConfig();
217
+ const next = flag === "on";
218
+ const already = cfg.keepNativeChatGptOnV1 === true === next;
219
+ if (next) cfg.keepNativeChatGptOnV1 = true;
220
+ else delete cfg.keepNativeChatGptOnV1;
221
+ saveConfig(cfg);
222
+ try {
223
+ const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
224
+ await sync(findPort ? await findPort() : undefined);
225
+ } catch (err) {
226
+ log.error(`catalog resync failed: ${err instanceof Error ? err.message : String(err)} — run 'ocx sync' manually.`);
227
+ return 1;
228
+ }
229
+ if (already) {
230
+ log.log(next
231
+ ? "keep_native_chatgpt_on_v1 already ON — catalog re-synced."
232
+ : "keep_native_chatgpt_on_v1 already OFF — catalog re-synced.");
233
+ return 0;
234
+ }
235
+ log.log(next
236
+ ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2 (new sessions)."
237
+ : "keep_native_chatgpt_on_v1: OFF — ChatGPT-native rows follow v1/base/v2 (new sessions).");
238
+ return 0;
239
+ }
207
240
  if (verb !== "on" && verb !== "off") {
208
- log.error(`v2: unknown verb '${verb}' (expected status|on|off|mode <v1|default|v2>|threads <n>|mode-hint <text|--clear>)`);
241
+ log.error(`v2: unknown verb '${verb}' (expected status|on|off|mode <v1|default|v2>|keep-native-v1 <on|off>|threads <n>|mode-hint <text|--clear>)`);
209
242
  return 1;
210
243
  }
211
244