@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -191,12 +191,18 @@ export function stripRejectedSamplingParams(
191
191
  }
192
192
 
193
193
  /**
194
- * When `store` is false, the upstream API does not persist response items. Any item ID
194
+ * When `store: false`, the upstream cannot retrieve stored turns by ID. An ID
195
195
  * forwarded in `input` is then interpreted as a reference to a stored item that does not
196
- * exist, producing a 404. Strip all item IDs in this case — `call_id` pairing is unaffected.
196
+ * exist, producing a 404. Strip all item IDs in this case unless `preserveIds` is true
197
+ * — `call_id` pairing is unaffected. Also repairs missing or broken xAI custom_tool_call IDs
198
+ * when `requireCustomCallIds` is true, regardless of `preserveIds`.
197
199
  * Matches codex-rs behavior (core/src/client.rs:918-925).
198
200
  */
199
- export function stripItemIdsWhenUnstored(body: unknown, requireCustomCallIds = false): unknown {
201
+ export function stripItemIdsWhenUnstored(
202
+ body: unknown,
203
+ requireCustomCallIds = false,
204
+ preserveIds = false,
205
+ ): unknown {
200
206
  const repairCustomCallIds = requireCustomCallIds === true;
201
207
  if (!isPlainObject(body) || (body.store !== false && !repairCustomCallIds)) return body;
202
208
  if (!Array.isArray(body.input)) return body;
@@ -226,7 +232,7 @@ export function stripItemIdsWhenUnstored(body: unknown, requireCustomCallIds = f
226
232
  return item;
227
233
  }
228
234
  }
229
- if (body.store !== false || !("id" in item)) return item;
235
+ if (preserveIds || body.store !== false || !("id" in item)) return item;
230
236
  changed = true;
231
237
  const next = { ...item };
232
238
  delete next.id;
@@ -5,7 +5,8 @@ import { createGoogleAdapter } from "./google";
5
5
  import { createOpenAIChatAdapter } from "./openai-chat";
6
6
  import { createResponsesPassthroughAdapter } from "./openai-responses";
7
7
  import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types";
8
- import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget";
8
+ import { createTranslatorBudget, releaseTranslatedEvent, retainTranslatedEvent, type TranslatorBudget } from "../lib/translator-budget";
9
+ import { releaseCompactionCiphertextLease } from "../responses/compaction";
9
10
  import { redactSecretString } from "../lib/redact";
10
11
  import {
11
12
  normalizeZedProvider,
@@ -365,12 +366,33 @@ export function createZedAdapter(provider: OcxProviderConfig): ProviderAdapter {
365
366
  status: response.status,
366
367
  headers: { "Content-Type": "text/event-stream" },
367
368
  });
368
- yield* delegate.adapter.parseStream(translated, budget);
369
+ try {
370
+ yield* delegate.adapter.parseStream(translated, budget);
371
+ } finally {
372
+ // Some delegates only unlock their reader when a collector stops early.
373
+ try { await translated.body?.cancel(); } catch { /* already closed/errored */ }
374
+ }
369
375
  },
370
376
  async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
371
377
  const events: AdapterEvent[] = [];
372
- for await (const event of adapter.parseStream(response, budget)) events.push(event);
373
- return events;
378
+ try {
379
+ for await (const event of adapter.parseStream(response, budget)) {
380
+ try {
381
+ retainTranslatedEvent(event, budget, events.at(-1));
382
+ } catch (error) {
383
+ releaseCompactionCiphertextLease(event, budget);
384
+ throw error;
385
+ }
386
+ events.push(event);
387
+ }
388
+ return events;
389
+ } catch (error) {
390
+ for (const event of events) {
391
+ releaseTranslatedEvent(event, budget);
392
+ releaseCompactionCiphertextLease(event, budget);
393
+ }
394
+ throw error;
395
+ }
374
396
  },
375
397
  };
376
398
  return adapter;
@@ -17,7 +17,7 @@ import {
17
17
  type OcxErrorPayload,
18
18
  } from "../lib/errors";
19
19
  import { mayBecomePatchEnvelope, repairFreeformToolInput } from "../responses/apply-patch-envelope";
20
- import { encodeCompactionSummary } from "../responses/compaction";
20
+ import { encodeCompactionSummary, releaseCompactionCiphertextLease } from "../responses/compaction";
21
21
  import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
