@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922
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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +8 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +20 -1
- package/src/adapters/base.ts +23 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +32 -4
- package/src/adapters/command-code.ts +52 -4
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +20 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +14 -3
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +125 -36
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +34 -7
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google-http.ts +49 -10
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +66 -11
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +23 -58
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +17 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +13 -151
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +46 -7
- package/src/cli/doctor.ts +28 -9
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +203 -59
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +4 -7
- package/src/client/runtime.ts +26 -2
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/account-store.ts +65 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +43 -17
- package/src/codex/catalog/retained-sync.ts +24 -28
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +31 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +99 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +116 -54
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +12 -42
- package/src/codex/shim-templates.ts +29 -3
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/companion/settings.ts +132 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +52 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +31 -1
- package/src/config/schema/leaf-validators.ts +59 -0
- package/src/config.ts +3 -3
- package/src/generated/compatibility-version.json +604 -280
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +921 -0
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +134 -110
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +63 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +109 -16
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +27 -108
- package/src/oauth/login-cli.ts +80 -29
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +97 -19
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +179 -38
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/vendor-probes-key.ts +38 -23
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +47 -20
- package/src/providers/registry/entries-extended.ts +63 -4
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +56 -10
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/apply-patch-envelope.ts +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +3 -0
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +108 -117
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +60 -4
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +61 -20
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +142 -39
- package/src/server/index/spend-ledger-lifecycle.ts +92 -0
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +34 -38
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/config-routes.ts +18 -2
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +20 -0
- package/src/server/management/shared.ts +28 -4
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +10 -9
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +138 -12
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +169 -2
- package/src/server/request-metrics.ts +298 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +43 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- package/src/server/responses/core-opaque-recovery.ts +91 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +45 -15
- package/src/server/responses/passthrough-dispatch.ts +215 -44
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +109 -18
- package/src/server/responses/request-send-budget.ts +17 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +26 -6
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +35 -2
- package/src/server/search.ts +25 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +156 -4
- package/src/types/config.ts +48 -3
- package/src/types/provider.ts +91 -0
- package/src/types/request.ts +30 -2
- package/src/types/tools.ts +33 -0
- package/src/types.ts +4 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/alpha-search.ts +21 -1
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
package/src/lib/errors.ts
CHANGED
|
@@ -74,6 +74,75 @@ export function isCyberPolicyMessage(text: string): boolean {
|
|
|
74
74
|
return false;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Refusal codes Codex ends a turn on.
|
|
79
|
+
*
|
|
80
|
+
* Its Responses parser classifies a `response.failed` terminal by `error.code`
|
|
81
|
+
* alone (codex-rs/codex-api/src/sse/responses.rs:423-462). These four become a
|
|
82
|
+
* fatal `ApiError` the client reports instead of reconnecting. A code outside
|
|
83
|
+
* this set is never read as a refusal: `server_is_overloaded` and `slow_down`
|
|
84
|
+
* take the overload arm, `rate_limit_exceeded` takes the rate-limit arm, and
|
|
85
|
+
* everything else falls through to `ApiError::Retryable` and is reconnected up
|
|
86
|
+
* to `stream_max_retries`.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately scoped to content refusals. `insufficient_quota` and
|
|
89
|
+
* `context_length_exceeded` are terminal for Codex too, but they are separate
|
|
90
|
+
* failure families this proxy already classifies through its own quota and
|
|
91
|
+
* context paths, and pulling them in here would change their retry behavior
|
|
92
|
+
* with no evidence asking for it.
|
|
93
|
+
*/
|
|
94
|
+
const TERMINAL_REFUSAL_CODES = new Set<string>([
|
|
95
|
+
CYBER_POLICY_ERROR_CODE,
|
|
96
|
+
"misalignment_policy_violation",
|
|
97
|
+
"invalid_prompt",
|
|
98
|
+
"bio_policy",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/** True when an upstream error code is a refusal the client must not retry. */
|
|
102
|
+
export function isTerminalRefusalCode(code: string | null | undefined): boolean {
|
|
103
|
+
return typeof code === "string" && TERMINAL_REFUSAL_CODES.has(code);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stand-in copy for a refusal the upstream sent without a message of its own. */
|
|
107
|
+
export const TERMINAL_REFUSAL_FALLBACK_MESSAGE = "The upstream refused this request.";
|
|
108
|
+
|
|
109
|
+
/** Readable copy for a refusal code, used when the upstream carried no message. */
|
|
110
|
+
export function terminalRefusalFallbackMessage(code: string): string {
|
|
111
|
+
return code === CYBER_POLICY_ERROR_CODE
|
|
112
|
+
? CYBER_POLICY_FALLBACK_MESSAGE
|
|
113
|
+
: TERMINAL_REFUSAL_FALLBACK_MESSAGE;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Name the refusal code behind upstream safety copy when the structured code
|
|
118
|
+
* was not carried on the wire.
|
|
119
|
+
*
|
|
120
|
+
* Same discipline as {@link isCyberPolicyMessage}: whole distinctive phrases
|
|
121
|
+
* only. A loose match here is the mirror-image defect — it would end a turn
|
|
122
|
+
* that a genuine transient failure would have retried successfully — so a
|
|
123
|
+
* message that merely mentions safety or blocking does not qualify. Callers
|
|
124
|
+
* must consult this only when the upstream carried no structured code at all,
|
|
125
|
+
* so that a transport failure quoting a refusal in its diagnostic text cannot
|
|
126
|
+
* be promoted past the verdict the upstream actually gave.
|
|
127
|
+
*
|
|
128
|
+
* Copy provenance: "limited access to this content for safety reasons" is the
|
|
129
|
+
* prefix Codex itself matches (tui/src/chatwidget/turn_runtime.rs:8-15) and
|
|
130
|
+
* pairs with `invalid_prompt` in its parser fixture (sse/responses.rs:1358-1366),
|
|
131
|
+
* which also pairs "flagged for possible biological risk" with `bio_policy`.
|
|
132
|
+
* "blocked by our safety systems" is the copy reported in #5176; pairing it
|
|
133
|
+
* with `invalid_prompt` is an inference from the two verified pairings above,
|
|
134
|
+
* not something the upstream source states.
|
|
135
|
+
*/
|
|
136
|
+
export function safetyRefusalCodeFromMessage(text: string): string | undefined {
|
|
137
|
+
const lower = String(text ?? "").toLowerCase();
|
|
138
|
+
if (lower.includes("flagged for possible biological risk")) return "bio_policy";
|
|
139
|
+
if (
|
|
140
|
+
lower.includes("blocked by our safety systems")
|
|
141
|
+
|| lower.includes("limited access to this content for safety reasons")
|
|
142
|
+
) return "invalid_prompt";
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
77
146
|
function isSubscriptionGateMessage(text: string): boolean {
|
|
78
147
|
return (
|
|
79
148
|
text.includes("requires a subscription") ||
|
|
@@ -187,6 +256,16 @@ export function isClientClosedMessage(text: string): boolean {
|
|
|
187
256
|
);
|
|
188
257
|
}
|
|
189
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Ambiguous-reset refusal wording owned by this proxy (src/lib/upstream-retry.ts):
|
|
261
|
+
* the upstream connection closed before any response arrived, so the request may
|
|
262
|
+
* already have been processed and automatic replay was stopped. Matched narrowly
|
|
263
|
+
* so a provider-sent message is never relabeled by it.
|
|
264
|
+
*/
|
|
265
|
+
export function isUpstreamResetReplayRefusedMessage(text: string): boolean {
|
|
266
|
+
return text.toLowerCase().includes("connection closed before a response was received");
|
|
267
|
+
}
|
|
268
|
+
|
|
190
269
|
export function classifyError(status: number, type: string, message: string): OcxErrorPayload {
|
|
191
270
|
const text = message.toLowerCase();
|
|
192
271
|
if (type === "previous_response_not_found") {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response rules both raw outbound transports have to apply themselves.
|
|
3
|
+
*
|
|
4
|
+
* `fetch` applies them below the Response constructor. `src/lib/pinned-http.ts` and
|
|
5
|
+
* `src/lib/socks5-fetch.ts` assemble a Response from a socket instead, so each one answers the
|
|
6
|
+
* same questions on its own — and a rule written twice is a rule that drifts. It already had:
|
|
7
|
+
* the SOCKS helper excluded 204 and the pinned helper excluded nothing, so the same no-content
|
|
8
|
+
* upstream answer behaved differently depending on which transport carried the request.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Statuses the Fetch specification defines as null-body.
|
|
13
|
+
*
|
|
14
|
+
* `new Response(body, { status })` throws a TypeError for a non-null body on any of these, so a
|
|
15
|
+
* transport that attaches its stream unconditionally converts a valid no-content answer into a
|
|
16
|
+
* construction failure. There are no body bytes to wait for either, so a transport that streams
|
|
17
|
+
* one of these holds the caller until the peer closes a connection it is entitled to keep alive.
|
|
18
|
+
*
|
|
19
|
+
* 101 and 103 are null-body statuses too, but neither is a final Response status these helpers
|
|
20
|
+
* support. 103 and every other informational head is consumed while looking for the final one,
|
|
21
|
+
* and 101 hands the connection to a protocol neither helper speaks, which is outside what they
|
|
22
|
+
* construct a Response for at all. The three below are the statuses these transports actually
|
|
23
|
+
* have to answer for.
|
|
24
|
+
*
|
|
25
|
+
* https://fetch.spec.whatwg.org/#null-body-status
|
|
26
|
+
*/
|
|
27
|
+
export function isNullBodyStatus(status: number): boolean {
|
|
28
|
+
return status === 204 || status === 205 || status === 304;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a transport must do with the coding a response declares. */
|
|
32
|
+
export type ContentCoding =
|
|
33
|
+
| { kind: "identity" }
|
|
34
|
+
| { kind: "decodable"; format: "gzip" | "deflate" }
|
|
35
|
+
| { kind: "unsupported"; coding: string };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Classify the `content-encoding` a raw transport received.
|
|
39
|
+
*
|
|
40
|
+
* `fetch` undoes a content-coding below the Response constructor. A transport that assembles a
|
|
41
|
+
* body from a socket hands the coded bytes to whatever reads them instead: `response.json()`
|
|
42
|
+
* throws a SyntaxError on the gzip magic number and an SSE reader sees noise rather than frames.
|
|
43
|
+
* Only `gzip` and `deflate` can be undone with `DecompressionStream`, so anything else is
|
|
44
|
+
* reported as unsupported and each transport refuses it under its own error type rather than
|
|
45
|
+
* surfacing bytes no caller can parse.
|
|
46
|
+
*
|
|
47
|
+
* This reads the coding the response actually carries and nothing else. A caller whose
|
|
48
|
+
* `accept-encoding` names a coding this code cannot undo is not the problem; what the peer
|
|
49
|
+
* chose to send is.
|
|
50
|
+
*/
|
|
51
|
+
export function classifyContentCoding(headers: Headers): ContentCoding {
|
|
52
|
+
const coding = (headers.get("content-encoding") ?? "").trim().toLowerCase();
|
|
53
|
+
if (coding === "" || coding === "identity") return { kind: "identity" };
|
|
54
|
+
if (coding === "gzip" || coding === "x-gzip") return { kind: "decodable", format: "gzip" };
|
|
55
|
+
if (coding === "deflate") return { kind: "decodable", format: "deflate" };
|
|
56
|
+
return { kind: "unsupported", coding };
|
|
57
|
+
}
|
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
import { PinnedHttpError, pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
|
|
1
|
+
import { PinnedHttpError, pinnedHttpGet, pinnedHttpPost, type PinnedHttpErrorCode } from "./pinned-http";
|
|
2
2
|
import { TransportError } from "../lab/live/transport";
|
|
3
|
-
import type { LabCredentialLeaseV1, LabPinnedSender } from "../lab/live/types";
|
|
3
|
+
import type { LabCredentialLeaseV1, LabPinnedSender, TransportErrorCode } from "../lab/live/types";
|
|
4
4
|
|
|
5
5
|
/** Only response metadata required by current live assertions crosses into Lab. */
|
|
6
6
|
const LAB_RESPONSE_HEADER_ALLOWLIST = ["content-type"] as const;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Every pinned-transport failure code, mapped to the Lab transport taxonomy or to nothing.
|
|
10
|
+
*
|
|
11
|
+
* A switch over the codes that existed when it was written silently let later ones fall through
|
|
12
|
+
* to a raw rethrow. That is not the same as leaving them unclassified: Lab's executor turns an
|
|
13
|
+
* unrecognized error into `harness_failure` / `execution_error`, so a response the peer coded
|
|
14
|
+
* in a format this transport cannot undo was reported as a fault of the runner. Both unreadable
|
|
15
|
+
* answers therefore carry `unreadable_response`, which Lab classifies as a protocol failure.
|
|
16
|
+
* A total map keeps a future code from acquiring that misattribution by default.
|
|
17
|
+
*/
|
|
18
|
+
const LAB_TRANSPORT_FAILURES = {
|
|
19
|
+
connect_timeout: { code: "connect_timeout", message: "pinned provider connection timed out" },
|
|
20
|
+
first_byte_timeout: { code: "first_byte_timeout", message: "pinned provider first byte timed out" },
|
|
21
|
+
inactivity_timeout: { code: "inactivity_timeout", message: "pinned provider response stalled" },
|
|
22
|
+
output_byte_limit: { code: "output_byte_limit", message: "pinned provider response exceeded byte budget" },
|
|
23
|
+
content_decode_failed: { code: "unreadable_response", message: "pinned provider response did not decode" },
|
|
24
|
+
unsupported_content_encoding: {
|
|
25
|
+
code: "unreadable_response",
|
|
26
|
+
message: "pinned provider used a content-encoding this transport cannot decode",
|
|
27
|
+
},
|
|
28
|
+
} satisfies Record<PinnedHttpErrorCode, { code: TransportErrorCode; message: string }>;
|
|
29
|
+
|
|
8
30
|
/**
|
|
9
31
|
* Trusted credential/transport owner. Secret headers exist only in this non-Lab module and are
|
|
10
32
|
* consumed directly by the pinned HTTP primitive; they are never returned to Lab code.
|
|
@@ -33,16 +55,8 @@ export function createLabAuthorizedPinnedSender(
|
|
|
33
55
|
body = await response.text();
|
|
34
56
|
} catch (error) {
|
|
35
57
|
if (error instanceof PinnedHttpError) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
throw new TransportError("connect_timeout", "pinned provider connection timed out");
|
|
39
|
-
case "first_byte_timeout":
|
|
40
|
-
throw new TransportError("first_byte_timeout", "pinned provider first byte timed out");
|
|
41
|
-
case "inactivity_timeout":
|
|
42
|
-
throw new TransportError("inactivity_timeout", "pinned provider response stalled");
|
|
43
|
-
case "output_byte_limit":
|
|
44
|
-
throw new TransportError("output_byte_limit", "pinned provider response exceeded byte budget");
|
|
45
|
-
}
|
|
58
|
+
const mapped = LAB_TRANSPORT_FAILURES[error.code];
|
|
59
|
+
throw new TransportError(mapped.code, mapped.message);
|
|
46
60
|
}
|
|
47
61
|
throw error;
|
|
48
62
|
}
|
package/src/lib/open-url.ts
CHANGED
|
@@ -8,8 +8,33 @@ function windowsRundll32(): string {
|
|
|
8
8
|
return existsSync(candidate) ? candidate : "rundll32";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Whether the OS launcher actually started. `started` does not prove a browser rendered the
|
|
13
|
+
* page — nothing observable from here can — but it does separate "we handed the URL off" from
|
|
14
|
+
* "there was nothing to hand it to", which is the distinction a caller needs (#5261).
|
|
15
|
+
*/
|
|
16
|
+
export type OpenUrlResult =
|
|
17
|
+
| { status: "started" }
|
|
18
|
+
| { status: "failed"; reason: "invalid-url" | "spawn-error" | "launcher-exit" };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long to watch a launcher that did spawn before calling it started.
|
|
22
|
+
*
|
|
23
|
+
* `spawn` only proves a process began. `xdg-open` with no desktop handler, and rundll32 given a
|
|
24
|
+
* broken association, both spawn happily and exit nonzero a moment later without opening
|
|
25
|
+
* anything — so resolving on `spawn` alone would report a launch that did not happen. Those
|
|
26
|
+
* failures are immediate, and the only caller that awaits this is a login start, so a short
|
|
27
|
+
* window buys a true answer cheaply. A launcher still running when it elapses has started.
|
|
28
|
+
*/
|
|
29
|
+
const LAUNCHER_SETTLE_MS = 400;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Never rejects. A browser that would not open is an inconvenience, not a login failure: the
|
|
33
|
+
* URL is still a valid thing to open by hand, so the caller decides what to say about it.
|
|
34
|
+
* Callers that genuinely do not care use `void openUrl(...)`.
|
|
35
|
+
*/
|
|
36
|
+
export function openUrl(url: string): Promise<OpenUrlResult> {
|
|
37
|
+
if (!/^https?:\/\//i.test(url)) return Promise.resolve({ status: "failed", reason: "invalid-url" });
|
|
13
38
|
const cmd =
|
|
14
39
|
process.platform === "darwin" ? "open"
|
|
15
40
|
: process.platform === "win32" ? windowsRundll32()
|
|
@@ -17,9 +42,28 @@ export function openUrl(url: string): void {
|
|
|
17
42
|
const args = process.platform === "win32"
|
|
18
43
|
? ["url.dll,FileProtocolHandler", url]
|
|
19
44
|
: [url];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
45
|
+
return new Promise<OpenUrlResult>(resolve => {
|
|
46
|
+
let settled = false;
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
const settle = (result: OpenUrlResult) => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
if (timer) clearTimeout(timer);
|
|
52
|
+
resolve(result);
|
|
53
|
+
};
|
|
54
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false });
|
|
55
|
+
// Headless hosts (no xdg-open) emit ENOENT as an async 'error' event; without a
|
|
56
|
+
// listener that is an uncaught exception that kills the whole proxy/login flow.
|
|
57
|
+
// It is also the signal itself: on Windows this is how a missing rundll32 arrives.
|
|
58
|
+
child.on("error", () => settle({ status: "failed", reason: "spawn-error" }));
|
|
59
|
+
child.on("spawn", () => {
|
|
60
|
+
// Unref'd either way: this never keeps the process alive, it only decides what to report.
|
|
61
|
+
timer = setTimeout(() => settle({ status: "started" }), LAUNCHER_SETTLE_MS);
|
|
62
|
+
timer.unref?.();
|
|
63
|
+
});
|
|
64
|
+
child.on("exit", code => settle(code === 0 || code === null
|
|
65
|
+
? { status: "started" }
|
|
66
|
+
: { status: "failed", reason: "launcher-exit" }));
|
|
67
|
+
child.unref();
|
|
68
|
+
});
|
|
25
69
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
|
+
import { isStandaloneBinary } from "./standalone";
|
|
2
3
|
|
|
3
4
|
export interface PackageTreeObservation {
|
|
4
5
|
readonly device: bigint;
|
|
@@ -96,6 +97,6 @@ export function createRuntimePackageTreeIntegrityGuard(
|
|
|
96
97
|
observe: ObservePackageTree = observePackageManifest,
|
|
97
98
|
now: () => number = Date.now,
|
|
98
99
|
): PackageTreeIntegrityGuard {
|
|
99
|
-
if (installer === "source") return { status: () => ({ ok: true }) };
|
|
100
|
+
if (installer === "source" || isStandaloneBinary()) return { status: () => ({ ok: true }) };
|
|
100
101
|
return createPackageTreeIntegrityGuard(observe, now);
|
|
101
102
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
2
|
+
|
|
3
|
+
type PackageManifest = { version?: unknown };
|
|
4
|
+
|
|
5
|
+
export function packageVersion(fallback = "unknown"): string {
|
|
6
|
+
const version = (pkg as PackageManifest).version;
|
|
7
|
+
return typeof version === "string" ? version : fallback;
|
|
8
|
+
}
|
package/src/lib/pinned-http.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import http, { type ClientRequest, type IncomingMessage, type RequestOptions } from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
|
+
import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics";
|
|
3
4
|
|
|
4
5
|
export type PinnedAddress = { address: string; family: number };
|
|
5
6
|
|
|
@@ -7,7 +8,9 @@ export type PinnedHttpErrorCode =
|
|
|
7
8
|
| "connect_timeout"
|
|
8
9
|
| "first_byte_timeout"
|
|
9
10
|
| "inactivity_timeout"
|
|
10
|
-
| "output_byte_limit"
|
|
11
|
+
| "output_byte_limit"
|
|
12
|
+
| "unsupported_content_encoding"
|
|
13
|
+
| "content_decode_failed";
|
|
11
14
|
|
|
12
15
|
export class PinnedHttpError extends Error {
|
|
13
16
|
override readonly name = "PinnedHttpError";
|
|
@@ -32,6 +35,99 @@ export interface PinnedHttpRequestOptions {
|
|
|
32
35
|
/** @deprecated Use {@link PinnedHttpRequestOptions}. */
|
|
33
36
|
export type PinnedHttpGetOptions = PinnedHttpRequestOptions;
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Undo the content-coding this transport has to undo itself, under the caller's byte ceiling.
|
|
40
|
+
*
|
|
41
|
+
* `maxBytes` keeps its existing meaning for the bytes that arrive on the socket, and gains the
|
|
42
|
+
* same meaning for the bytes the caller ends up reading. Bounding only the coded side would let
|
|
43
|
+
* a small compressed response expand past a ceiling the caller set precisely so it would not
|
|
44
|
+
* have to hold an unbounded body in memory.
|
|
45
|
+
*
|
|
46
|
+
* A completed body is not torn down here. The response already ended, so there is nothing to
|
|
47
|
+
* release, and destroying it would take a connection the agent is entitled to reuse. That
|
|
48
|
+
* matches the identity path, which also only closes. Teardown belongs to the paths that end a
|
|
49
|
+
* response early: a decode failure, an exceeded ceiling, and a caller that cancels.
|
|
50
|
+
*
|
|
51
|
+
* Only a decoder failure is renamed. A mid-body reset, a stalled response and an exceeded
|
|
52
|
+
* socket-byte ceiling all reach this pipeline as "the stream failed", and calling any of them a
|
|
53
|
+
* decode failure would tell the caller the peer sent unreadable bytes when the truth is that the
|
|
54
|
+
* connection died. The source failure is recorded as it passes so the original error survives;
|
|
55
|
+
* what is left after that is the decompressor's own, and that one is named because the caller's
|
|
56
|
+
* alternative is a bare TypeError from a stream it never constructed.
|
|
57
|
+
*/
|
|
58
|
+
function decodedBody(
|
|
59
|
+
source: ReadableStream<Uint8Array>,
|
|
60
|
+
format: "gzip" | "deflate",
|
|
61
|
+
maxBytes: number | undefined,
|
|
62
|
+
context: string,
|
|
63
|
+
release: () => void,
|
|
64
|
+
): ReadableStream<Uint8Array> {
|
|
65
|
+
// Interposed purely to attribute failures. Once bytes enter the decompressor, a transport
|
|
66
|
+
// error and a corrupt trailer are indistinguishable from the far side of the pipe.
|
|
67
|
+
let sourceFailure: { error: unknown } | undefined;
|
|
68
|
+
const sourceReader = source.getReader();
|
|
69
|
+
const attributed = new ReadableStream<Uint8Array>({
|
|
70
|
+
async pull(controller) {
|
|
71
|
+
try {
|
|
72
|
+
const next = await sourceReader.read();
|
|
73
|
+
if (next.done) {
|
|
74
|
+
controller.close();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
controller.enqueue(next.value);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
sourceFailure = { error };
|
|
80
|
+
controller.error(error);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
cancel(reason) {
|
|
84
|
+
return sourceReader.cancel(reason);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
// `DecompressionStream` declares its writable side as `WritableStream<BufferSource>`, and
|
|
88
|
+
// TypeScript measures `WritableStream` as invariant in its chunk type, so the pair is not
|
|
89
|
+
// assignable to `ReadableWritablePair<Uint8Array, Uint8Array>` even though every chunk this
|
|
90
|
+
// body produces is a valid `BufferSource`. The conversion states that relationship and
|
|
91
|
+
// nothing else; it does not widen what is actually written.
|
|
92
|
+
const decompressor = new DecompressionStream(format) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
|
|
93
|
+
const reader = attributed.pipeThrough(decompressor).getReader();
|
|
94
|
+
let decoded = 0;
|
|
95
|
+
return new ReadableStream<Uint8Array>({
|
|
96
|
+
async pull(controller) {
|
|
97
|
+
try {
|
|
98
|
+
const next = await reader.read();
|
|
99
|
+
if (next.done) {
|
|
100
|
+
controller.close();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
decoded += next.value.byteLength;
|
|
104
|
+
if (maxBytes !== undefined && decoded > maxBytes) {
|
|
105
|
+
throw new PinnedHttpError("output_byte_limit", `${context} exceeds ${maxBytes} byte cap`);
|
|
106
|
+
}
|
|
107
|
+
controller.enqueue(next.value);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// A failure the socket stream raised is the caller's answer, whatever shape it has.
|
|
110
|
+
// Only what the decompressor itself rejected is renamed.
|
|
111
|
+
const named = sourceFailure !== undefined
|
|
112
|
+
? sourceFailure.error
|
|
113
|
+
: error instanceof PinnedHttpError
|
|
114
|
+
? error
|
|
115
|
+
: new PinnedHttpError("content_decode_failed", `${context} could not decode its ${format} body`);
|
|
116
|
+
// Cancelling the decoded reader propagates back through the decompressor to the socket
|
|
117
|
+
// stream's own `cancel`, which destroys the request; `release` covers the case where
|
|
118
|
+
// that propagation is already finished.
|
|
119
|
+
await reader.cancel(named).catch(() => { /* already torn down */ });
|
|
120
|
+
controller.error(named);
|
|
121
|
+
release();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
async cancel(reason) {
|
|
125
|
+
await reader.cancel(reason).catch(() => { /* already torn down */ });
|
|
126
|
+
release();
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
35
131
|
function pinnedHttpRequest(
|
|
36
132
|
url: string,
|
|
37
133
|
pinned: PinnedAddress,
|
|
@@ -55,6 +151,10 @@ function pinnedHttpRequest(
|
|
|
55
151
|
const maxBytes = options?.maxBytes;
|
|
56
152
|
const headers = new Headers(options?.headers);
|
|
57
153
|
headers.set("host", parsed.host);
|
|
154
|
+
// This transport assembles the response itself, so a coding it did not ask for becomes its own
|
|
155
|
+
// problem to undo. Ask for none by default and leave an explicit caller choice alone, which is
|
|
156
|
+
// the same rule `src/lib/socks5-fetch.ts` applies to the other raw route.
|
|
157
|
+
if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
|
|
58
158
|
if (body !== undefined && !headers.has("content-length")) {
|
|
59
159
|
headers.set("content-length", String(Buffer.byteLength(body)));
|
|
60
160
|
}
|
|
@@ -150,6 +250,39 @@ function pinnedHttpRequest(
|
|
|
150
250
|
return;
|
|
151
251
|
}
|
|
152
252
|
|
|
253
|
+
// A success status can still be null-body. `new Response(stream, { status: 204 })` throws a
|
|
254
|
+
// TypeError, so attaching the body below would turn a correct no-content answer into a
|
|
255
|
+
// construction failure raised inside this event handler rather than a resolved response.
|
|
256
|
+
// Nothing is coming on the socket either, so streaming one of these would hold the caller
|
|
257
|
+
// until the peer closed a connection it is entitled to keep alive. The headers still
|
|
258
|
+
// describe the representation the peer would have sent and are preserved as they arrived.
|
|
259
|
+
if (isNullBodyStatus(status)) {
|
|
260
|
+
try { response.destroy(); } catch { /* ignore */ }
|
|
261
|
+
try { req?.destroy(); } catch { /* ignore */ }
|
|
262
|
+
if (settled) return;
|
|
263
|
+
settled = true;
|
|
264
|
+
resolve(new Response(null, { status, headers: responseHeaders }));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The peer may have coded the body whatever this request asked for. Classify before the
|
|
269
|
+
// stream takes the socket so a coding this transport cannot undo fails on the ordinary
|
|
270
|
+
// error path rather than reaching the caller as bytes its parser cannot read.
|
|
271
|
+
const coding = classifyContentCoding(responseHeaders);
|
|
272
|
+
if (coding.kind === "unsupported") {
|
|
273
|
+
try { response.destroy(); } catch { /* ignore */ }
|
|
274
|
+
fail(new PinnedHttpError(
|
|
275
|
+
"unsupported_content_encoding",
|
|
276
|
+
`${context} returned an unsupported content-encoding: ${coding.coding}`,
|
|
277
|
+
));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (coding.kind === "decodable") {
|
|
281
|
+
responseHeaders.delete("content-encoding");
|
|
282
|
+
// The declared length counted the coded bytes, not what the caller now reads.
|
|
283
|
+
responseHeaders.delete("content-length");
|
|
284
|
+
}
|
|
285
|
+
|
|
153
286
|
let received = 0;
|
|
154
287
|
const stream = new ReadableStream<Uint8Array>({
|
|
155
288
|
start(controller) {
|
|
@@ -191,7 +324,14 @@ function pinnedHttpRequest(
|
|
|
191
324
|
|
|
192
325
|
if (settled) return;
|
|
193
326
|
settled = true;
|
|
194
|
-
|
|
327
|
+
const release = () => {
|
|
328
|
+
try { response.destroy(); } catch { /* ignore */ }
|
|
329
|
+
try { req?.destroy(); } catch { /* ignore */ }
|
|
330
|
+
};
|
|
331
|
+
const payload = coding.kind === "decodable"
|
|
332
|
+
? decodedBody(stream, coding.format, maxBytes, context, release)
|
|
333
|
+
: stream;
|
|
334
|
+
resolve(new Response(payload, { status, headers: responseHeaders }));
|
|
195
335
|
};
|
|
196
336
|
|
|
197
337
|
const requestFn = parsed.protocol === "https:" ? https.request : http.request;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying a value the way a consumer that must not observe later edits needs it copied.
|
|
3
|
+
*
|
|
4
|
+
* Serializing with JSON is not a copier and using it as one is a quiet hole: it drops functions
|
|
5
|
+
* without saying so, and it invokes getters and toJSON, so the object being copied gets to decide
|
|
6
|
+
* what the copy contains. Every read here goes through a property descriptor instead, including
|
|
7
|
+
* array elements, which an exotic array can define as accessors just as an object can.
|
|
8
|
+
*
|
|
9
|
+
* The contract is narrow on purpose. This copies what JSON could have produced and refuses
|
|
10
|
+
* everything else rather than approximating it: an accessor, a cycle, a function, a class instance,
|
|
11
|
+
* a Map, a Date, a symbol value, a bigint, a non-finite number. A caller that is refused has to
|
|
12
|
+
* decide what to do about it, which is the point; a copier that silently degraded would leave the
|
|
13
|
+
* caller believing it held a snapshot of something it never read.
|
|
14
|
+
*
|
|
15
|
+
* Symbol-keyed properties are skipped rather than refused. Process bookkeeping rides on symbols by
|
|
16
|
+
* convention here, and none of it is the data a consumer is being given a copy of.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A copy, or a refusal. A union rather than a nullable value, because null is copyable data. */
|
|
20
|
+
export type PlainDataCopy<T> = { readonly ok: true; readonly value: T } | { readonly ok: false };
|
|
21
|
+
|
|
22
|
+
const REFUSED = Symbol("refused");
|
|
23
|
+
|
|
24
|
+
export function copyPlainData<T>(value: T): PlainDataCopy<T> {
|
|
25
|
+
const copied = copyValue(value, new Set());
|
|
26
|
+
return copied === REFUSED ? { ok: false } : { ok: true, value: copied as T };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A stable string for comparing two plain-data values.
|
|
31
|
+
*
|
|
32
|
+
* Key-sorted entry pairs rather than objects, because property order is observable through
|
|
33
|
+
* serialization and two values that differ only in it are the same value.
|
|
34
|
+
*/
|
|
35
|
+
export function canonicalPlainData(value: unknown): string {
|
|
36
|
+
return JSON.stringify(sorted(value));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Own enumerable string keys, with null in the position of any key that is an accessor. */
|
|
40
|
+
export function ownDataKeys(value: Record<string, unknown>): Array<string | null> {
|
|
41
|
+
return Object.keys(value).map(key => {
|
|
42
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
43
|
+
return descriptor !== undefined && descriptor.get === undefined && descriptor.set === undefined ? key : null;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
49
|
+
const prototype = Object.getPrototypeOf(value);
|
|
50
|
+
return prototype === Object.prototype || prototype === null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function copyValue(value: unknown, seen: Set<object>): unknown {
|
|
54
|
+
if (value === null) return null;
|
|
55
|
+
const type = typeof value;
|
|
56
|
+
if (type === "string" || type === "boolean") return value;
|
|
57
|
+
if (type === "number") return Number.isFinite(value as number) ? value : REFUSED;
|
|
58
|
+
if (type !== "object") return REFUSED;
|
|
59
|
+
const object = value as object;
|
|
60
|
+
if (seen.has(object)) return REFUSED;
|
|
61
|
+
seen.add(object);
|
|
62
|
+
try {
|
|
63
|
+
if (Array.isArray(object)) return copyArray(object, seen);
|
|
64
|
+
if (!isPlainObject(object)) return REFUSED;
|
|
65
|
+
const copied: Record<string, unknown> = {};
|
|
66
|
+
for (const key of ownDataKeys(object)) {
|
|
67
|
+
if (key === null) return REFUSED;
|
|
68
|
+
const entry = (object as Record<string, unknown>)[key];
|
|
69
|
+
if (entry === undefined) continue;
|
|
70
|
+
const item = copyValue(entry, seen);
|
|
71
|
+
if (item === REFUSED) return REFUSED;
|
|
72
|
+
copied[key] = item;
|
|
73
|
+
}
|
|
74
|
+
return copied;
|
|
75
|
+
} finally {
|
|
76
|
+
seen.delete(object);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function copyArray(source: unknown[], seen: Set<object>): unknown {
|
|
81
|
+
const copied: unknown[] = [];
|
|
82
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
83
|
+
const descriptor = Object.getOwnPropertyDescriptor(source, index);
|
|
84
|
+
// A hole is written as null, which is what a reader of the serialized value would find there.
|
|
85
|
+
if (descriptor === undefined) { copied.push(null); continue; }
|
|
86
|
+
if (descriptor.get !== undefined || descriptor.set !== undefined) return REFUSED;
|
|
87
|
+
if (descriptor.value === undefined) { copied.push(null); continue; }
|
|
88
|
+
const item = copyValue(descriptor.value, seen);
|
|
89
|
+
if (item === REFUSED) return REFUSED;
|
|
90
|
+
copied.push(item);
|
|
91
|
+
}
|
|
92
|
+
return copied;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sorted(value: unknown): unknown {
|
|
96
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
97
|
+
if (value !== null && typeof value === "object") {
|
|
98
|
+
return Object.keys(value as Record<string, unknown>)
|
|
99
|
+
.sort()
|
|
100
|
+
.map(key => [key, sorted((value as Record<string, unknown>)[key])]);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { isIP } from "node:net";
|
|
2
3
|
import { loadConfig } from "../config";
|
|
3
4
|
import { readRuntimePort } from "../config/process-state";
|
|
4
5
|
import { configuredAdminToken } from "./admin-secrets";
|
|
@@ -50,9 +51,11 @@ export interface GracefulStopIo {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
|
-
* Host to POST /api/stop against: follow the recorded bind hostname when it
|
|
54
|
-
*
|
|
55
|
-
*
|
|
54
|
+
* Host to POST /api/stop against: follow the recorded bind hostname only when it is
|
|
55
|
+
* an IP literal (a proxy bound to ::1 or a LAN IP is unreachable on 127.0.0.1).
|
|
56
|
+
* DNS names are not safe here because resolution may change between bind and stop,
|
|
57
|
+
* while this request carries the management token. Loopback aliases, DNS names, and
|
|
58
|
+
* wildcard binds therefore all fall back to IPv4 loopback.
|
|
56
59
|
*/
|
|
57
60
|
export function gracefulStopHost(hostname: string | undefined): string {
|
|
58
61
|
const trimmed = (hostname ?? "").trim();
|
|
@@ -61,8 +64,13 @@ export function gracefulStopHost(hostname: string | undefined): string {
|
|
|
61
64
|
return "127.0.0.1";
|
|
62
65
|
}
|
|
63
66
|
if (lower === "::1" || lower === "[::1]") return "[::1]";
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
const unbracketed = trimmed.startsWith("[") && trimmed.endsWith("]")
|
|
68
|
+
? trimmed.slice(1, -1)
|
|
69
|
+
: trimmed;
|
|
70
|
+
const ipVersion = isIP(unbracketed);
|
|
71
|
+
if (ipVersion === 6) return `[${unbracketed}]`;
|
|
72
|
+
if (ipVersion === 4) return unbracketed;
|
|
73
|
+
return "127.0.0.1";
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
/**
|