@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005
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.
- package/bin/ocx.mjs +11 -10
- package/gui/dist/assets/App-CppMz-6w.js +54 -0
- package/gui/dist/assets/App-lCIslZ1L.css +1 -0
- package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
- package/gui/dist/assets/index-DQt9srNi.js +94 -0
- package/gui/dist/assets/index-DmQB8D_R.css +1 -0
- package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
- package/gui/dist/index.html +4 -3
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +19 -2
- package/src/adapters/anthropic/client-identity.ts +13 -4
- package/src/adapters/anthropic/native-client-preamble.ts +31 -0
- package/src/adapters/anthropic/passthrough.ts +103 -25
- package/src/adapters/antigravity-refusal.ts +28 -0
- package/src/adapters/cursor/protobuf-request.ts +6 -0
- package/src/adapters/cursor/request-builder.ts +3 -0
- package/src/adapters/cursor/structured-output.ts +13 -0
- package/src/adapters/cursor/types.ts +2 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -2
- package/src/adapters/devin/trajectory.ts +49 -0
- package/src/adapters/devin.ts +13 -0
- package/src/adapters/ollama-native.ts +62 -19
- package/src/adapters/openai-responses/passthrough.ts +133 -103
- package/src/adapters/openai-responses/request-strips.ts +10 -4
- package/src/adapters/zed.ts +26 -4
- package/src/bridge/response-json.ts +14 -14
- package/src/bridge/sse.ts +15 -5
- package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
- package/src/claude/agents-inject.ts +4 -43
- package/src/claude/gateway-cache.ts +16 -13
- package/src/claude/inbound-model-options.ts +9 -2
- package/src/claude/intercept/cli-catalog.ts +27 -16
- package/src/claude/intercept/client-hello.ts +103 -0
- package/src/claude/intercept/model-bindings.ts +3 -2
- package/src/claude/intercept/picker-bootstrap.ts +37 -16
- package/src/claude/intercept/picker-budget.ts +41 -0
- package/src/claude/intercept/picker-listener.ts +279 -66
- package/src/claude/outbound.ts +2 -0
- package/src/claude/subagent-force-status.ts +53 -0
- package/src/claude/subagent-model.ts +93 -0
- package/src/cli/access-audio-input.ts +84 -0
- package/src/cli/access-audio-live.ts +99 -0
- package/src/cli/access-audio.ts +123 -0
- package/src/cli/access-data-client.ts +127 -0
- package/src/cli/access-data-plane.ts +104 -0
- package/src/cli/access-data-response.ts +76 -0
- package/src/cli/access.ts +65 -2
- package/src/cli/account-api.ts +33 -6
- package/src/cli/account-auth.ts +111 -42
- package/src/cli/account-key-quota.ts +118 -0
- package/src/cli/account-main.ts +11 -4
- package/src/cli/account-next-actions.ts +63 -0
- package/src/cli/account-policy-dto.ts +68 -0
- package/src/cli/account-policy.ts +167 -0
- package/src/cli/account-target.ts +8 -0
- package/src/cli/account.ts +37 -9
- package/src/cli/agent-runtime-settings.ts +130 -0
- package/src/cli/agent-settings.ts +101 -0
- package/src/cli/agent.ts +29 -2
- package/src/cli/alias.ts +7 -2
- package/src/cli/aside-profiles.ts +2 -2
- package/src/cli/capabilities-access-remote.ts +147 -0
- package/src/cli/capabilities-accounts.ts +195 -0
- package/src/cli/capabilities-agents-routing.ts +414 -0
- package/src/cli/capabilities-base.ts +783 -0
- package/src/cli/capabilities-command.ts +1 -0
- package/src/cli/capabilities-integrations.ts +264 -0
- package/src/cli/capabilities-lab.ts +214 -0
- package/src/cli/capabilities-observe-system.ts +662 -0
- package/src/cli/capabilities-provider-models.ts +302 -0
- package/src/cli/capabilities.ts +30 -1190
- package/src/cli/capability-types.ts +54 -0
- package/src/cli/catalog-command-result.ts +57 -0
- package/src/cli/chatgpt-command.ts +35 -9
- package/src/cli/claude-agent-startup-sync.ts +17 -0
- package/src/cli/claude-desktop-profile.ts +100 -0
- package/src/cli/claude-desktop.ts +39 -6
- package/src/cli/claude.ts +22 -6
- package/src/cli/codex-shim-autorestore.ts +2 -1
- package/src/cli/codex-shim-readiness.ts +109 -0
- package/src/cli/combo-input.ts +88 -0
- package/src/cli/combo-result.ts +52 -0
- package/src/cli/combo-stats.ts +123 -0
- package/src/cli/combo.ts +37 -14
- package/src/cli/companion-timeline.ts +142 -0
- package/src/cli/companion-usage.ts +146 -0
- package/src/cli/companion.ts +12 -3
- package/src/cli/config-command.ts +24 -8
- package/src/cli/connect.ts +19 -0
- package/src/cli/dispatch.ts +55 -82
- package/src/cli/doctor.ts +22 -4
- package/src/cli/export-command.ts +20 -9
- package/src/cli/gui.ts +8 -3
- package/src/cli/help-catalog.ts +101 -0
- package/src/cli/help-models-context.ts +17 -0
- package/src/cli/help-navigation.ts +50 -0
- package/src/cli/help-recovery.ts +66 -0
- package/src/cli/help.ts +69 -11
- package/src/cli/index.ts +38 -27
- package/src/cli/injection-follow.ts +35 -0
- package/src/cli/inspect.ts +5 -0
- package/src/cli/integration-aside-sync.ts +69 -0
- package/src/cli/integration-cursor.ts +106 -0
- package/src/cli/integration-input.ts +60 -0
- package/src/cli/integration-journal.ts +49 -0
- package/src/cli/integration-plan-dto.ts +156 -0
- package/src/cli/integration-preview.ts +215 -0
- package/src/cli/integrations.ts +24 -18
- package/src/cli/json-input.ts +134 -0
- package/src/cli/link.ts +32 -4
- package/src/cli/local-sync-result.ts +41 -0
- package/src/cli/log-follow.ts +84 -0
- package/src/cli/log-view-filter.ts +146 -0
- package/src/cli/logout-command.ts +44 -0
- package/src/cli/model-picker-ordering.ts +137 -0
- package/src/cli/models-custom-input.ts +107 -0
- package/src/cli/models-custom-runtime.ts +112 -0
- package/src/cli/models-order.ts +194 -0
- package/src/cli/models-runtime-subcommands.ts +2 -0
- package/src/cli/models-runtime.ts +9 -1
- package/src/cli/models.ts +66 -81
- package/src/cli/observe-stream.ts +142 -0
- package/src/cli/observe.ts +75 -34
- package/src/cli/opencode.ts +54 -10
- package/src/cli/provider-batch.ts +77 -0
- package/src/cli/provider-lifecycle-runtime.ts +170 -0
- package/src/cli/provider-result.ts +124 -0
- package/src/cli/provider-runtime.ts +15 -12
- package/src/cli/provider-settings.ts +143 -0
- package/src/cli/provider.ts +116 -64
- package/src/cli/registry.ts +90 -24
- package/src/cli/remote-workspace-hub.ts +83 -0
- package/src/cli/remote-workspace.ts +6 -1
- package/src/cli/restart-failure.ts +27 -0
- package/src/cli/root.ts +42 -9
- package/src/cli/route-policy-write.ts +137 -0
- package/src/cli/route-policy.ts +8 -0
- package/src/cli/runtime-api.ts +57 -80
- package/src/cli/secret-args.ts +149 -0
- package/src/cli/settings-result.ts +30 -0
- package/src/cli/storage.ts +77 -7
- package/src/cli/system-command.ts +60 -2
- package/src/cli/system-restart-client.ts +20 -3
- package/src/cli/system-settings-parity.ts +83 -0
- package/src/cli/tray-proxy.ts +3 -2
- package/src/cli/update-restart-candidate.ts +21 -0
- package/src/cli/update-restart-child.ts +81 -0
- package/src/cli/update-restart-home.ts +38 -0
- package/src/cli/update-restart-transport.ts +193 -0
- package/src/cli/update-restart.ts +260 -0
- package/src/cli/usage-model-search.ts +58 -0
- package/src/cli/usage-report.ts +13 -5
- package/src/cli/v2-input.ts +66 -0
- package/src/cli/v2-local-output.ts +37 -0
- package/src/cli/v2-runtime.ts +90 -0
- package/src/cli/v2.ts +102 -144
- package/src/clients/config-export/contracts.ts +100 -0
- package/src/clients/config-export/droid.ts +54 -2
- package/src/clients/config-export/dsh.ts +21 -0
- package/src/clients/config-export/kilo.ts +12 -2
- package/src/clients/config-export/model-metadata.ts +10 -4
- package/src/clients/config-export/reasoning-metadata.ts +47 -0
- package/src/clients/config-export.ts +53 -67
- package/src/clients/omo-role-models.ts +17 -3
- package/src/codex/account-credit-use.ts +4 -2
- package/src/codex/account-runtime-state.ts +21 -0
- package/src/codex/account-store.ts +11 -47
- package/src/codex/account-usability.ts +11 -8
- package/src/codex/auth-api/account-list.ts +73 -63
- package/src/codex/auth-api/main-account-probe.ts +25 -12
- package/src/codex/auth-api/pool-mode-gate.ts +6 -9
- package/src/codex/auth-api/pool-quota-probe.ts +2 -4
- package/src/codex/auth-api/reset-credit-service.ts +3 -2
- package/src/codex/auth-context.ts +30 -11
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/build-entries.ts +1 -1
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/heal-observation.ts +66 -0
- package/src/codex/catalog/model-hints.ts +2 -1
- package/src/codex/catalog/model-visibility.ts +6 -3
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-models.ts +13 -4
- package/src/codex/catalog/publication-observer.ts +19 -0
- package/src/codex/catalog/remote.ts +13 -15
- package/src/codex/catalog/restore.ts +19 -6
- package/src/codex/catalog/retained-sync.ts +60 -11
- package/src/codex/catalog/routed-removal.ts +116 -0
- package/src/codex/catalog/write-audit-contract.ts +21 -0
- package/src/codex/catalog/write-audit.ts +171 -0
- package/src/codex/catalog-self-heal.ts +284 -0
- package/src/codex/catalog-write-serialization.ts +71 -7
- package/src/codex/chatgpt-refresh-failure.ts +56 -0
- package/src/codex/codex-home-owner.ts +124 -0
- package/src/codex/convergence.ts +57 -16
- package/src/codex/features.ts +8 -1
- package/src/codex/inject/restore.ts +46 -4
- package/src/codex/inject.ts +11 -1
- package/src/codex/internal/catalog-writer.ts +120 -13
- package/src/codex/journal.ts +49 -13
- package/src/codex/main-account.ts +144 -11
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/quota-refresh-outcome.ts +17 -2
- package/src/codex/quota-types.ts +36 -1
- package/src/codex/quota.ts +67 -16
- package/src/codex/refresh.ts +20 -9
- package/src/codex/routing/cooldown-math.ts +10 -4
- package/src/codex/routing/selection.ts +11 -4
- package/src/codex/routing.ts +9 -9
- package/src/codex/runtime.ts +7 -1
- package/src/codex/shim-diagnostics.ts +80 -0
- package/src/codex/shim-fingerprint.ts +7 -2
- package/src/codex/shim-migration.ts +153 -0
- package/src/codex/shim-overlay.ts +509 -0
- package/src/codex/shim-path-resolution.ts +145 -0
- package/src/codex/shim-probe.ts +4 -3
- package/src/codex/shim-state-file.ts +93 -7
- package/src/codex/shim.ts +54 -107
- package/src/codex/subagent-model-fallback.ts +2 -1
- package/src/codex/sync.ts +24 -4
- package/src/combos/failover.ts +101 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/jev.ts +13 -3
- package/src/combos/resolve.ts +12 -0
- package/src/config/atomic-write.ts +13 -8
- package/src/config/diagnostics.ts +23 -1
- package/src/config/load-degrade.ts +7 -0
- package/src/config/provider-validation.ts +3 -0
- package/src/config/schema/chatgpt-desktop.ts +22 -0
- package/src/config/schema/compaction-triggers.ts +8 -0
- package/src/config/schema/config-schema.ts +43 -14
- package/src/config/schema/leaf-validators.ts +8 -3
- package/src/config/subagent-models.ts +12 -0
- package/src/generated/compatibility-version.json +645 -237
- package/src/grok/session-identity.ts +33 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/merge.ts +13 -5
- package/src/integrations/mutation-plan.ts +50 -5
- package/src/integrations/omp-yaml-source.ts +181 -1
- package/src/integrations/ownership-policy.ts +66 -0
- package/src/integrations/registry.ts +50 -1
- package/src/integrations/state.ts +106 -19
- package/src/integrations/target.ts +25 -2
- package/src/integrations/writer.ts +107 -35
- package/src/lib/gui-pair-capability.ts +17 -0
- package/src/lib/package-tree-integrity.ts +42 -1
- package/src/lib/provider-client-headers.ts +62 -0
- package/src/lib/proxy-env.ts +3 -0
- package/src/lib/test-home-guard.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +19 -8
- package/src/oauth/anthropic-continuity.ts +65 -0
- package/src/oauth/anthropic-identity.ts +75 -0
- package/src/oauth/anthropic-routing.ts +20 -15
- package/src/oauth/anthropic.ts +34 -3
- package/src/oauth/generic-account-failover.ts +21 -0
- package/src/oauth/health.ts +32 -5
- package/src/oauth/index.ts +42 -35
- package/src/oauth/local-token-detect.ts +7 -0
- package/src/oauth/pool-settings-capability.ts +9 -2
- package/src/oauth/refresh-policy.ts +2 -0
- package/src/oauth/store.ts +55 -6
- package/src/oauth/types.ts +9 -0
- package/src/protocols/encoders/messages.ts +4 -3
- package/src/protocols/plan-snapshot.ts +1 -1
- package/src/protocols/settings.ts +28 -4
- package/src/providers/antigravity-effort-families.ts +75 -0
- package/src/providers/antigravity-models.ts +54 -16
- package/src/providers/antigravity-wire-snapshot.ts +66 -0
- package/src/providers/derive.ts +2 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/new-model-policy.ts +48 -10
- package/src/providers/registry/entries-extended.ts +3 -0
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/model-seeds.ts +5 -0
- package/src/providers/registry/types.ts +4 -0
- package/src/responses/compaction.ts +19 -0
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +9 -0
- package/src/server/caller-session-identity.ts +32 -0
- package/src/server/chat-completions.ts +10 -0
- package/src/server/claude-messages.ts +29 -15
- package/src/server/droid-reasoning-default.ts +50 -0
- package/src/server/github-copilot-responses-repair.ts +3 -6
- package/src/server/grok-responses-control-frame.ts +2 -2
- package/src/server/grok-responses-snapshot-repair.ts +4 -5
- package/src/server/gui-session.ts +33 -11
- package/src/server/index/claude-intercept-lifecycle.ts +4 -0
- package/src/server/index/serve-options.ts +13 -7
- package/src/server/index.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +12 -134
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +8 -1
- package/src/server/management/cursor-integration-routes.ts +3 -3
- package/src/server/management/integration-routes.ts +69 -11
- package/src/server/management/link-routes.ts +20 -13
- package/src/server/management/model-routes.ts +3 -3
- package/src/server/management/model-row-export-metadata.ts +150 -0
- package/src/server/management/model-rows.ts +148 -9
- package/src/server/management/native-integration-routes.ts +2 -2
- package/src/server/management/oauth-account-routes.ts +96 -30
- package/src/server/management/route-registry.ts +29 -27
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/subagent-model-routes.ts +214 -0
- package/src/server/messages-native-eligibility.ts +3 -6
- package/src/server/messages-native-oauth.ts +82 -35
- package/src/server/messages-native.ts +78 -16
- package/src/server/relay.ts +14 -44
- package/src/server/request-log-generation-window.ts +38 -0
- package/src/server/request-log.ts +13 -0
- package/src/server/responses/adapter-dispatch.ts +152 -28
- package/src/server/responses/codex-auth-error.ts +10 -2
- package/src/server/responses/combo-stream-preflight.ts +29 -3
- package/src/server/responses/compaction-routing.ts +10 -1
- package/src/server/responses/core-auth.ts +67 -30
- package/src/server/responses/core-codex-account.ts +6 -3
- package/src/server/responses/core-combo-failure.ts +5 -1
- package/src/server/responses/core-combo-native.ts +7 -1
- package/src/server/responses/core-combo.ts +64 -7
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/fetch-helpers.ts +9 -0
- package/src/server/responses/passthrough-delivery.ts +1 -1
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/server/responses/request-prepare.ts +5 -0
- package/src/server/responses/request-spend.ts +30 -30
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/responses-hosted-image-display.ts +70 -9
- package/src/server/responses-terminal-repair.ts +15 -2
- package/src/server/sse-frame-buffer.ts +38 -36
- package/src/server/sse-payload-rewrite.ts +78 -49
- package/src/server/ws-bridge.ts +1 -11
- package/src/service/cli.ts +6 -1
- package/src/service/ownership-mutation-lease.d.mts +26 -0
- package/src/service/ownership-mutation-lease.mjs +76 -1
- package/src/service/windows-taskxml.ts +1 -2
- package/src/service-manager-probe.ts +36 -2
- package/src/types/config.ts +6 -0
- package/src/types/provider.ts +28 -10
- package/src/update/index.ts +2 -1
- package/src/update/npm-invocation.mjs +35 -3
- package/src/update/proxy-liveness-probe.mjs +38 -8
- package/src/update/update-failure-guidance.d.mts +6 -0
- package/src/update/update-failure-guidance.mjs +30 -5
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +7 -0
- package/src/usage/summary.ts +82 -1
- package/src/web-search/loop.ts +11 -8
- package/src/web-search/run-turn-loop.ts +10 -7
- package/gui/dist/assets/App-CddorHdA.js +0 -51
- package/gui/dist/assets/App-DMudtISW.css +0 -1
- package/gui/dist/assets/index-Btze1I3T.css +0 -1
- package/gui/dist/assets/index-CvnY7E6i.js +0 -86
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
if (options.recordBufferedDelivery !== false) {
|
|
68
|
-
attemptDeliveryRecorder(
|
|
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
|
-
|
|
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 ?
|
|
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(
|
|
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
|
-
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
51
|
-
*
|
|
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
|
|
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
|
|
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 {
|
|
19
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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<
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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. */
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the ALPN offer from a TLS ClientHello without terminating TLS.
|
|
3
|
+
*
|
|
4
|
+
* The picker listener uses it to send a client that offers `h2` to its HTTP/2 server and every
|
|
5
|
+
* other client to its HTTP/1.1 server. The ClientHello is reassembled from as many plaintext
|
|
6
|
+
* handshake records as it spans (RFC 8446 5.1 allows any split), within fixed byte and record
|
|
7
|
+
* limits. This is a routing hint, not validation: parsing stops at the ALPN decision, and the TLS
|
|
8
|
+
* server that receives the replayed bytes validates the handshake. Anything the parser cannot read
|
|
9
|
+
* up to that decision returns false, which keeps that client on HTTP/1.1, the behaviour before
|
|
10
|
+
* HTTP/2 existed here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Wire bytes the front reads before deciding; a real ClientHello is far smaller. */
|
|
14
|
+
export const CLIENT_HELLO_MAX_BYTES = 64 * 1024;
|
|
15
|
+
/** Handshake records one ClientHello may span. */
|
|
16
|
+
export const CLIENT_HELLO_MAX_RECORDS = 16;
|
|
17
|
+
|
|
18
|
+
const RECORD_HEADER_BYTES = 5;
|
|
19
|
+
const RECORD_HANDSHAKE = 0x16;
|
|
20
|
+
const MAX_RECORD_PAYLOAD = 16_384;
|
|
21
|
+
const HANDSHAKE_CLIENT_HELLO = 0x01;
|
|
22
|
+
const EXTENSION_ALPN = 0x0010;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the ClientHello at the start of `data` offers `h2` in ALPN. Returns null while more
|
|
26
|
+
* bytes are needed to decide, and false for anything else (not TLS, malformed, over a limit).
|
|
27
|
+
*/
|
|
28
|
+
export function clientHelloOffersH2(data: Uint8Array): boolean | null {
|
|
29
|
+
const handshake: Uint8Array[] = [];
|
|
30
|
+
let handshakeBytes = 0;
|
|
31
|
+
let at = 0;
|
|
32
|
+
for (let records = 0; ; records++) {
|
|
33
|
+
if (handshakeBytes >= 4) {
|
|
34
|
+
const first = concat(handshake, 4);
|
|
35
|
+
if (first[0] !== HANDSHAKE_CLIENT_HELLO) return false;
|
|
36
|
+
const needed = 4 + ((first[1]! << 16) | (first[2]! << 8) | first[3]!);
|
|
37
|
+
if (needed > CLIENT_HELLO_MAX_BYTES) return false;
|
|
38
|
+
if (handshakeBytes >= needed) return helloOffersH2(concat(handshake, needed).subarray(4));
|
|
39
|
+
}
|
|
40
|
+
if (records >= CLIENT_HELLO_MAX_RECORDS || at >= CLIENT_HELLO_MAX_BYTES) return false;
|
|
41
|
+
if (data.length < at + RECORD_HEADER_BYTES) return null;
|
|
42
|
+
if (data[at] !== RECORD_HANDSHAKE || data[at + 1] !== 0x03) return false;
|
|
43
|
+
const length = (data[at + 3]! << 8) | data[at + 4]!;
|
|
44
|
+
if (length === 0 || length > MAX_RECORD_PAYLOAD) return false;
|
|
45
|
+
const end = at + RECORD_HEADER_BYTES + length;
|
|
46
|
+
if (end > CLIENT_HELLO_MAX_BYTES) return false;
|
|
47
|
+
if (data.length < end) return null;
|
|
48
|
+
handshake.push(data.subarray(at + RECORD_HEADER_BYTES, end));
|
|
49
|
+
handshakeBytes += length;
|
|
50
|
+
at = end;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function concat(parts: readonly Uint8Array[], bytes: number): Uint8Array {
|
|
55
|
+
const out = new Uint8Array(bytes);
|
|
56
|
+
let offset = 0;
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
if (offset >= bytes) break;
|
|
59
|
+
const take = part.subarray(0, bytes - offset);
|
|
60
|
+
out.set(take, offset);
|
|
61
|
+
offset += take.length;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function helloOffersH2(hello: Uint8Array): boolean {
|
|
67
|
+
let at = 2 + 32; // legacy_version, random
|
|
68
|
+
const skip = (lengthBytes: 1 | 2): boolean => {
|
|
69
|
+
if (at + lengthBytes > hello.length) return false;
|
|
70
|
+
const length = lengthBytes === 1 ? hello[at]! : (hello[at]! << 8) | hello[at + 1]!;
|
|
71
|
+
at += lengthBytes + length;
|
|
72
|
+
return at <= hello.length;
|
|
73
|
+
};
|
|
74
|
+
if (!skip(1) || !skip(2) || !skip(1)) return false; // session id, cipher suites, compression
|
|
75
|
+
if (at + 2 > hello.length) return false; // no extensions block: no ALPN
|
|
76
|
+
const extensionsEnd = at + 2 + ((hello[at]! << 8) | hello[at + 1]!);
|
|
77
|
+
if (extensionsEnd > hello.length) return false;
|
|
78
|
+
at += 2;
|
|
79
|
+
// Unknown and GREASE extensions are skipped by their declared length, in any order.
|
|
80
|
+
while (at + 4 <= extensionsEnd) {
|
|
81
|
+
const type = (hello[at]! << 8) | hello[at + 1]!;
|
|
82
|
+
const length = (hello[at + 2]! << 8) | hello[at + 3]!;
|
|
83
|
+
const body = at + 4;
|
|
84
|
+
if (body + length > extensionsEnd) return false;
|
|
85
|
+
if (type === EXTENSION_ALPN) return alpnListOffersH2(hello.subarray(body, body + length));
|
|
86
|
+
at = body + length;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function alpnListOffersH2(extension: Uint8Array): boolean {
|
|
92
|
+
if (extension.length < 2) return false;
|
|
93
|
+
const end = 2 + ((extension[0]! << 8) | extension[1]!);
|
|
94
|
+
if (end > extension.length) return false;
|
|
95
|
+
for (let at = 2; at < end;) {
|
|
96
|
+
const length = extension[at]!;
|
|
97
|
+
const name = at + 1;
|
|
98
|
+
if (length === 0 || name + length > end) return false;
|
|
99
|
+
if (length === 2 && extension[name] === 0x68 && extension[name + 1] === 0x32) return true; // "h2"
|
|
100
|
+
at = name + length;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
@@ -113,7 +113,7 @@ export function parseInterceptBindingPatch(body: unknown): InterceptBindingPatch
|
|
|
113
113
|
if (!set || typeof set !== "object" || Array.isArray(set)) return { error: "set must be an object of picker id to route" };
|
|
114
114
|
const entries: Record<string, string> = {};
|
|
115
115
|
for (const [id, route] of Object.entries(set as Record<string, unknown>)) {
|
|
116
|
-
if (!isInterceptBindingId(id)) return { error:
|
|
116
|
+
if (!isInterceptBindingId(id)) return { error: "invalid picker id (expected a claude- model id)" };
|
|
117
117
|
if (!isInterceptBindingRoute(route)) return { error: `invalid route for ${id}` };
|
|
118
118
|
entries[id] = route;
|
|
119
119
|
}
|
|
@@ -137,7 +137,8 @@ export function applyInterceptBindingPatch(
|
|
|
137
137
|
): InterceptBindingPatchResult {
|
|
138
138
|
const next: Record<string, string> = { ...current };
|
|
139
139
|
for (const [id, route] of Object.entries(patch.set ?? {})) {
|
|
140
|
-
|
|
140
|
+
// A rejected route is not echoed: whatever sits in that position may be a misplaced credential.
|
|
141
|
+
if (!availableRoutes.has(route)) return { ok: false, error: "route is not available (see ocx claude desktop show)" };
|
|
141
142
|
next[id] = route;
|
|
142
143
|
}
|
|
143
144
|
for (const id of patch.remove ?? []) delete next[id];
|