@bitkyc08/opencodex 2.86.0 → 2.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-CHC89EXQ.js +58 -0
- package/gui/dist/assets/App-wT3mXqFz.css +1 -0
- package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-B5bC7AWL.js} +1 -1
- package/gui/dist/assets/index-B5bsvxor.js +94 -0
- package/gui/dist/assets/index-DPeB9HpV.css +1 -0
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +9 -8
- package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
- package/src/adapters/anthropic/passthrough.ts +7 -23
- package/src/adapters/anthropic-progress.ts +84 -0
- package/src/adapters/anthropic-rejection-memo.ts +71 -0
- package/src/adapters/anthropic-replay.ts +135 -0
- package/src/adapters/anthropic-tool-catalog.ts +11 -0
- package/src/adapters/anthropic.ts +151 -33
- package/src/adapters/base.ts +2 -0
- package/src/adapters/claude-cli/adapter.ts +111 -23
- package/src/adapters/claude-cli/profiles.ts +2 -1
- package/src/adapters/claude-cli/stable-replay.ts +108 -0
- package/src/adapters/claude-cli/usage-admission.ts +230 -0
- package/src/adapters/coding-agent/protocol.ts +1 -1
- package/src/adapters/coding-agent/turn.ts +18 -2
- package/src/adapters/dsh-account.ts +97 -0
- package/src/adapters/google-progress-history.ts +53 -0
- package/src/adapters/google.ts +52 -9
- package/src/adapters/openai-chat/messages.ts +4 -3
- package/src/adapters/openai-chat/passthrough.ts +8 -2
- package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
- package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
- package/src/adapters/openai-responses/passthrough.ts +57 -125
- package/src/adapters/openai-responses/request-strips.ts +25 -3
- package/src/adapters/openai-responses/transport.ts +81 -0
- package/src/adapters/qoder/adapter.ts +4 -2
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/zed.ts +26 -1
- package/src/advisor/activity.ts +55 -0
- package/src/advisor/budget.ts +62 -0
- package/src/advisor/consult.ts +4 -1
- package/src/advisor/context.ts +16 -4
- package/src/advisor/evidence-tool.ts +21 -0
- package/src/advisor/evidence.ts +117 -0
- package/src/advisor/runtime.ts +79 -31
- package/src/advisor/settings.ts +11 -2
- package/src/bridge/errors.ts +19 -1
- package/src/bridge/internal.ts +7 -1
- package/src/bridge/response-json.ts +18 -3
- package/src/bridge/sse.ts +59 -8
- package/src/bridge/terminal-refusal.ts +59 -0
- package/src/claude/desktop-3p-library.ts +32 -7
- package/src/claude/desktop-3p.ts +55 -13
- package/src/claude/desktop-first-party.ts +1 -1
- package/src/claude/desktop-picker-platform.ts +4 -0
- package/src/claude/desktop-picker.ts +15 -8
- package/src/claude/desktop-system-proxy.ts +50 -4
- package/src/claude/inbound-content-options.ts +77 -0
- package/src/claude/inbound.ts +33 -0
- package/src/claude/intercept/picker-ca-startup.ts +2 -1
- package/src/claude/intercept/picker-runtime.ts +6 -5
- package/src/claude/intercept/picker-trust-windows.ts +338 -0
- package/src/claude/intercept/picker-trust.ts +19 -0
- package/src/claude/intercept/port-policy.ts +30 -0
- package/src/claude/intercept/runtime.ts +97 -94
- package/src/claude/outbound.ts +46 -13
- package/src/cli/account-api.ts +1 -0
- package/src/cli/account-policy-dto.ts +6 -0
- package/src/cli/account-policy.ts +34 -5
- package/src/cli/account-preservation.ts +44 -0
- package/src/cli/account.ts +8 -2
- package/src/cli/advisor.ts +17 -3
- package/src/cli/capabilities-accounts.ts +20 -1
- package/src/cli/capabilities-agents-routing.ts +5 -2
- package/src/cli/capabilities-base.ts +38 -10
- package/src/cli/capabilities-observe-system.ts +63 -3
- package/src/cli/capabilities-provider-models.ts +11 -0
- package/src/cli/codex-cli-update.ts +151 -41
- package/src/cli/codex-provider.ts +44 -0
- package/src/cli/combo-input.ts +10 -1
- package/src/cli/combo-result.ts +1 -1
- package/src/cli/combo.ts +2 -1
- package/src/cli/cross-home-owner.ts +33 -16
- package/src/cli/dispatch.ts +25 -4
- package/src/cli/doctor.ts +6 -1
- package/src/cli/ensure-desktop-cold-start.ts +60 -7
- package/src/cli/help.ts +3 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/integration-plan-dto.ts +1 -0
- package/src/cli/log-history.ts +172 -0
- package/src/cli/loopback-family.ts +31 -0
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +72 -8
- package/src/cli/observe-stream.ts +2 -0
- package/src/cli/observe.ts +16 -4
- package/src/cli/provider.ts +27 -5
- package/src/cli/registry.ts +28 -8
- package/src/cli/runtime-api.ts +5 -3
- package/src/cli/system-command.ts +40 -8
- package/src/cli/uninstall-client-state.ts +32 -0
- package/src/cli/usage-compare.ts +309 -0
- package/src/cli/usage-model-search.ts +4 -4
- package/src/cli/usage-report.ts +2 -2
- package/src/cli/v2.ts +1 -1
- package/src/client/machine-listener.ts +1 -3
- package/src/client/runtime.ts +1 -0
- package/src/clients/client-path.ts +51 -0
- package/src/clients/config-export/contracts.ts +2 -1
- package/src/clients/config-export.ts +18 -44
- package/src/clients/gentle-shell-path.ts +87 -0
- package/src/codex/account-auto-switch.ts +2 -0
- package/src/codex/account-identity.ts +29 -0
- package/src/codex/account-label.ts +1 -1
- package/src/codex/account-lifecycle.ts +21 -5
- package/src/codex/account-preservation.ts +28 -0
- package/src/codex/account-use-remaining.ts +23 -0
- package/src/codex/app-server-processes.ts +97 -7
- package/src/codex/app-server-restart-service.ts +55 -4
- package/src/codex/auth-api/account-list.ts +4 -0
- package/src/codex/auth-api/account-pause-group.ts +6 -50
- package/src/codex/auth-api/account-preservation.ts +57 -0
- package/src/codex/auth-api/routes.ts +8 -0
- package/src/codex/auth-api/use-remaining-route.ts +36 -0
- package/src/codex/auth-api.ts +0 -1
- package/src/codex/auth-context.ts +2 -2
- package/src/codex/catalog/auto-review.ts +20 -3
- package/src/codex/catalog/build-entries.ts +9 -1
- package/src/codex/catalog/combo-member.ts +14 -5
- package/src/codex/catalog/configured-tiers.ts +1 -1
- package/src/codex/catalog/gather-capture.ts +2 -0
- package/src/codex/catalog/metadata.ts +11 -9
- package/src/codex/catalog/model-hints.ts +21 -16
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/parsing.ts +30 -86
- package/src/codex/catalog/provider-models.ts +36 -34
- package/src/codex/catalog/routed-gather.ts +14 -13
- package/src/codex/cli-install-provenance.ts +105 -17
- package/src/codex/cli-update-plan.ts +545 -0
- package/src/codex/config-write-lock.ts +23 -2
- package/src/codex/desktop-app/picker-freshness.ts +96 -0
- package/src/codex/desktop-app-restart.ts +21 -0
- package/src/codex/desktop-compatibility/installed-build.ts +24 -6
- package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
- package/src/codex/desktop-compatibility/runtime.ts +12 -3
- package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
- package/src/codex/features.ts +85 -45
- package/src/codex/inject/history-failure.ts +27 -0
- package/src/codex/inject/restore.ts +24 -2
- package/src/codex/inject/routing-classify.ts +4 -3
- package/src/codex/inject.ts +18 -27
- package/src/codex/main-account-hard-lock.ts +30 -1
- package/src/codex/native-profile-startup.ts +1 -1
- package/src/codex/ocx-compaction-history.ts +12 -10
- package/src/codex/paths.ts +86 -1
- package/src/codex/provider-routing.ts +174 -0
- package/src/codex/refresh.ts +2 -2
- package/src/codex/routing/health-store.ts +1 -1
- package/src/codex/routing/selection.ts +4 -3
- package/src/codex/routing/thread-affinity.ts +1 -1
- package/src/codex/signed-in-identity.ts +43 -0
- package/src/codex/subagent-model-fallback.ts +1 -1
- package/src/codex/sync.ts +6 -1
- package/src/codex/user-identity.ts +16 -5
- package/src/codex/websocket-activity.ts +161 -0
- package/src/codex/websocket-registry.ts +25 -1
- package/src/codex/windows-local-appdata.ts +84 -0
- package/src/combos/failover.ts +6 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +46 -10
- package/src/combos/types.ts +15 -0
- package/src/config/diagnostics.ts +33 -6
- package/src/config/live-reconcile.ts +38 -2
- package/src/config/load-degrade.ts +17 -0
- package/src/config/opencode-wire-upgrade.ts +54 -0
- package/src/config/opencode-zen-upgrade.ts +53 -0
- package/src/config/owner-registry.ts +22 -17
- package/src/config/provider-validation.ts +45 -2
- package/src/config/schema/config-schema.ts +22 -0
- package/src/config/schema/leaf-validators.ts +38 -9
- package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
- package/src/config/schema/native-reasoning-retention.ts +26 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +657 -297
- package/src/grok/sync.ts +1 -1
- package/src/images/loop.ts +116 -27
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/mutation-plan.ts +1 -0
- package/src/integrations/registry.ts +8 -0
- package/src/lib/codex-restart-contract.ts +15 -0
- package/src/lib/desktop-upstream-tunnel.ts +18 -53
- package/src/lib/empty-tool-catalog.ts +62 -0
- package/src/lib/errors.ts +19 -3
- package/src/lib/gcp-adc.ts +96 -9
- package/src/lib/gcp-credential-input.ts +12 -0
- package/src/lib/lab-live-route-production.ts +5 -0
- package/src/lib/opaque-proxy-tunnel.ts +217 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/request-failure-model.ts +2 -0
- package/src/oauth/account-quota-rank.ts +69 -2
- package/src/oauth/anthropic-account-refusal.ts +20 -2
- package/src/oauth/anthropic-oauth-definitions.ts +3 -1
- package/src/oauth/anthropic-routing.ts +1 -21
- package/src/oauth/anthropic.ts +38 -7
- package/src/oauth/callback-server.ts +27 -1
- package/src/oauth/chatgpt-device.ts +1 -1
- package/src/oauth/chatgpt-shared.ts +83 -0
- package/src/oauth/chatgpt.ts +8 -72
- package/src/oauth/dsh.ts +203 -0
- package/src/oauth/generic-account-failover.ts +40 -11
- package/src/oauth/generic-session-affinity.ts +125 -0
- package/src/oauth/index.ts +184 -109
- package/src/oauth/local-token-detect.ts +21 -1
- package/src/oauth/login-cli-errors.ts +44 -0
- package/src/oauth/login-cli.ts +69 -7
- package/src/oauth/login-flow-state.ts +371 -23
- package/src/oauth/meta-muse-errors.ts +41 -0
- package/src/oauth/meta-muse-keychain.ts +52 -0
- package/src/oauth/meta-muse.ts +51 -84
- package/src/oauth/model-discovery-auth.ts +3 -0
- package/src/oauth/pool-settings-capability.ts +4 -2
- package/src/oauth/refresh-flight-retirement.ts +48 -0
- package/src/oauth/session-affinity-key.ts +23 -0
- package/src/oauth/store-anthropic-instance.ts +9 -2
- package/src/oauth/store.ts +12 -5
- package/src/protocols/encoders/adapter-events.ts +6 -5
- package/src/protocols/encoders/messages.ts +9 -6
- package/src/protocols/provider-summary.ts +2 -1
- package/src/providers/api-key-cooldowns.ts +49 -0
- package/src/providers/api-key-resolve.ts +11 -6
- package/src/providers/api-key-response-ownership.ts +57 -0
- package/src/providers/api-key-selection.ts +31 -6
- package/src/providers/api-keys.ts +214 -3
- package/src/providers/claude-cli-usage.ts +96 -0
- package/src/providers/dsh-account-destination.ts +31 -0
- package/src/providers/dsh-account-models.ts +41 -0
- package/src/providers/gcp-credential-cleanup.ts +59 -0
- package/src/providers/key-failover.ts +54 -50
- package/src/providers/key-store.ts +7 -2
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/model-rename-migration.ts +2 -2
- package/src/providers/openai-tiers-destination.ts +11 -2
- package/src/providers/openai-tiers.ts +5 -12
- package/src/providers/opencode-go-transport.ts +3 -1
- package/src/providers/opencode-zen-rate-limit.ts +7 -2
- package/src/providers/quota/account-cache.ts +6 -5
- package/src/providers/quota/account-row.ts +34 -0
- package/src/providers/quota/antigravity.ts +28 -7
- package/src/providers/quota/dsh-account.ts +70 -0
- package/src/providers/quota/vendor-probes-oauth.ts +1 -93
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +14 -20
- package/src/providers/registry/entries-core.ts +37 -8
- package/src/providers/registry/types.ts +1 -1
- package/src/providers/request-pacing.ts +77 -19
- package/src/providers/routed-config.ts +339 -0
- package/src/providers/service-tier.ts +2 -1
- package/src/quota/window-mapping.ts +5 -2
- package/src/responses/anthropic-progress.ts +34 -0
- package/src/responses/assistant-replay.ts +35 -0
- package/src/responses/compaction-history-framing.ts +65 -0
- package/src/responses/compaction.ts +6 -1
- package/src/responses/incomplete-reason.ts +31 -0
- package/src/responses/parser.ts +50 -5
- package/src/responses/reasoning-envelope.ts +4 -0
- package/src/responses/reasoning-replay-cache.ts +74 -6
- package/src/responses/reasoning-retention.ts +173 -0
- package/src/responses/retained-compaction.ts +46 -0
- package/src/responses/schema.ts +23 -0
- package/src/router.ts +8 -321
- package/src/routing/analytics.ts +7 -15
- package/src/routing/health.ts +37 -22
- package/src/routing/history/indexer.ts +6 -5
- package/src/routing/history/ingest.ts +6 -2
- package/src/routing/profile.ts +1 -1
- package/src/server/admission-model-scope.ts +17 -0
- package/src/server/auth-cors.ts +11 -0
- package/src/server/chat-completions.ts +7 -1
- package/src/server/chat-native-reasoning.ts +123 -0
- package/src/server/chat-native-sse.ts +110 -7
- package/src/server/chat-native.ts +76 -17
- package/src/server/claude-messages.ts +56 -414
- package/src/server/effort-policy.ts +1 -1
- package/src/server/images.ts +15 -2
- package/src/server/index/live-sideband.ts +3 -2
- package/src/server/index/model-alias-id.ts +36 -0
- package/src/server/index/optional-listeners.ts +2 -0
- package/src/server/index/serve-options.ts +20 -6
- package/src/server/index/startup-warnings.ts +2 -0
- package/src/server/index/websocket-handler.ts +18 -4
- package/src/server/index.ts +8 -8
- package/src/server/inference/advisor-diagnostics.ts +31 -0
- package/src/server/lifecycle.ts +13 -1
- package/src/server/management/advisor-routes.ts +15 -4
- package/src/server/management/claude-desktop-picker-routes.ts +1 -1
- package/src/server/management/combo-routes.ts +176 -50
- package/src/server/management/companion-routes.ts +1 -1
- package/src/server/management/config-routes.ts +75 -4
- package/src/server/management/cursor-integration-routes.ts +1 -1
- package/src/server/management/model-routes.ts +11 -6
- package/src/server/management/model-rows.ts +14 -0
- package/src/server/management/native-integration-routes.ts +2 -7
- package/src/server/management/native-reasoning-retention-routes.ts +41 -0
- package/src/server/management/oauth-account-routes.ts +87 -14
- package/src/server/management/provider-gcp-credential-routes.ts +29 -0
- package/src/server/management/provider-key-pacing-routes.ts +31 -0
- package/src/server/management/provider-overwrite-carry.ts +7 -0
- package/src/server/management/provider-routes.ts +25 -30
- package/src/server/management/route-registry.ts +10 -1
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native-tool-guard.ts +185 -0
- package/src/server/messages-native.ts +117 -24
- package/src/server/messages-shared.ts +431 -0
- package/src/server/relay.ts +1 -1
- package/src/server/request-log-terminal-status.ts +1 -1
- package/src/server/request-log.ts +6 -1
- package/src/server/responses/account-change-state.ts +8 -2
- package/src/server/responses/adapter-continuation.ts +47 -19
- package/src/server/responses/adapter-delivery.ts +72 -12
- package/src/server/responses/adapter-dispatch.ts +46 -3
- package/src/server/responses/advisor-retention.ts +117 -0
- package/src/server/responses/advisor-slot.ts +73 -41
- package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
- package/src/server/responses/codex-ws-wire.ts +15 -8
- package/src/server/responses/combo-stream-preflight.ts +3 -2
- package/src/server/responses/compact.ts +303 -125
- package/src/server/responses/compaction-recovery.ts +20 -10
- package/src/server/responses/compaction-retention.ts +61 -0
- package/src/server/responses/context-overflow.ts +11 -6
- package/src/server/responses/core-auth.ts +6 -0
- package/src/server/responses/core-codex-account.ts +11 -1
- package/src/server/responses/core-combo-failure.ts +2 -0
- package/src/server/responses/core-combo.ts +121 -36
- package/src/server/responses/core-errors.ts +3 -3
- package/src/server/responses/core-lifetime.ts +8 -4
- package/src/server/responses/core-opaque-recovery.ts +35 -0
- package/src/server/responses/core-options.ts +7 -0
- package/src/server/responses/core-pipeline.ts +141 -0
- package/src/server/responses/core-replay.ts +23 -3
- package/src/server/responses/core.ts +2 -115
- package/src/server/responses/fetch-helpers.ts +73 -19
- package/src/server/responses/main-hard-lock-routing.ts +109 -0
- package/src/server/responses/native-response-control.ts +4 -2
- package/src/server/responses/passthrough-delivery.ts +3 -1
- package/src/server/responses/passthrough-dispatch.ts +15 -13
- package/src/server/responses/passthrough-execution.ts +3 -2
- package/src/server/responses/request-prepare.ts +77 -12
- package/src/server/responses/request-sidecar-auth.ts +2 -2
- package/src/server/responses/request-transport.ts +104 -27
- package/src/server/responses/response-effects.ts +2 -0
- package/src/server/responses/response-tool-ownership.ts +21 -0
- package/src/server/responses/run-turn-execution.ts +96 -30
- package/src/server/responses/sidecar-execution.ts +63 -9
- package/src/server/responses/sidecar-send-budget.ts +2 -2
- package/src/server/responses/terminal-guard.ts +2 -16
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/ws-bridge.ts +2 -0
- package/src/service/auth-preflight.ts +71 -0
- package/src/service/cli.ts +4 -2
- package/src/service/definition.ts +97 -0
- package/src/service/guards.ts +1 -61
- package/src/service/health-sentinel-linux.ts +112 -0
- package/src/service/health-sentinel-main.ts +76 -57
- package/src/service/health.ts +5 -89
- package/src/service/launchd.ts +27 -7
- package/src/service/logging.ts +72 -9
- package/src/service/repair.ts +2 -1
- package/src/service/state.ts +7 -8
- package/src/service/systemd.ts +1 -1
- package/src/service/windows-guarded-manager.ts +2 -1
- package/src/service/windows-ops.ts +1 -1
- package/src/service/windows-scheduler.ts +2 -7
- package/src/service/windows-taskxml.ts +2 -2
- package/src/service/windows-wscript.ts +14 -0
- package/src/service.ts +4 -2
- package/src/sidecar/auth.ts +2 -0
- package/src/types/config.ts +64 -3
- package/src/types/opencode-go.ts +61 -0
- package/src/types/opencode-zen.ts +35 -0
- package/src/types/provider.ts +32 -6
- package/src/types/request.ts +31 -2
- package/src/types/wire.ts +31 -37
- package/src/types.ts +2 -0
- package/src/update/npm-invocation.mjs +12 -2
- package/src/usage/cost.ts +48 -12
- package/src/usage/expected-prices.ts +3 -0
- package/src/usage/ledger-retention.ts +2 -1
- package/src/usage/ledger-scanner.ts +6 -5
- package/src/usage/log.ts +17 -8
- package/src/usage/summary.ts +30 -11
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/usage/user-cost-overlays.ts +14 -19
- package/src/usage/user-cost-validation.ts +38 -0
- package/src/vision/eligibility.ts +13 -0
- package/src/web-search/anthropic-history.ts +59 -0
- package/src/web-search/loop.ts +236 -73
- package/src/web-search/progress-stream.ts +15 -2
- package/src/web-search/run-turn-loop.ts +9 -1
- package/gui/dist/assets/App-BjlttVLy.js +0 -58
- package/gui/dist/assets/App-CSJvmjNs.css +0 -1
- package/gui/dist/assets/index-BB6uK6tU.css +0 -1
- package/gui/dist/assets/index-BMssP4k3.js +0 -94
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
|
@@ -76,6 +76,83 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined {
|
|
|
76
76
|
return out.length > 0 ? out : undefined;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The caller's typed Anthropic web tools (`web_search_*`, `web_fetch_*`), as declared. The
|
|
81
|
+
* Responses body has no slot for them — web_search becomes the hosted sidecar tool above and
|
|
82
|
+
* web_fetch is dropped — so an Anthropic destination receives them out of body, unchanged.
|
|
83
|
+
*/
|
|
84
|
+
export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined {
|
|
85
|
+
if (!isRec(raw) || !Array.isArray(raw.tools)) return undefined;
|
|
86
|
+
const tools = raw.tools.filter((t): t is Rec => isRec(t) && typeof t.type === "string" && /^web_(search|fetch)_\d+$/.test(t.type));
|
|
87
|
+
return tools.length > 0 ? tools : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Validate original call/result order without inventing inputs or pairing a later call backwards. */
|
|
91
|
+
function replayableServerRun(run: readonly Rec[]): boolean {
|
|
92
|
+
const pending = new Map<string, Rec>();
|
|
93
|
+
const completed = new Set<string>();
|
|
94
|
+
for (const block of run) {
|
|
95
|
+
if (block.type === "server_tool_use") {
|
|
96
|
+
const { id, name, input } = block;
|
|
97
|
+
if (typeof id !== "string" || !id.startsWith("srvtoolu_") || pending.has(id) || completed.has(id)
|
|
98
|
+
|| (name !== "web_search" && name !== "web_fetch") || !isRec(input)) return false;
|
|
99
|
+
const prototype = Object.getPrototypeOf(input);
|
|
100
|
+
const required = input[name === "web_search" ? "query" : "url"];
|
|
101
|
+
if ((prototype !== Object.prototype && prototype !== null)
|
|
102
|
+
|| typeof required !== "string" || required.trim().length === 0) return false;
|
|
103
|
+
pending.set(id, block);
|
|
104
|
+
} else {
|
|
105
|
+
const id = block.tool_use_id;
|
|
106
|
+
if (typeof id !== "string") return false;
|
|
107
|
+
const call = pending.get(id);
|
|
108
|
+
if (!call || block.type !== `${call.name}_tool_result`
|
|
109
|
+
|| (Array.isArray(block.content) && !block.content.every(h => isRec(h) && typeof h.encrypted_content === "string"))) return false;
|
|
110
|
+
pending.delete(id);
|
|
111
|
+
completed.add(id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return completed.size > 0 && pending.size === 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Completed Anthropic server-tool exchanges (`server_tool_use` + `web_search_tool_result` /
|
|
119
|
+
* `web_fetch_tool_result`) in the caller's assistant history. The Responses body drops them, so
|
|
120
|
+
* an Anthropic destination re-inserts them out of body, keyed by the block that follows each run
|
|
121
|
+
* in its message: `call:<tool_use id>`, `text:<text>`, `thinking:<signature>` or
|
|
122
|
+
* `redacted:<data>`, all of which survive translation. An anchor that occurs more than once in the
|
|
123
|
+
* assistant history (string content included) cannot name one owner, so its run stays dropped,
|
|
124
|
+
* as it was before replay existed. A run with none, an unpaired call, a
|
|
125
|
+
* non-Anthropic id, or a search hit without `encrypted_content` is not replayable and stays dropped.
|
|
126
|
+
*/
|
|
127
|
+
export function replayedAnthropicServerBlocks(raw: unknown): Map<string, Rec[]> | undefined {
|
|
128
|
+
if (!isRec(raw) || !Array.isArray(raw.messages)) return undefined;
|
|
129
|
+
const out = new Map<string, Rec[]>();
|
|
130
|
+
const seen = new Map<string, number>();
|
|
131
|
+
const count = (anchor: string) => seen.set(anchor, (seen.get(anchor) ?? 0) + 1);
|
|
132
|
+
for (const message of raw.messages) {
|
|
133
|
+
if (!isRec(message) || message.role !== "assistant") continue;
|
|
134
|
+
if (typeof message.content === "string") count(`text:${message.content}`);
|
|
135
|
+
if (!Array.isArray(message.content)) continue;
|
|
136
|
+
let run: Rec[] = [];
|
|
137
|
+
for (const block of message.content.filter(isRec)) {
|
|
138
|
+
if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") {
|
|
139
|
+
// The caller's breakpoints are dropped on this lane, as for the declared tools above.
|
|
140
|
+
run.push({ ...block, cache_control: undefined });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const anchor = block.type === "tool_use" ? `call:${String(block.id)}`
|
|
144
|
+
: block.type === "text" ? `text:${String(block.text)}`
|
|
145
|
+
: block.type === "thinking" ? `thinking:${String(block.signature)}`
|
|
146
|
+
: block.type === "redacted_thinking" ? `redacted:${String(block.data)}` : undefined;
|
|
147
|
+
if (anchor) count(anchor);
|
|
148
|
+
if (anchor && replayableServerRun(run)) out.set(anchor, run);
|
|
149
|
+
run = [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const anchor of out.keys()) if ((seen.get(anchor) ?? 0) > 1) out.delete(anchor);
|
|
153
|
+
return out.size > 0 ? out : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
79
156
|
export function toolChoiceToResponses(choice: unknown, body: Rec): void {
|
|
80
157
|
if (!isRec(choice)) return;
|
|
81
158
|
if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false;
|
package/src/claude/inbound.ts
CHANGED
|
@@ -387,6 +387,39 @@ function canonicalJson(value: unknown): string {
|
|
|
387
387
|
/** Provenance of the generated prompt_cache_key (never serialized into the wire body). */
|
|
388
388
|
export type ClaudeCacheKeySource = "metadata" | "system" | null;
|
|
389
389
|
|
|
390
|
+
/**
|
|
391
|
+
* The translated lane places its own prompt-cache breakpoints, so the caller's are dropped;
|
|
392
|
+
* their TTL is the one thing worth keeping. When every caller breakpoint asks for `ttl: "1h"`
|
|
393
|
+
* (Claude Code on a subscription marks them so) this maps to `cacheRetention: "long"`. A mixed
|
|
394
|
+
* caller (1h prefix, 5m tail) stays on the default: "long" also makes the moving per-turn
|
|
395
|
+
* breakpoint 1h, which bills writes the caller asked to keep at the 5m rate at the 1h rate.
|
|
396
|
+
* Read out of body: the Responses body has no field for it.
|
|
397
|
+
*/
|
|
398
|
+
export function callerCacheRetention(raw: unknown): "long" | undefined {
|
|
399
|
+
if (!isRec(raw)) return undefined;
|
|
400
|
+
// Top-level automatic caching is a breakpoint too, and content blocks nest (tool_result,
|
|
401
|
+
// search_result, document sources), so every block container is walked. Tool inputs and
|
|
402
|
+
// schemas are caller data, not blocks: a `cache_control` key there is not a breakpoint.
|
|
403
|
+
const ttls: unknown[] = [];
|
|
404
|
+
const visit = (node: unknown, depth: number): void => {
|
|
405
|
+
if (depth > 16) return;
|
|
406
|
+
if (Array.isArray(node)) { for (const item of node) visit(item, depth + 1); return; }
|
|
407
|
+
if (!isRec(node)) return;
|
|
408
|
+
if (isRec(node.cache_control)) ttls.push(node.cache_control.ttl);
|
|
409
|
+
for (const [key, value] of Object.entries(node)) {
|
|
410
|
+
if (key !== "cache_control" && key !== "input" && key !== "input_schema"
|
|
411
|
+
&& (Array.isArray(value) || isRec(value))) visit(value, depth + 1);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
if (isRec(raw.cache_control)) ttls.push(raw.cache_control.ttl);
|
|
415
|
+
visit(raw.system, 0);
|
|
416
|
+
visit(raw.tools, 0);
|
|
417
|
+
visit(raw.messages, 0);
|
|
418
|
+
return ttls.length > 0 && ttls.every(ttl => ttl === "1h")
|
|
419
|
+
? "long"
|
|
420
|
+
: undefined;
|
|
421
|
+
}
|
|
422
|
+
|
|
390
423
|
export interface ClaudeInboundTranslation {
|
|
391
424
|
body: Rec;
|
|
392
425
|
cacheKeySource: ClaudeCacheKeySource;
|
|
@@ -2,12 +2,13 @@ import { ensurePickerCa, type PickerCa } from "./picker-ca";
|
|
|
2
2
|
import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup";
|
|
3
3
|
import type { PickerCaStore } from "./picker-ca-store";
|
|
4
4
|
import type { SecurityRunner } from "./picker-trust";
|
|
5
|
+
import { supportsDesktopPicker } from "../desktop-picker-platform";
|
|
5
6
|
|
|
6
7
|
/** Internal activation seam: finish legacy cleanup before a persistent issuer is usable. */
|
|
7
8
|
export async function preparePersistentPickerAuthority(options: {
|
|
8
9
|
configDir: string; store?: PickerCaStore; security?: SecurityRunner; platform: NodeJS.Platform;
|
|
9
10
|
}): Promise<PickerCa> {
|
|
10
|
-
if (options.platform
|
|
11
|
+
if (!supportsDesktopPicker(options.platform)) throw new Error("picker_ca_unsupported_platform");
|
|
11
12
|
const drain = () => drainPendingPickerCaUntrust(options.configDir, options.security, options.platform);
|
|
12
13
|
if (!await drain()) throw new Error("picker_ca_pending_untrust");
|
|
13
14
|
const ca = ensurePickerCa(options.configDir, { rotation: "startup", persistent: true, store: options.store });
|
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
* `intercept`. Everything else, and claude.ai whenever any condition fails, is a blind tunnel.
|
|
8
8
|
*
|
|
9
9
|
* The decision is cached by `refresh()` and only read by `selectTunnel`, so a CONNECT never
|
|
10
|
-
* waits on the
|
|
10
|
+
* waits on the OS trust store or the catalog. Arming needs all of: a supported platform, the persisted resolved mode is
|
|
11
11
|
* first-party (observation-aware, so a pre-field first-party install counts), Desktop intent on,
|
|
12
12
|
* `claudeCode.intercept.picker !== false`, the disarm latch clear, the picker listener up and the
|
|
13
|
-
* current picker CA trusted in the
|
|
13
|
+
* current picker CA trusted in the OS root store. `disarm()` stops terminating immediately and
|
|
14
14
|
* latches; only the picker controller's owner-only `rearm()` clears the latch.
|
|
15
15
|
*/
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import type { OcxConfig } from "../../types";
|
|
18
18
|
import type { ClaudeDesktopMode } from "../desktop-first-party";
|
|
19
|
+
import { supportsDesktopPicker } from "../desktop-picker-platform";
|
|
19
20
|
import type { TunnelDecision } from "./connect-proxy";
|
|
20
21
|
import type { PemKeyPair } from "./local-ca";
|
|
21
22
|
import { PICKER_HOST, ensurePickerCa, issuePickerLeaf, pickerCaFingerprints, pickerLeafCertPath, pickerStateDir, type PickerCa } from "./picker-ca";
|
|
@@ -108,7 +109,7 @@ export function pickerDesired(
|
|
|
108
109
|
mode: ClaudeDesktopMode,
|
|
109
110
|
platform: NodeJS.Platform = process.platform,
|
|
110
111
|
): boolean {
|
|
111
|
-
return platform
|
|
112
|
+
return supportsDesktopPicker(platform)
|
|
112
113
|
&& config.clientIntegrations?.["claude-desktop"] !== false
|
|
113
114
|
&& mode === "first-party"
|
|
114
115
|
&& config.claudeCode?.intercept?.picker !== false;
|
|
@@ -225,7 +226,7 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
|
|
|
225
226
|
desired = pickerDesired(fresh, mode, platform);
|
|
226
227
|
if (!desired || latched) {
|
|
227
228
|
armed = false;
|
|
228
|
-
reason = platform
|
|
229
|
+
reason = !supportsDesktopPicker(platform) ? "unsupported_platform" : latched ? "disarmed" : "not_desired";
|
|
229
230
|
closeListener();
|
|
230
231
|
return;
|
|
231
232
|
}
|
|
@@ -312,7 +313,7 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
|
|
|
312
313
|
const current = snapshot?.current() ?? null;
|
|
313
314
|
return {
|
|
314
315
|
desired,
|
|
315
|
-
supported: platform
|
|
316
|
+
supported: supportsDesktopPicker(platform),
|
|
316
317
|
trust,
|
|
317
318
|
listenerReady: listener !== null,
|
|
318
319
|
effective: armed && listener !== null && !latched && !stopped,
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { X509Certificate } from "node:crypto";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation";
|
|
6
|
+
import {
|
|
7
|
+
acceptsPickerAuthority,
|
|
8
|
+
PICKER_CA_COMMON_NAME,
|
|
9
|
+
PICKER_HOST,
|
|
10
|
+
pickerCaFingerprints,
|
|
11
|
+
} from "./picker-ca";
|
|
12
|
+
import type { PickerTrustState, SecurityResult } from "./picker-trust";
|
|
13
|
+
|
|
14
|
+
/** powershell.exe argv result; alias so the two trust seams never drift apart. */
|
|
15
|
+
export type WindowsTrustResult = SecurityResult;
|
|
16
|
+
/** Per-script options; fakes may ignore them, so single-arg fakes stay portable. */
|
|
17
|
+
export interface WindowsTrustRunnerOptions { timeoutMs?: number }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* powershell.exe argv runner. A SecurityRunner satisfies this shape so macOS-style
|
|
21
|
+
* fakes stay portable. The caller in picker-trust.ts only forwards an explicitly
|
|
22
|
+
* injected runner and otherwise leaves this undefined for the Windows default.
|
|
23
|
+
*/
|
|
24
|
+
export type WindowsTrustRunner = (
|
|
25
|
+
args: readonly string[],
|
|
26
|
+
options?: WindowsTrustRunnerOptions,
|
|
27
|
+
) => Promise<WindowsTrustResult>;
|
|
28
|
+
|
|
29
|
+
/** Read-only argv prefix: no profile, no logo, no interaction. Used for inspection. */
|
|
30
|
+
export const WINDOWS_TRUST_POWERSHELL_PREFIX = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"] as const;
|
|
31
|
+
/** Mutating argv prefix: the OS confirmation dialog must be able to appear. */
|
|
32
|
+
export const WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX = ["-NoLogo", "-NoProfile", "-Command"] as const;
|
|
33
|
+
|
|
34
|
+
/** Read-only inspection budget: a hung store lookup must never stall status checks. */
|
|
35
|
+
export const WINDOWS_TRUST_INSPECT_TIMEOUT_MS = 10_000;
|
|
36
|
+
/** Interactive import budget: Root installs raise the OS confirmation dialog. */
|
|
37
|
+
export const WINDOWS_TRUST_IMPORT_TIMEOUT_MS = 120_000;
|
|
38
|
+
/**
|
|
39
|
+
* Removal budget stays bounded: predecessor cleanup also runs in headless migration
|
|
40
|
+
* and startup paths where an OS confirmation dialog cannot be answered. A timed-out or
|
|
41
|
+
* declined removal reports failure and is retried on the next explicit activation.
|
|
42
|
+
*/
|
|
43
|
+
export const WINDOWS_TRUST_UNTRUST_TIMEOUT_MS = 30_000;
|
|
44
|
+
|
|
45
|
+
const OUTPUT_LIMIT = 64 * 1024;
|
|
46
|
+
const TRUST_MARKER = "OCX_PICKER_TRUST=";
|
|
47
|
+
const SHA1_PATTERN = /^[0-9A-F]{40}$/;
|
|
48
|
+
const BASE64_PATTERN = /^[A-Za-z0-9+/=]*$/;
|
|
49
|
+
|
|
50
|
+
/** Uppercase hex without separators, or null. Trust matches on this, never on the CN. */
|
|
51
|
+
export function normalizePickerTrustSha1(value: string): string | null {
|
|
52
|
+
const normalized = value.replace(/[:\s]/g, "").toUpperCase();
|
|
53
|
+
return SHA1_PATTERN.test(normalized) ? normalized : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Copy the environment minus every PSModulePath entry (case-insensitive).
|
|
58
|
+
* The trust scripts are .NET-only so module autoload is pure cost and shadowing
|
|
59
|
+
* risk: a PSModulePath entry can shadow resolution and stall process start on
|
|
60
|
+
* hosts with a cold module cache. Nothing here needs a module.
|
|
61
|
+
*/
|
|
62
|
+
export function sanitizeWindowsPowerShellEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string | undefined> {
|
|
63
|
+
const out: Record<string, string | undefined> = {};
|
|
64
|
+
for (const [key, value] of Object.entries(env)) {
|
|
65
|
+
if (key.toLowerCase() === "psmodulepath") continue;
|
|
66
|
+
out[key] = value;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Production runner: trusted System32 powershell.exe, hidden window, sanitized
|
|
73
|
+
* module path, bounded runtime. Timeout and output-read errors kill the child and report code null
|
|
74
|
+
* so callers map it to unknown/declined instead of hanging periodic status.
|
|
75
|
+
*/
|
|
76
|
+
export const defaultWindowsPowerShellRunner: WindowsTrustRunner = async (args, options) => {
|
|
77
|
+
const timeoutMs = options?.timeoutMs ?? WINDOWS_TRUST_INSPECT_TIMEOUT_MS;
|
|
78
|
+
let exe: string;
|
|
79
|
+
try {
|
|
80
|
+
exe = resolveTrustedWindowsPowerShellExe();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return { code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
83
|
+
}
|
|
84
|
+
let child: { kill(): void } | undefined;
|
|
85
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
86
|
+
try {
|
|
87
|
+
const proc = Bun.spawn([exe, ...args], {
|
|
88
|
+
stdin: "ignore",
|
|
89
|
+
stdout: "pipe",
|
|
90
|
+
stderr: "pipe",
|
|
91
|
+
windowsHide: true,
|
|
92
|
+
env: sanitizeWindowsPowerShellEnv(),
|
|
93
|
+
});
|
|
94
|
+
child = proc;
|
|
95
|
+
const pending = (async () => {
|
|
96
|
+
const [out, err, code] = await Promise.all([
|
|
97
|
+
new Response(proc.stdout).text(),
|
|
98
|
+
new Response(proc.stderr).text(),
|
|
99
|
+
proc.exited,
|
|
100
|
+
]);
|
|
101
|
+
return { out, err, code };
|
|
102
|
+
})();
|
|
103
|
+
const settled = await new Promise<{ out: string; err: string; code: number } | null>((resolve, reject) => {
|
|
104
|
+
timer = setTimeout(() => {
|
|
105
|
+
try { child?.kill(); } catch { /* already gone */ }
|
|
106
|
+
resolve(null);
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
pending.then(resolve, reject);
|
|
109
|
+
});
|
|
110
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
111
|
+
if (settled === null) {
|
|
112
|
+
return { code: null, stdout: "", stderr: `powershell trust script timed out after ${timeoutMs}ms` };
|
|
113
|
+
}
|
|
114
|
+
return { code: settled.code, stdout: settled.out.slice(-OUTPUT_LIMIT), stderr: settled.err.slice(-OUTPUT_LIMIT) };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
117
|
+
try { child?.kill(); } catch { /* already gone */ }
|
|
118
|
+
return { code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function base64Utf8(value: string): string {
|
|
123
|
+
return Buffer.from(value, "utf8").toString("base64");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A filesystem path carried as data, never as PowerShell syntax. */
|
|
127
|
+
function encodedPathExpression(path: string): string {
|
|
128
|
+
const encoded = base64Utf8(path);
|
|
129
|
+
if (!BASE64_PATTERN.test(encoded)) throw new Error("path encoding failed");
|
|
130
|
+
return `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Interpolated constants must not carry a quote or wildcard that changes the script. */
|
|
134
|
+
function psLiteral(value: string, label: string): string {
|
|
135
|
+
if (value.includes("'") || /[*?[\]]/.test(value)) throw new Error(`unsafe ${label} for script literal`);
|
|
136
|
+
return `'${value}'`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function markerOf(stdout: string): string | null {
|
|
140
|
+
const line = stdout
|
|
141
|
+
.split(/\r?\n/)
|
|
142
|
+
.map(entry => entry.trim())
|
|
143
|
+
.find(entry => entry.startsWith(TRUST_MARKER));
|
|
144
|
+
return line === undefined ? null : line.slice(TRUST_MARKER.length);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function currentUserRootStore(): string {
|
|
148
|
+
return "New-Object Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser')";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function subjectLikePattern(): string {
|
|
152
|
+
if (PICKER_CA_COMMON_NAME.length === 0 || /['*?[\]]/.test(PICKER_CA_COMMON_NAME)) {
|
|
153
|
+
throw new Error("unsafe picker CA common name for script literal");
|
|
154
|
+
}
|
|
155
|
+
return `'*CN=${PICKER_CA_COMMON_NAME}*'`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Inspect script: exact SHA-1 lookup in CurrentUser Root, persisted-leaf chain build
|
|
160
|
+
* against that root, plus DNS-name and validity checks. Emits exactly one marker
|
|
161
|
+
* line and exits 0 (trusted), 1 (untrusted) or 2 (error). Read-only: trust is never
|
|
162
|
+
* added or removed here; installation happens only on explicit activation via trust.
|
|
163
|
+
*/
|
|
164
|
+
export function buildWindowsTrustInspectScript(leafPath: string, wantSha1: string): string {
|
|
165
|
+
const want = normalizePickerTrustSha1(wantSha1);
|
|
166
|
+
if (want === null) throw new Error("picker trust fingerprint must be 40 hex characters");
|
|
167
|
+
if (leafPath.length === 0) throw new Error("picker trust leaf path must not be empty");
|
|
168
|
+
const host = psLiteral(PICKER_HOST, "picker host");
|
|
169
|
+
return [
|
|
170
|
+
"$ErrorActionPreference='Stop'",
|
|
171
|
+
`$leafFile=${encodedPathExpression(leafPath)}`,
|
|
172
|
+
`$want=${psLiteral(want, "picker CA fingerprint")}`,
|
|
173
|
+
`$store=${currentUserRootStore()}`,
|
|
174
|
+
"$store.Open('ReadOnly')",
|
|
175
|
+
"try {",
|
|
176
|
+
"$found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want })",
|
|
177
|
+
`if($found.Count -eq 0) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`,
|
|
178
|
+
"$ca=$found[0]",
|
|
179
|
+
`if($ca.Subject -notlike ${subjectLikePattern()}) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`,
|
|
180
|
+
"$leaf=New-Object Security.Cryptography.X509Certificates.X509Certificate2($leafFile)",
|
|
181
|
+
"$dns=$leaf.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::DnsName,$false)",
|
|
182
|
+
`if($dns -ne ${host}) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`,
|
|
183
|
+
"$now=Get-Date",
|
|
184
|
+
`if($leaf.NotBefore -gt $now -or $leaf.NotAfter -lt $now) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`,
|
|
185
|
+
`if($leaf.Issuer -ne $ca.Subject) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`,
|
|
186
|
+
"$chain=New-Object Security.Cryptography.X509Certificates.X509Chain",
|
|
187
|
+
"$chain.ChainPolicy.RevocationMode=[Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck",
|
|
188
|
+
"$chain.ChainPolicy.VerificationFlags=[Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority",
|
|
189
|
+
"$chain.ChainPolicy.ExtraStore.Add($ca) | Out-Null",
|
|
190
|
+
"$built=$chain.Build($leaf)",
|
|
191
|
+
"$root=$null",
|
|
192
|
+
"if($chain.ChainElements.Count -gt 0) { $root=$chain.ChainElements[$chain.ChainElements.Count-1].Certificate }",
|
|
193
|
+
`if($built -and ($null -ne $root) -and ($root.Thumbprint -eq $want)) { Write-Output '${TRUST_MARKER}TRUSTED'; exit 0 }`,
|
|
194
|
+
`Write-Output '${TRUST_MARKER}UNTRUSTED'`,
|
|
195
|
+
"exit 1",
|
|
196
|
+
`} catch { Write-Output '${TRUST_MARKER}ERROR'; exit 2 } finally { $store.Close() }`,
|
|
197
|
+
].join(";");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Install script: imports the snapshot file into CurrentUser Root after re-checking
|
|
202
|
+
* its thumbprint inside the script. Adding to Root raises the OS confirmation
|
|
203
|
+
* dialog and that user approval is the install gate; the powershell.exe process
|
|
204
|
+
* itself stays hidden and only the OS dialog is ever visible.
|
|
205
|
+
*/
|
|
206
|
+
export function buildWindowsTrustInstallScript(caPath: string, wantSha1: string): string {
|
|
207
|
+
const want = normalizePickerTrustSha1(wantSha1);
|
|
208
|
+
if (want === null) throw new Error("picker trust fingerprint must be 40 hex characters");
|
|
209
|
+
if (caPath.length === 0) throw new Error("picker trust CA path must not be empty");
|
|
210
|
+
return [
|
|
211
|
+
"$ErrorActionPreference='Stop'",
|
|
212
|
+
`$caFile=${encodedPathExpression(caPath)}`,
|
|
213
|
+
`$want=${psLiteral(want, "picker CA fingerprint")}`,
|
|
214
|
+
"$cert=New-Object Security.Cryptography.X509Certificates.X509Certificate2($caFile)",
|
|
215
|
+
`if($cert.Thumbprint -ne $want) { Write-Output '${TRUST_MARKER}ERROR'; exit 2 }`,
|
|
216
|
+
`$store=${currentUserRootStore()}`,
|
|
217
|
+
"$store.Open('ReadWrite')",
|
|
218
|
+
"try {",
|
|
219
|
+
"$found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want })",
|
|
220
|
+
"if($found.Count -eq 0) { $store.Add($cert) }",
|
|
221
|
+
`Write-Output '${TRUST_MARKER}INSTALLED'`,
|
|
222
|
+
"exit 0",
|
|
223
|
+
`} catch { Write-Output '${TRUST_MARKER}ERROR'; exit 2 } finally { $store.Close() }`,
|
|
224
|
+
].join(";");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Remove script: verifies picker identity for every exact-thumbprint Root entry before
|
|
229
|
+
* deleting any of them, then re-checks. Legacy picker roots need not match the new install profile.
|
|
230
|
+
* Removal from Root raises OS confirmation like an install, so this runs on the
|
|
231
|
+
* interactive prefix with a bounded 30s budget: predecessor cleanup also runs in headless
|
|
232
|
+
* removal may prompt, mirroring the macOS keychain prompt on migration cleanup.
|
|
233
|
+
*/
|
|
234
|
+
export function buildWindowsTrustRemoveScript(wantSha1: string): string {
|
|
235
|
+
const want = normalizePickerTrustSha1(wantSha1);
|
|
236
|
+
if (want === null) throw new Error("picker trust fingerprint must be 40 hex characters");
|
|
237
|
+
return [
|
|
238
|
+
"$ErrorActionPreference='Stop'",
|
|
239
|
+
`$want=${psLiteral(want, "picker CA fingerprint")}`,
|
|
240
|
+
`$pickerName=${psLiteral(PICKER_CA_COMMON_NAME, "picker CA common name")}`,
|
|
241
|
+
"try {",
|
|
242
|
+
`$store=${currentUserRootStore()}`,
|
|
243
|
+
"$store.Open('ReadWrite')",
|
|
244
|
+
"try { $found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want });",
|
|
245
|
+
"foreach($c in $found) {",
|
|
246
|
+
`if($c.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false) -ne $pickerName) { Write-Output '${TRUST_MARKER}ERROR'; exit 2 }`,
|
|
247
|
+
"}",
|
|
248
|
+
"foreach($c in $found) { $store.Remove($c) } } finally { $store.Close() }",
|
|
249
|
+
`$check=${currentUserRootStore()}`,
|
|
250
|
+
"$check.Open('ReadOnly')",
|
|
251
|
+
"try { $left=@($check.Certificates | Where-Object { $_.Thumbprint -eq $want });",
|
|
252
|
+
`if($left.Count -eq 0) { Write-Output '${TRUST_MARKER}REMOVED'; exit 0 }`,
|
|
253
|
+
`Write-Output '${TRUST_MARKER}ERROR'; exit 1 } finally { $check.Close() }`,
|
|
254
|
+
`} catch { Write-Output '${TRUST_MARKER}ERROR'; exit 2 }`,
|
|
255
|
+
].join(";");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function powershellArgs(script: string, interactive: boolean): string[] {
|
|
259
|
+
const prefix = interactive ? WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX : WINDOWS_TRUST_POWERSHELL_PREFIX;
|
|
260
|
+
return [...prefix, script];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function inspectPickerTrustWindows(
|
|
264
|
+
leafPath: string,
|
|
265
|
+
caSha1: string,
|
|
266
|
+
run?: WindowsTrustRunner,
|
|
267
|
+
): Promise<PickerTrustState> {
|
|
268
|
+
if (normalizePickerTrustSha1(caSha1) === null || leafPath.length === 0) return "untrusted";
|
|
269
|
+
const exec = run ?? defaultWindowsPowerShellRunner;
|
|
270
|
+
try {
|
|
271
|
+
const result = await exec(powershellArgs(buildWindowsTrustInspectScript(leafPath, caSha1), false), {
|
|
272
|
+
timeoutMs: WINDOWS_TRUST_INSPECT_TIMEOUT_MS,
|
|
273
|
+
});
|
|
274
|
+
if (result.code === 0 && markerOf(result.stdout) === "TRUSTED") return "trusted";
|
|
275
|
+
if (result.code === 1 && markerOf(result.stdout) === "UNTRUSTED") return "untrusted";
|
|
276
|
+
return "unknown";
|
|
277
|
+
} catch {
|
|
278
|
+
return "unknown";
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function trustPickerCaWindows(
|
|
283
|
+
caPath: string,
|
|
284
|
+
run?: WindowsTrustRunner,
|
|
285
|
+
cert?: { pem?: string },
|
|
286
|
+
): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> {
|
|
287
|
+
// The caller gates this on explicit activation; status and restart paths never call it.
|
|
288
|
+
// The installed bytes are always a private snapshot of the authority authorized here,
|
|
289
|
+
// never the shared ca.pem a same-user process could swap mid-flight.
|
|
290
|
+
const exec = run ?? defaultWindowsPowerShellRunner;
|
|
291
|
+
let privateDir: string | undefined;
|
|
292
|
+
try {
|
|
293
|
+
let pem = cert?.pem;
|
|
294
|
+
if (pem === undefined) pem = readFileSync(caPath, "utf8");
|
|
295
|
+
if (!acceptsPickerAuthority(pem)) return { ok: false, reason: "declined_or_failed" };
|
|
296
|
+
const { sha1 } = pickerCaFingerprints(pem);
|
|
297
|
+
privateDir = mkdtempSync(join(tmpdir(), "ocx-picker-ca-win-"));
|
|
298
|
+
const snapshot = join(privateDir, "ca.pem");
|
|
299
|
+
writeFileSync(snapshot, pem, { mode: 0o600 });
|
|
300
|
+
const result = await exec(powershellArgs(buildWindowsTrustInstallScript(snapshot, sha1), true), {
|
|
301
|
+
timeoutMs: WINDOWS_TRUST_IMPORT_TIMEOUT_MS,
|
|
302
|
+
});
|
|
303
|
+
return result.code === 0 && markerOf(result.stdout) === "INSTALLED"
|
|
304
|
+
? { ok: true }
|
|
305
|
+
: { ok: false, reason: "declined_or_failed" };
|
|
306
|
+
} catch {
|
|
307
|
+
return { ok: false, reason: "declined_or_failed" };
|
|
308
|
+
} finally {
|
|
309
|
+
if (privateDir !== undefined) rmSync(privateDir, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function untrustPickerCaWindows(
|
|
314
|
+
fingerprintSha1: string,
|
|
315
|
+
run?: WindowsTrustRunner,
|
|
316
|
+
cert?: { pem: string },
|
|
317
|
+
): Promise<{ ok: boolean }> {
|
|
318
|
+
const exec = run ?? defaultWindowsPowerShellRunner;
|
|
319
|
+
if (normalizePickerTrustSha1(fingerprintSha1) === null) return { ok: false };
|
|
320
|
+
try {
|
|
321
|
+
// Public recovery state is not removal authority. Accept legacy picker roots,
|
|
322
|
+
// but reject foreign certificates before invoking even an injected runner.
|
|
323
|
+
if (cert) {
|
|
324
|
+
const root = new X509Certificate(cert.pem);
|
|
325
|
+
const names = root.subject.split("\n");
|
|
326
|
+
if (!root.ca || !names.includes(`CN=${PICKER_CA_COMMON_NAME}`)
|
|
327
|
+
|| names.filter(name => name.startsWith("CN=")).length !== 1
|
|
328
|
+
|| root.issuer !== root.subject || !root.verify(root.publicKey)
|
|
329
|
+
|| pickerCaFingerprints(cert.pem).sha1 !== normalizePickerTrustSha1(fingerprintSha1)) return { ok: false };
|
|
330
|
+
}
|
|
331
|
+
const result = await exec(powershellArgs(buildWindowsTrustRemoveScript(fingerprintSha1), true), {
|
|
332
|
+
timeoutMs: WINDOWS_TRUST_UNTRUST_TIMEOUT_MS,
|
|
333
|
+
});
|
|
334
|
+
return result.code === 0 && markerOf(result.stdout) === "REMOVED" ? { ok: true } : { ok: false };
|
|
335
|
+
} catch {
|
|
336
|
+
return { ok: false };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -2,6 +2,11 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "./picker-ca";
|
|
5
|
+
import {
|
|
6
|
+
inspectPickerTrustWindows,
|
|
7
|
+
trustPickerCaWindows,
|
|
8
|
+
untrustPickerCaWindows,
|
|
9
|
+
} from "./picker-trust-windows";
|
|
5
10
|
|
|
6
11
|
/** Trust is scoped to the current root fingerprint and a verified persisted leaf. */
|
|
7
12
|
export type PickerTrustState = "trusted" | "untrusted" | "unsupported" | "unknown";
|
|
@@ -61,6 +66,11 @@ export async function inspectPickerTrust(
|
|
|
61
66
|
run: SecurityRunner = defaultSecurityRunner,
|
|
62
67
|
platform: NodeJS.Platform = process.platform,
|
|
63
68
|
): Promise<PickerTrustState> {
|
|
69
|
+
if (platform === "win32") {
|
|
70
|
+
// The macOS default runner is never passed down: only an explicitly injected
|
|
71
|
+
// runner crosses over, otherwise the Windows default powershell.exe runner runs.
|
|
72
|
+
return inspectPickerTrustWindows(leafPath, caSha1, run === defaultSecurityRunner ? undefined : run);
|
|
73
|
+
}
|
|
64
74
|
if (platform !== "darwin") return "unsupported";
|
|
65
75
|
const keychain = loginKeychainPath();
|
|
66
76
|
try {
|
|
@@ -86,6 +96,9 @@ export async function trustPickerCa(
|
|
|
86
96
|
platform: NodeJS.Platform = process.platform,
|
|
87
97
|
cert?: { pem?: string },
|
|
88
98
|
): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> {
|
|
99
|
+
if (platform === "win32") {
|
|
100
|
+
return trustPickerCaWindows(caPath, run === defaultSecurityRunner ? undefined : run, cert);
|
|
101
|
+
}
|
|
89
102
|
if (platform !== "darwin") return { ok: false, reason: "unsupported" };
|
|
90
103
|
// `cert.pem` is the caller-verified certificate bytes: the shared on-disk ca.pem is writable by
|
|
91
104
|
// same-user processes and could be swapped between fingerprint inspection and this install, so
|
|
@@ -117,6 +130,12 @@ export async function untrustPickerCa(
|
|
|
117
130
|
run: SecurityRunner = defaultSecurityRunner,
|
|
118
131
|
platform: NodeJS.Platform = process.platform,
|
|
119
132
|
): Promise<{ ok: boolean }> {
|
|
133
|
+
if (platform === "win32") {
|
|
134
|
+
try {
|
|
135
|
+
return await untrustPickerCaWindows(fingerprintSha1, run === defaultSecurityRunner ? undefined : run,
|
|
136
|
+
{ pem: readFileSync(caPath, "utf8") });
|
|
137
|
+
} catch { return { ok: false }; }
|
|
138
|
+
}
|
|
120
139
|
if (platform !== "darwin") return { ok: false };
|
|
121
140
|
const keychain = loginKeychainPath();
|
|
122
141
|
// Listed means the current picker CA is in the login keychain; unlisted means nothing to remove,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { OcxConfig } from "../../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Port and enablement policy for the Claude intercept pair.
|
|
5
|
+
*
|
|
6
|
+
* These are pure functions of config, with no listener, CA, or picker dependency. They live
|
|
7
|
+
* apart from `./runtime` because the first-party Desktop path reads them too: importing them
|
|
8
|
+
* from `./runtime` made `desktop-first-party` reach the picker runtime, which reaches
|
|
9
|
+
* `desktop-first-party` back. Keeping the policy in a leaf keeps that pair acyclic.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const CLAUDE_INTERCEPT_PORT_OFFSET = 100;
|
|
13
|
+
|
|
14
|
+
export function claudeInterceptEnabled(config: Pick<OcxConfig, "claudeCode" | "runtimeRole">): boolean {
|
|
15
|
+
if (config.runtimeRole === "client") return false;
|
|
16
|
+
if (config.claudeCode?.enabled === false) return false;
|
|
17
|
+
return config.claudeCode?.intercept?.enabled !== false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function claudeInterceptProxyPort(config: Pick<OcxConfig, "claudeCode">, publicPort: number): number {
|
|
21
|
+
const configured = config.claudeCode?.intercept?.port;
|
|
22
|
+
if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured;
|
|
23
|
+
return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Desktop's egress proxy for picker mode: the port after the intercept proxy (before it at 65535). */
|
|
27
|
+
export function claudePickerProxyPort(config: Pick<OcxConfig, "claudeCode">, publicPort: number): number {
|
|
28
|
+
const interceptPort = claudeInterceptProxyPort(config, publicPort);
|
|
29
|
+
return interceptPort < 65535 ? interceptPort + 1 : interceptPort - 1;
|
|
30
|
+
}
|