22
22
  import { isTruncatedStopReason, truncationReasonFor } from "../responses/truncated-stop-reason";
23
23
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "../responses/reasoning-envelope";
@@ -59,21 +59,21 @@ export function buildResponseJSON(
59
59
  ): Record<string, unknown> {
60
60
  // Default-budget safety net: a caller that omits the budget gets a bounded
61
61
  // default (disposed with the call), never the unbounded append path.
62
- if (options?.translatorBudget) {
63
- const body = buildResponseJSONWithBudget(events, modelId, options);
64
- // A buffered turn delivers its whole answer as one body, so nothing calls the per-frame
65
- // recorder on the SSE bridge. Without this the attempt would persist adapter events with
66
- // zero relayed ones, which is the loss signal -- raised on every non-streaming request.
67
- if (options.recordBufferedDelivery !== false) {
68
- attemptDeliveryRecorder(options.translatorBudget)?.noteBufferedDelivery(body);
62
+ const ownsBudget = !options?.translatorBudget;
63
+ const budget = options?.translatorBudget ?? createTranslatorBudget();
64
+ try {
65
+ const body = buildResponseJSONWithBudget(events, modelId, { ...options, translatorBudget: budget });
66
+ // Buffered delivery has no per-frame recorder; retain its existing one-shot attribution.
67
+ if (options?.translatorBudget && options.recordBufferedDelivery !== false) {
68
+ attemptDeliveryRecorder(budget)?.noteBufferedDelivery(body);
69
69
  }
70
70
  return body;
71
- }
72
- const budget = createTranslatorBudget();
73
- try {
74
- return buildResponseJSONWithBudget(events, modelId, { ...options, translatorBudget: budget });
75
71
  } finally {
76
- budget.dispose();
72
+ for (const event of events) {
73
+ releaseTranslatedEvent(event, budget);
74
+ releaseCompactionCiphertextLease(event, budget);
75
+ }
76
+ if (ownsBudget) budget.dispose();
77
77
  }
78
78
  }
79
79
 
@@ -618,7 +618,7 @@ function buildResponseJSONWithBudget(
618
618
  type: "compaction", id: `cmp_${uuid()}`,
619
619
  encrypted_content: compactionEncryptedContent ?? encodeCompactionSummary(joinChunks(batchCompaction)),
620
620
  };
621
- pushOutput(item, compactionEncryptedContent ? bytesOf(compactionEncryptedContent) : batchCompaction.bytes);
621
+ pushOutput(item, compactionEncryptedContent ? 0 : batchCompaction.bytes);
622
622
  }
623
623
 
624
624
  const failure = errorEvent ? adapterFailureFromEvent(errorEvent) : undefined;
package/src/bridge/sse.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  repairFreeformToolInput,
23
23
  } from "../responses/apply-patch-envelope";
24
24
  import { progressiveFreeformInput } from "../responses/progressive-freeform-input";
25
- import { encodeCompactionSummary } from "../responses/compaction";
25
+ import { encodeCompactionSummary, releaseCompactionCiphertextLease } from "../responses/compaction";
26
26
  import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
27
27
  import { mayBecomeCodeModeShellInput } from "../responses/code-mode-shell-input";
28
28
  import { isTruncatedStopReason, truncationReasonFor } from "../responses/truncated-stop-reason";
@@ -714,6 +714,10 @@ export function bridgeToResponsesSSE(
714
714
  firstOutputReported = true;
715
715
  try { options?.onFirstOutput?.(); } catch { /* metrics must not break the stream */ }
716
716
  };
717
+ const releaseEvent = (event: AdapterEvent) => {
718
+ releaseTranslatedEvent(event, budget);
719
+ releaseCompactionCiphertextLease(event, budget);
720
+ };
717
721
  const it = events[Symbol.asyncIterator]();
718
722
  let iteratorStarted = false;
719
723
  let iteratorReturned = false;
@@ -734,8 +738,11 @@ export function bridgeToResponsesSSE(
734
738
  if (!iteratorStarted) {
735
739
  iteratorStarted = true;
736
740
  try {
737
- void it.next().then(finishReturn, () => {}).catch(() => {});
741
+ void it.next().then(next => {
742
+ try { if (!next.done) releaseEvent(next.value); } finally { finishReturn(); }
743
+ }, finishReturn).catch(() => {});
738
744
  } catch {
745
+ finishReturn();
739
746
  /* synchronous iterator start failure is also best-effort */
740
747
  }
741
748
  return;
@@ -809,6 +816,7 @@ export function bridgeToResponsesSSE(
809
816
  while (!terminated && !closed && emittedFrames === emittedAtStart) {
810
817
  iteratorStarted = true;
811
818
  const next = await it.next();
819
+ try {
812
820
  // A cancel during this await disposes the owned budget; a late event
813
821
  // must never be processed or charged against it. Exit step() outright:
814
822
  // falling into EOF synthesis would let closeCurrentMessage() charge
@@ -1228,9 +1236,8 @@ export function bridgeToResponsesSSE(
1228
1236
  encrypted_content: event.compactionEncryptedContent ?? encodeCompactionSummary(joinChunks(compaction)),
1229
1237
  };
1230
1238
  emit("response.output_item.done", { output_index: outputIndex, item });
1231
- retainFinishedItem(item as OutputItem, event.compactionEncryptedContent
1232
- ? bytesOf(event.compactionEncryptedContent)
1233
- : compaction.bytes);
1239
+ retainFinishedItem(item as OutputItem, event.compactionEncryptedContent ? 0 : compaction.bytes);
1240
+ releaseCompactionCiphertextLease(event, budget);
1234
1241
  outputIndex++;
1235
1242
  }
1236
1243
  // Recognize every adapter's truncation vocabulary, not just the canonical pair.
@@ -1333,6 +1340,9 @@ export function bridgeToResponsesSSE(
1333
1340
  terminated = true;
1334
1341
  break;
1335
1342
  }
1343
+ } finally {
1344
+ if (!next.done) releaseEvent(next.value);
1345
+ }
1336
1346
  }
1337
1347
  } catch (err) {
1338
1348
  if (isTranslatorBudgetExceededError(err)) {
@@ -1,10 +1,10 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { lstatSync } from "node:fs";
3
- import { dirname, relative, sep } from "node:path";
3
+ import { dirname, join, relative, sep } from "node:path";
4
4
 
5
5
  /**
6
- * The launcher persistently execs a binary taken from a discovered bundle, so the bundle must be
7
- * one this user can trust: OpenAI-signed, and not replaceable by another local account. Bundle
6
+ * Launch and restore execute code from a discovered bundle, so the bundle must be
7
+ * OpenAI-signed and pass ownership/permission checks along its filesystem path. Bundle
8
8
  * identifier alone (what discovery checks) is plain text in Info.plist that anyone can copy.
9
9
  */
10
10
  export const OPENAI_TEAM_ID = "2DC432GLL2";
@@ -14,17 +14,23 @@ const CODESIGN_TIMEOUT_MS = 30_000;
14
14
 
15
15
  export interface BundleTrustDeps {
16
16
  readonly uid: number;
17
- stat(path: string): { uid: number; mode: number; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean } | null;
17
+ readonly adminGroupId?: number;
18
+ stat(path: string): { uid: number; gid?: number; mode: number; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean } | null;
18
19
  codesign(args: readonly string[]): { status: number | null; output: string };
19
20
  }
20
21
 
21
22
  export function defaultBundleTrustDeps(): BundleTrustDeps {
23
+ const admin = spawnSync("/usr/bin/dscl", [".", "-read", "/Groups/admin", "PrimaryGroupID"], {
24
+ encoding: "utf8", timeout: 5000,
25
+ });
26
+ const adminId = admin.status === 0 ? /^PrimaryGroupID: ([0-9]+)\s*$/.exec(admin.stdout)?.[1] : undefined;
22
27
  return {
28
+ adminGroupId: adminId === undefined ? undefined : Number(adminId),
23
29
  uid: typeof process.getuid === "function" ? process.getuid() : -1,
24
30
  stat(path) {
25
31
  try {
26
32
  const s = lstatSync(path);
27
- return { uid: s.uid, mode: s.mode, isFile: s.isFile(), isDirectory: s.isDirectory(), isSymbolicLink: s.isSymbolicLink() };
33
+ return { uid: s.uid, gid: s.gid, mode: s.mode, isFile: s.isFile(), isDirectory: s.isDirectory(), isSymbolicLink: s.isSymbolicLink() };
28
34
  } catch {
29
35
  return null;
30
36
  }
@@ -46,26 +52,50 @@ function ownedSafely(deps: BundleTrustDeps, path: string, kind: "file" | "direct
46
52
  return null;
47
53
  }
48
54
 
55
+ /** Protect the bundle's directory entry all the way to the filesystem root. */
56
+ function untrustedAncestorReason(bundleRoot: string, deps: BundleTrustDeps): string | null {
57
+ for (let dir = dirname(bundleRoot); ; dir = dirname(dir)) {
58
+ const s = deps.stat(dir);
59
+ if (!s || !s.isDirectory || s.isSymbolicLink) return `${dir} is not a regular ancestor directory`;
60
+ if (s.uid !== 0 && s.uid !== deps.uid) return `${dir} is owned by another user`;
61
+ // A trusted sticky parent protects the already-checked child from replacement.
62
+ // Root-owned admin-group install containers are privileged OS-controlled paths;
63
+ // resolve that group from the local directory service rather than assuming a GID.
64
+ const sticky = (s.mode & 0o1000) !== 0;
65
+ const privileged = s.uid === 0 && (s.mode & 0o002) === 0
66
+ && deps.adminGroupId !== undefined && s.gid === deps.adminGroupId;
67
+ if ((s.mode & 0o022) !== 0 && !sticky && !privileged) return `${dir} is writable by group or others`;
68
+ if (dirname(dir) === dir) return null;
69
+ }
70
+ }
71
+
49
72
  /**
50
- * Null when the bundle root and the app-server binary inside it may be exec'd by this user;
51
- * otherwise a short reason. Every directory from the binary up to the bundle root, and the
73
+ * Null when the bundle root and the selected executable may be run by this user.
74
+ * Restore omits the app-server binary and validates the app shell instead, so a missing
75
+ * app-server does not prevent returning to a normal launch. Otherwise returns a short reason.
76
+ * Every directory from the binary up to the bundle root, and the
52
77
  * binary itself, must be owned by root or this user and not group/other-writable, and both the
53
78
  * bundle and the binary must pass strict code-signature verification under OpenAI's team ID.
79
+ * Ancestors above the bundle are also checked for POSIX directory-entry replacement rights.
54
80
  */
55
81
  export function untrustedChatgptBundleReason(
56
82
  bundleRoot: string,
57
- binary: string,
83
+ binary: string | undefined = undefined,
58
84
  deps: BundleTrustDeps = defaultBundleTrustDeps(),
59
85
  ): string | null {
86
+ const label = binary === undefined ? "app executable" : "app-server binary";
87
+ binary ??= join(bundleRoot, "Contents", "MacOS", "ChatGPT");
60
88
  const inside = relative(bundleRoot, binary);
61
- if (!inside || inside.startsWith("..") || inside.startsWith(sep)) return "the app-server binary is outside the bundle";
89
+ if (!inside || inside.startsWith("..") || inside.startsWith(sep)) return `the ${label} is outside the bundle`;
62
90
  const fileProblem = ownedSafely(deps, binary, "file");
63
- if (fileProblem) return `the app-server binary ${fileProblem}`;
91
+ if (fileProblem) return `the ${label} ${fileProblem}`;
64
92
  for (let dir = dirname(binary); ; dir = dirname(dir)) {
65
93
  const dirProblem = ownedSafely(deps, dir, "directory");
66
94
  if (dirProblem) return `${dir} ${dirProblem}`;
67
95
  if (dir === bundleRoot || dirname(dir) === dir) break;
68
96
  }
97
+ const ancestorProblem = untrustedAncestorReason(bundleRoot, deps);
98
+ if (ancestorProblem) return ancestorProblem;
69
99
  for (const target of [bundleRoot, binary]) {
70
100
  if (deps.codesign(["--verify", "--strict", target]).status !== 0) return `${target} failed code-signature verification`;
71
101
  const details = deps.codesign(["-dv", "--verbose=2", target]);
@@ -38,7 +38,10 @@ export function unlockRateLimitGate(value: unknown): boolean {
38
38
  return { cleared, exhausted, blocked };
39
39
  }
40
40
  if (!isRecord(node)) return { cleared, exhausted, blocked };
41
+ // A usage window at 100%, in either spelling the gate fields come in: `usedPercent` in the
42
+ // app-server's JSON-RPC, `used_percent` in the web usage snapshot.
41
43
  if (typeof node.usedPercent === "number" && node.usedPercent >= 100) exhausted = true;
44
+ if (typeof node.used_percent === "number" && node.used_percent >= 100) exhausted = true;
42
45
  if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) blocked = true;
43
46
  if (isRecord(node.spend_control) && node.spend_control.reached === true) blocked = true;
44
47
  const reachedType = node.rate_limit_reached_type;
@@ -15,14 +15,12 @@ import { lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileS
15
15
  import { join } from "node:path";
16
16
  import type { OcxConfig } from "../types";
17
17
  import { renameAtomicFile } from "../lib/windows-atomic-replace";
18
- import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias";
19
- import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, withOneMillionMarker } from "./context-windows";
18
+ import { entryParts, SAFE_AGENT_MODEL_ID, withSubagentContextMarker } from "./subagent-model";
19
+ import { stripOneMillionMarker } from "./context-windows";
20
20
  import { claudeConfigDir } from "./gateway-cache";
21
- import { DEFAULT_SUBAGENT_MODELS, hasOwnProvider } from "../config";
21
+ import { DEFAULT_SUBAGENT_MODELS } from "../config";
22
22
  import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound";
23
23
  import { AnthropicRequestError } from "./inbound-records";
24
- import { knownModelIdsForProvider } from "../router";
25
- import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec";
26
24
  import { siblingOfLivePort } from "../codex/sibling-start";
27
25
 
28
26
  export interface ClaudeAgentDef {
@@ -37,7 +35,7 @@ export interface ClaudeAgentDef {
37
35
  const OWNED_PREFIX = "ocx-";
38
36
  /** Ownership proof (audit 071 #2): a file without this marker is NEVER touched. */
39
37
  const GENERATED_MARKER = "generated-by: opencodex";
40
- const SAFE_AGENT_MODEL_ID = /^[a-z0-9][a-z0-9._:/@+\[\]~-]*$/i;
38
+
41
39
 
42
40
  function sanitizeName(value: string): string {
43
41
  const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -59,43 +57,6 @@ function pickerDefaultModel(configDir: string): string | null {
59
57
  }
60
58
  }
61
59
 
62
- /** Roster entry -> alias + display parts. Entries are bare native slugs or "provider/id".
63
- * Codex-facing encoded ids (`provider/vendor-model`) decode to the native slash id first
64
- * so the alias joins the raw-native context-window map (context-windows.ts). */
65
-
66
- /**
67
- * Generated subagent defs cannot rely on the parent's auto-context compaction
68
- * pairing, so their [1m] marker follows the AUTHORITATIVE window only: mark when
69
- * the effective window (exact selector, then the canonical [1m] form, then bare)
70
- * is genuinely >= 1M; strip an inherited unsafe marker back to the bare selector;
71
- * with no window information, keep the selector as it was. Genuine routed [1m]
72
- * ids are preserved through the canonical-exact lookup. (#854)
73
- */
74
- function withSubagentContextMarker(selector: string, windows: Record<string, number>): string {
75
- const bare = stripOneMillionMarker(selector);
76
- const wasMarked = selector !== bare;
77
- const canonicalExact = wasMarked ? `${bare}[1m]` : selector;
78
- const authoritativeWindow = windows[selector] ?? windows[canonicalExact] ?? windows[bare];
79
- if (typeof authoritativeWindow === "number" && authoritativeWindow > 0) {
80
- return shouldMarkOneMillion(authoritativeWindow, AUTO_CONTEXT_OFF)
81
- ? (withOneMillionMarker(selector, windows) ?? selector)
82
- : bare;
83
- }
84
- return wasMarked ? selector : bare;
85
- }
86
- function entryParts(entry: string, config: OcxConfig): { alias: string; id: string; provider: string } {
87
- const slash = entry.indexOf("/");
88
- if (slash > 0) {
89
- const provider = entry.slice(0, slash);
90
- const prov = hasOwnProvider(config.providers, provider) ? config.providers[provider] : undefined;
91
- const id = prov
92
- ? decodeRoutedModelIdOrThrow(entry.slice(slash + 1), knownModelIdsForProvider(provider, prov, config))
93
- : entry.slice(slash + 1);
94
- return { alias: claudeCodeAlias(provider, id), id, provider };
95
- }
96
- return { alias: claudeCodeNativeAlias(entry), id: entry, provider: "native" };
97
- }
98
-
99
60
  export function buildClaudeAgentDefs(
100
61
  config: OcxConfig,
101
62
  windows: Record<string, number>,
@@ -73,19 +73,13 @@ export function writeGatewayModelCache(baseUrl: string, models: readonly Gateway
73
73
  }
74
74
  }
75
75
 
76
- /** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
77
- export async function refreshGatewayModelCacheFromProxy(
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(
76
+ export interface GatewayModelSnapshot { baseUrl: string; models: GatewayModelRow[] }
77
+
78
+ /** Acquire fresh exposure independently of whether the local cache is writable. */
79
+ export async function fetchGatewayModels(
86
80
  portOrTarget: number | GatewayModelTarget,
87
81
  options: GatewayModelCacheRefreshOptions = {},
88
- ): Promise<string | null> {
82
+ ): Promise<GatewayModelSnapshot | null> {
89
83
  try {
90
84
  const headers = new Headers({ "anthropic-version": "2023-06-01" });
91
85
  // A wildcard/non-loopback listener requires data-plane admission even for a
@@ -114,14 +108,23 @@ export async function refreshGatewayModelCacheFromProxy(
114
108
  const body = await res.json() as { data?: unknown };
115
109
  if (!Array.isArray(body.data)) return null;
116
110
  const models: GatewayModelRow[] = body.data
117
- .filter(m => typeof m.id === "string" && (m.id as string).length > 0)
111
+ .filter(m => m && typeof m === "object" && typeof m.id === "string" && m.id.length > 0)
118
112
  .map(m => ({
119
113
  id: m.id as string,
120
114
  display_name: typeof m.display_name === "string" ? m.display_name : undefined,
121
115
  description: typeof m.description === "string" ? m.description : undefined,
122
116
  }));
123
- return writeGatewayModelCache(baseUrl, models, options.configDir);
117
+ return { baseUrl, models };
124
118
  } catch {
125
119
  return null;
126
120
  }
127
121
  }
122
+
123
+ /** Preserve the cache-refresh API used by launchers and profile application. */
124
+ export async function refreshGatewayModelCacheFromProxy(
125
+ portOrTarget: number | GatewayModelTarget,
126
+ options: GatewayModelCacheRefreshOptions = {},
127
+ ): Promise<string | null> {
128
+ const snapshot = await fetchGatewayModels(portOrTarget, options);
129
+ return snapshot ? writeGatewayModelCache(snapshot.baseUrl, snapshot.models, options.configDir) : null;
130
+ }
@@ -1,4 +1,4 @@
1
- import type { OcxClaudeCodeConfig } from "../types";
1
+ import type { OcxConfig, OcxClaudeCodeConfig } from "../types";
2
2
  import { isAnthropicOutputSchema, satisfiesOpenAiStrictSchema } from "../adapters/anthropic-output-schema";
3
3
  import { resolveAlias } from "./alias";
4
4
  import { stripOneMillionMarker } from "./context-windows";
@@ -153,7 +153,14 @@ export function extractOcxRouteDirective(body: unknown): string | null {
153
153
  const text = systemText(body);
154
154
  if (!text) return null;
155
155
  const match = OCX_ROUTE_RE.exec(text);
156
- return match ? match[1]! : null;
156
+ if (!match) return null;
157
+ // Explicit gateway selection belongs to this invocation, not the mutable saved
158
+ // force setting. Bare Claude ids still need the legacy frontmatter fallback.
159
+ if (isRec(body) && typeof body.model === "string") {
160
+ const selector = stripOneMillionMarker(body.model);
161
+ if (resolveAlias(selector) || resolveDesktop3pAlias(selector)) return body.model;
162
+ }
163
+ return match[1]!;
157
164
  }
158
165
 
159
166
  /**
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import type { ClaudeFirstPartyDesired } from "../first-party-settings";
18
18
  import { classifyInterceptClient } from "./client-class";
19
19
  import { BOOTSTRAP_MAX_DECODED_BYTES, injectPickerModels, type PickerModelEntry } from "./picker-bootstrap";
20
+ import { PickerRewriteBudget } from "./picker-budget";
20
21
 
21
22
  export type CliCatalogKind = "model_selector" | "bootstrap";
22
23
 
@@ -60,26 +61,36 @@ export function injectCliBootstrapOptions(bootstrap: unknown, models: readonly P
60
61
  if (current !== undefined && current !== null && !Array.isArray(current)) return 0;
61
62
  const options = Array.isArray(current) ? current : [];
62
63
  const existing = new Set(options.map(option => record(option)?.model));
63
- let added = 0;
64
- for (const model of models) {
65
- if (existing.has(model.id)) continue;
66
- options.push({ model: model.id, name: model.name, description: model.description ?? "" });
67
- existing.add(model.id);
68
- added++;
69
- }
70
- if (added > 0) body.additional_model_options = options;
71
- return added;
64
+ const additions: Record<string, unknown>[] = [];
65
+ try {
66
+ const budget = new PickerRewriteBudget(body);
67
+ // A missing/null property needs a key and array, plus a possible separator (conservative).
68
+ if (!Array.isArray(current)) budget.reserveBytes(32);
69
+ for (const model of models) {
70
+ if (existing.has(model.id)) continue;
71
+ const row = { model: model.id, name: model.name, description: model.description ?? "" };
72
+ budget.reserveRow(row);
73
+ additions.push(row);
74
+ existing.add(model.id);
75
+ }
76
+ } catch { return 0; } // Fail open without mutating the original options.
77
+ for (const row of additions) options.push(row);
78
+ if (additions.length > 0) body.additional_model_options = options;
79
+ return additions.length;
72
80
  }
73
81
 
74
82
  /** Rewrite a decoded catalog body; `null` when nothing was added or the body is not the expected shape. */
75
83
  export function rewriteCliCatalogBody(kind: CliCatalogKind, text: string, models: readonly PickerModelEntry[]): string | null {
76
- if (models.length === 0) return null;
77
- let parsed: unknown;
78
- try { parsed = JSON.parse(text); } catch { return null; }
79
- const added = kind === "bootstrap"
80
- ? injectCliBootstrapOptions(parsed, models)
81
- : injectPickerModels(parsed, models, undefined, { surfaces: CLI_PICKER_SURFACE_IDS, extraStrippedKeys: CLI_EXTRA_STRIPPED_KEYS });
82
- return added > 0 ? JSON.stringify(parsed) : null;
84
+ if (models.length === 0 || Buffer.byteLength(text) > BOOTSTRAP_MAX_DECODED_BYTES) return null;
85
+ try {
86
+ const parsed: unknown = JSON.parse(text);
87
+ const added = kind === "bootstrap"
88
+ ? injectCliBootstrapOptions(parsed, models)
89
+ : injectPickerModels(parsed, models, undefined, { surfaces: CLI_PICKER_SURFACE_IDS, extraStrippedKeys: CLI_EXTRA_STRIPPED_KEYS });
90
+ if (added === 0) return null;
91
+ const rewritten = JSON.stringify(parsed);
92
+ return Buffer.byteLength(rewritten) <= BOOTSTRAP_MAX_DECODED_BYTES ? rewritten : null;
93
+ } catch { return null; }
83
94
  }
84
95
 
85
96
  /** Read at most `cap` bytes; null (and the stream cancelled) once the body would exceed it. */
@@ -3,6 +3,7 @@
3
3
  * A failed or inapplicable transform leaves the upstream bytes untouched.
4
4
  */
5
5
  import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
6
+ import { PICKER_MAX_OUTPUT_BYTES, PickerRewriteBudget, pickerRowBytes } from "./picker-budget";
6
7
 
7
8
  /** One opencodex model offered in Desktop's Code-tab picker. */
8
9
  export interface PickerModelEntry {
@@ -16,7 +17,7 @@ export interface PickerModelEntry {
16
17
  }
17
18
 
18
19
  export const BOOTSTRAP_MAX_ENCODED_BYTES = 4 * 1024 * 1024;
19
- export const BOOTSTRAP_MAX_DECODED_BYTES = 16 * 1024 * 1024;
20
+ export const BOOTSTRAP_MAX_DECODED_BYTES = PICKER_MAX_OUTPUT_BYTES;
20
21
  const BOOTSTRAP_PATH = /^\/(?:edge-api|api)\/bootstrap(?:\/[A-Za-z0-9-]+\/app_start)?\/?$/;
21
22
  const REWRITE_REMOVED_HEADERS = new Set([
22
23
  "content-encoding", "content-length", "etag", "digest", "content-md5", "transfer-encoding",
@@ -56,11 +57,14 @@ export interface PickerInjectionOptions {
56
57
  extraStrippedKeys?: readonly string[];
57
58
  }
58
59
 
59
- function injectIntoSurface(
60
+ interface SurfaceInjection { entries: unknown[]; additions: Record<string, unknown>[] }
61
+
62
+ function planSurfaceInjection(
60
63
  surface: Record<string, unknown>,
61
64
  models: readonly PickerModelEntry[],
62
65
  stripped: readonly string[],
63
- ): number | string {
66
+ budget: PickerRewriteBudget,
67
+ ): SurfaceInjection | string {
64
68
  if (!Array.isArray(surface.models)) return "no_models";
65
69
  const entries = surface.models as unknown[];
66
70
  const template = entries.map(record).find(entry =>
@@ -68,24 +72,28 @@ function injectIntoSurface(
68
72
  && !entry.disabled && !entry.disabled_reason && entry.section !== "deprecated");
69
73
  if (!template) return `no_template(models=${entries.length})`;
70
74
  const existing = new Set(entries.map(record).map(entry => entry?.id));
71
- let added = 0;
75
+ // Strip by shallow projection before measuring or cloning retained metadata. Native rows stay intact.
76
+ const retained = { ...template };
77
+ for (const key of Object.keys(retained)) {
78
+ if (stripped.includes(key) || /version/i.test(key)
79
+ || ["id", "name", "section", "context_window"].includes(key)) delete retained[key];
80
+ }
81
+ pickerRowBytes(retained);
82
+ const additions: Record<string, unknown>[] = [];
72
83
  for (const model of models) {
73
84
  if (existing.has(model.id)) continue;
74
- const copy = structuredClone(template);
85
+ const copy = { ...retained };
75
86
  copy.id = model.id;
76
87
  copy.name = model.name;
77
88
  copy.section = "main";
78
89
  if (model.contextWindow === undefined) delete copy.context_window;
79
90
  else copy.context_window = model.contextWindow;
80
- for (const key of Object.keys(copy)) {
81
- if (stripped.includes(key) || /version/i.test(key)) delete copy[key];
82
- }
83
91
  if (model.description !== undefined) copy.description = model.description;
84
- entries.push(copy);
92
+ budget.reserveRow(copy);
93
+ additions.push(copy);
85
94
  existing.add(model.id);
86
- added++;
87
95
  }
88
- return added;
96
+ return { entries, additions };
89
97
  }
90
98
 
91
99
  export function injectPickerModels(
@@ -110,10 +118,21 @@ export function injectPickerModels(
110
118
  }
111
119
  let added = 0;
112
120
  const skipped: string[] = [];
113
- for (const surface of targets) {
114
- const result = injectIntoSurface(surface, models, stripped);
115
- if (typeof result === "number") added += result;
116
- else skipped.push(`${String(surface.id)}:${result}`);
121
+ try {
122
+ const budget = new PickerRewriteBudget(bootstrap);
123
+ const plans: SurfaceInjection[] = [];
124
+ for (const surface of targets) {
125
+ const result = planSurfaceInjection(surface, models, stripped, budget);
126
+ if (typeof result === "string") skipped.push(`${String(surface.id)}:${result}`);
127
+ else { plans.push(result); added += result.additions.length; }
128
+ }
129
+ // All surfaces must fit before any deep clone or mutation; a refusal cannot publish a partial list.
130
+ const copies = plans.map(plan => plan.additions.map(row => structuredClone(row)));
131
+ for (let i = 0; i < plans.length; i++) {
132
+ for (const row of copies[i]!) plans[i]!.entries.push(row);
133
+ }
134
+ } catch {
135
+ return unchanged("rewrite_limit");
117
136
  }
118
137
  if (added === 0) return unchanged(skipped.length > 0 ? skipped.join(";") : models.length === 0 ? "no_routes" : "all_present");
119
138
  explain?.({ kind: "rewritten", added });
@@ -142,7 +161,9 @@ export function rewriteBootstrapBody(
142
161
  if (decoded.length > BOOTSTRAP_MAX_DECODED_BYTES) return unchanged("decoded_cap");
143
162
  const parsed: unknown = JSON.parse(decoded.toString("utf8"));
144
163
  if (injectPickerModels(parsed, models, explain) === 0) return null;
145
- return Buffer.from(JSON.stringify(parsed), "utf8");
164
+ const text = JSON.stringify(parsed);
165
+ if (Buffer.byteLength(text) > BOOTSTRAP_MAX_DECODED_BYTES) return unchanged("rewrite_limit");
166
+ return Buffer.from(text, "utf8");
146
167
  } catch {
147
168
  return unchanged("decode_or_parse_failed");
148
169
  }