@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
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver";
|
|
8
|
+
import { npmInvocation } from "../update/npm-invocation.mjs";
|
|
9
|
+
import {
|
|
10
|
+
inspectCodexCliInstall,
|
|
11
|
+
type CodexCliInstallKind,
|
|
12
|
+
type CodexCliInstallProvenanceDeps,
|
|
13
|
+
type CodexCliInstallReport,
|
|
14
|
+
} from "./cli-install-provenance";
|
|
15
|
+
import {
|
|
16
|
+
scanCodexSessionProcesses,
|
|
17
|
+
type CodexAppServerProcessIo,
|
|
18
|
+
type CodexAppServerProcessScan,
|
|
19
|
+
} from "./app-server-processes";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Phase 2 of the Codex CLI update manager: a deterministic dry-run plan.
|
|
23
|
+
*
|
|
24
|
+
* Phase 1 ({@link inspectCodexCliInstall}) answers who owns the installation. It never
|
|
25
|
+
* queries a registry, never enumerates processes and never writes. This module adds the
|
|
26
|
+
* three inputs it deliberately left out — an exact resolved target with registry
|
|
27
|
+
* integrity, live session blockers, and a decision — and nothing else.
|
|
28
|
+
*
|
|
29
|
+
* Two invariants shape the whole module:
|
|
30
|
+
*
|
|
31
|
+
* - No persisted state. The plan is not stored anywhere; {@link CodexCliUpdatePlan.planId}
|
|
32
|
+
* is a digest over the evidence the decision rests on, so an approved change to any
|
|
33
|
+
* bound field produces a different id.
|
|
34
|
+
* - Nothing is terminated or restarted. A live Codex session refuses the plan; it is
|
|
35
|
+
* never signalled, killed or waited on. The plan performs no application-state
|
|
36
|
+
* mutation: it resolves registry metadata and reads the process table under a
|
|
37
|
+
* temporary isolated npm root (cwd, npmrc, cache and logs) that is removed
|
|
38
|
+
* afterwards; a forcibly terminated run may leave that temp root behind, so the
|
|
39
|
+
* cleanup contract is best-effort rather than absolute.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export const CODEX_CLI_PACKAGE = "@openai/codex";
|
|
43
|
+
export const CODEX_CLI_UPDATE_SCHEMA_VERSION = 1 as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The one registry this workflow is allowed to resolve, pack and install from.
|
|
47
|
+
*
|
|
48
|
+
* npm view/pack inherit the operator's npm configuration by default, so a project or
|
|
49
|
+
* user .npmrc — or an npm_config_* env var — can redirect "@openai/codex" to another
|
|
50
|
+
* registry whose version query, integrity query and tarball all agree with each
|
|
51
|
+
* other. Every npm call below pins this registry explicitly AND runs with the
|
|
52
|
+
* registry-affecting configuration isolated, so the pinned origin is the only source
|
|
53
|
+
* the evidence can come from.
|
|
54
|
+
*/
|
|
55
|
+
export const CODEX_CLI_REGISTRY = "https://registry.npmjs.org";
|
|
56
|
+
|
|
57
|
+
/** Only the stable channel is exposed; a moving dist-tag is resolved before it is used. */
|
|
58
|
+
export type CodexCliUpdateChannel = "latest";
|
|
59
|
+
|
|
60
|
+
export type CodexCliUpdateRefusal =
|
|
61
|
+
| "windows_inspection_deferred"
|
|
62
|
+
| "not_managed"
|
|
63
|
+
| "installed_version_unverified"
|
|
64
|
+
| "target_unresolved"
|
|
65
|
+
| "already_current"
|
|
66
|
+
| "target_not_newer"
|
|
67
|
+
| "blocked_active_session"
|
|
68
|
+
| "blocked_process_state_unknown";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `not-evaluated` is not a weaker `unknown`: it records that the plan was already refused
|
|
72
|
+
* on ownership or target grounds, so the process table was never read. Only `unknown`
|
|
73
|
+
* means an enumeration attempt failed.
|
|
74
|
+
*/
|
|
75
|
+
export type CodexCliUpdateSessionState = "none" | "active" | "unknown" | "not-evaluated";
|
|
76
|
+
|
|
77
|
+
export interface CodexCliUpdateSession {
|
|
78
|
+
readonly state: CodexCliUpdateSessionState;
|
|
79
|
+
/** Match count only. Command lines and paths never leave the process scanner. */
|
|
80
|
+
readonly matches: number | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type CodexCliUpdateTarget =
|
|
84
|
+
| Readonly<{ kind: "resolved"; version: string; integrity: string }>
|
|
85
|
+
| Readonly<{ kind: "unresolved"; reason: string }>;
|
|
86
|
+
|
|
87
|
+
export interface CodexCliUpdatePlan {
|
|
88
|
+
readonly schemaVersion: typeof CODEX_CLI_UPDATE_SCHEMA_VERSION;
|
|
89
|
+
readonly package: typeof CODEX_CLI_PACKAGE;
|
|
90
|
+
readonly channel: CodexCliUpdateChannel;
|
|
91
|
+
readonly applicable: boolean;
|
|
92
|
+
readonly refusal: CodexCliUpdateRefusal | null;
|
|
93
|
+
/** Present only for an applicable plan; there is nothing to quote for a refusal. */
|
|
94
|
+
readonly planId: string | null;
|
|
95
|
+
readonly provenance: CodexCliInstallKind;
|
|
96
|
+
readonly managed: boolean;
|
|
97
|
+
readonly installedVersion: string | null;
|
|
98
|
+
readonly versionEvidence: CodexCliInstallReport["versionEvidence"]["kind"];
|
|
99
|
+
readonly location: string | null;
|
|
100
|
+
readonly targetVersion: string | null;
|
|
101
|
+
readonly targetIntegrity: string | null;
|
|
102
|
+
readonly session: CodexCliUpdateSession;
|
|
103
|
+
/** Indicative install argv for the operator to read; the apply phase this plan precedes verifies a packed tarball against the bound sha512 rather than trusting a moving spec. */
|
|
104
|
+
readonly command: readonly string[] | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CodexCliUpdatePlanDeps {
|
|
108
|
+
readonly inspect?: (deps: CodexCliInstallProvenanceDeps) => Promise<CodexCliInstallReport>;
|
|
109
|
+
readonly inspectionDeps?: CodexCliInstallProvenanceDeps;
|
|
110
|
+
readonly platform?: NodeJS.Platform;
|
|
111
|
+
readonly channel?: CodexCliUpdateChannel;
|
|
112
|
+
readonly scanProcesses?: (io?: CodexAppServerProcessIo) => CodexAppServerProcessScan;
|
|
113
|
+
readonly processIo?: CodexAppServerProcessIo;
|
|
114
|
+
readonly resolveTarget?: (channel: CodexCliUpdateChannel) => CodexCliUpdateTarget;
|
|
115
|
+
readonly spawnProcess?: typeof spawnSync;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
const PLAN_ID_LENGTH = 32;
|
|
120
|
+
const PLAN_ID_RE = /^[0-9a-f]{32}$/;
|
|
121
|
+
const REGISTRY_TIMEOUT_MS = 12_000;
|
|
122
|
+
const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/;
|
|
123
|
+
|
|
124
|
+
function isSha512Integrity(token: string): boolean {
|
|
125
|
+
if (!SHA512_INTEGRITY_RE.test(token)) return false;
|
|
126
|
+
const encoded = token.slice("sha512-".length);
|
|
127
|
+
// Base64 decoders accept truncated input and nonzero unused padding bits.
|
|
128
|
+
// Require exactly 64 bytes and the canonical encoding of those same bytes.
|
|
129
|
+
const digest = Buffer.from(encoded, "base64");
|
|
130
|
+
return digest.byteLength === 64 && digest.toString("base64") === encoded;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The install argv this plan quotes for the operator to read, in argv form. */
|
|
134
|
+
export function codexCliUpdateCommand(version: string): readonly string[] {
|
|
135
|
+
// The displayed command must reproduce the artifact the plan bound: without the
|
|
136
|
+
// pinned registry the operator's npmrc could silently substitute another origin
|
|
137
|
+
// for the same package@version string.
|
|
138
|
+
return Object.freeze([
|
|
139
|
+
"npm",
|
|
140
|
+
"install",
|
|
141
|
+
"-g",
|
|
142
|
+
"--registry=" + CODEX_CLI_REGISTRY,
|
|
143
|
+
`${CODEX_CLI_PACKAGE}@${version}`,
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Digest over exactly the evidence the decision rests on.
|
|
149
|
+
*
|
|
150
|
+
* Session blockers are deliberately absent: a session that starts or ends between dry-run
|
|
151
|
+
* and a later apply must not invalidate an otherwise identical plan; it is re-read where
|
|
152
|
+
* it can only ever refuse. Everything else — ownership, installed version,
|
|
153
|
+
* location, resolved target, integrity — is bound, so any drift produces a different id.
|
|
154
|
+
*/
|
|
155
|
+
export function codexCliUpdatePlanId(bound: {
|
|
156
|
+
readonly platform: NodeJS.Platform;
|
|
157
|
+
readonly provenance: CodexCliInstallKind;
|
|
158
|
+
readonly installedVersion: string;
|
|
159
|
+
readonly installDigest: string | null;
|
|
160
|
+
readonly channel: CodexCliUpdateChannel;
|
|
161
|
+
readonly targetVersion: string;
|
|
162
|
+
readonly targetIntegrity: string;
|
|
163
|
+
}): string {
|
|
164
|
+
// Ordered pairs, not object key order: the digest must not depend on how a caller
|
|
165
|
+
// happened to build the record.
|
|
166
|
+
const fields: readonly (readonly [string, string])[] = [
|
|
167
|
+
["schemaVersion", String(CODEX_CLI_UPDATE_SCHEMA_VERSION)],
|
|
168
|
+
["package", CODEX_CLI_PACKAGE],
|
|
169
|
+
["platform", bound.platform],
|
|
170
|
+
["provenance", bound.provenance],
|
|
171
|
+
["installedVersion", bound.installedVersion],
|
|
172
|
+
["installDigest", bound.installDigest ?? ""],
|
|
173
|
+
["channel", bound.channel],
|
|
174
|
+
["targetVersion", bound.targetVersion],
|
|
175
|
+
["targetIntegrity", bound.targetIntegrity],
|
|
176
|
+
];
|
|
177
|
+
const hash = createHash("sha256");
|
|
178
|
+
for (const [key, value] of fields) hash.update(`${key}=${value}\n`);
|
|
179
|
+
return hash.digest("hex").slice(0, PLAN_ID_LENGTH);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface NpmTarget {
|
|
183
|
+
readonly bin: string;
|
|
184
|
+
readonly args: string[];
|
|
185
|
+
readonly options: {
|
|
186
|
+
readonly windowsVerbatimArguments?: boolean;
|
|
187
|
+
readonly cwd?: string;
|
|
188
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget | null {
|
|
193
|
+
const invocation = npmInvocation(args, process.platform, env ?? process.env);
|
|
194
|
+
if (!invocation) return null;
|
|
195
|
+
return { bin: invocation.file, args: invocation.args, options: invocation.options };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The npm environment with every registry-affecting config channel removed.
|
|
200
|
+
*
|
|
201
|
+
* npm maps any npm_config_* env var into its configuration, so NPM_CONFIG_REGISTRY or
|
|
202
|
+
* npm_config_@openai:registry would defeat the pinned --registry flag (a scoped
|
|
203
|
+
* registry beats the default for that scope). They are all stripped. The settings
|
|
204
|
+
* this codebase intentionally supports survive untouched: the standard proxy
|
|
205
|
+
* variables npm itself honors (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY, any case)
|
|
206
|
+
* and NODE_EXTRA_CA_CERTS, which npm's own Node runtime reads for TLS.
|
|
207
|
+
*
|
|
208
|
+
* Node's own startup channels are stripped too. npm runs inside Node, so ambient
|
|
209
|
+
* NODE_OPTIONS injects code before the first npm line, NODE_PATH redirects module
|
|
210
|
+
* resolution, and NODE_TLS_REJECT_UNAUTHORIZED would make the pinned-registry TLS
|
|
211
|
+
* check void - each one reaches the evidence-producing process unless removed.
|
|
212
|
+
* NODE_V8_COVERAGE and NODE_REDIRECT_WARNINGS are dropped for the same reason in
|
|
213
|
+
* reverse: they are output paths, so ambient values would write Node artifacts to
|
|
214
|
+
* arbitrary external directories on exit. ELF/Darwin loader controls (LD_* and
|
|
215
|
+
* DYLD_*) are also removed: they can load code or redirect loader output before
|
|
216
|
+
* npm runs. This is a denylist for process-startup/output channels, not a claim
|
|
217
|
+
* that every ambient variable is allowlisted. Ordinary OS environment, proxy
|
|
218
|
+
* settings and the supported NODE_EXTRA_CA_CERTS extension remain available.
|
|
219
|
+
*/
|
|
220
|
+
const NPM_ENV_DROP_KEYS: ReadonlySet<string> = new Set([
|
|
221
|
+
"node_options",
|
|
222
|
+
"node_path",
|
|
223
|
+
"node_tls_reject_unauthorized",
|
|
224
|
+
"node_compile_cache",
|
|
225
|
+
// Output-path channels: ambient values would make the Node process write
|
|
226
|
+
// coverage JSON or warning output to an arbitrary external directory on exit,
|
|
227
|
+
// escaping the owned temp root regardless of the isolated --cache.
|
|
228
|
+
"node_v8_coverage",
|
|
229
|
+
"node_redirect_warnings",
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
233
|
+
const env: NodeJS.ProcessEnv = { ...base };
|
|
234
|
+
for (const key of Object.keys(env)) {
|
|
235
|
+
const lower = key.toLowerCase();
|
|
236
|
+
if (lower.startsWith("npm_config_") || lower.startsWith("ld_")
|
|
237
|
+
|| lower.startsWith("dyld_") || NPM_ENV_DROP_KEYS.has(lower)) delete env[key];
|
|
238
|
+
}
|
|
239
|
+
return env;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Ambient environment with the executable-selection channels replaced by the proof-bound
|
|
244
|
+
* launcher snapshot. HOME, proxy variables and NODE_EXTRA_CA_CERTS stay ambient because npm
|
|
245
|
+
* itself needs them; PATH/PATHEXT are the channels a project dotenv could use to supply a
|
|
246
|
+
* fake npm, so they come only from the captured snapshot. A snapshot without PATH means no
|
|
247
|
+
* trusted PATH at all — an empty one fails the spawn closed rather than falling back.
|
|
248
|
+
*/
|
|
249
|
+
function codexCliUpdateBoundEnv(bound: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
|
|
250
|
+
const merged = codexCliUpdateNpmEnv();
|
|
251
|
+
if (bound === undefined) return merged;
|
|
252
|
+
for (const key of Object.keys(merged)) {
|
|
253
|
+
const k = key.toLowerCase();
|
|
254
|
+
if (k === "path" || k === "pathext") delete merged[key];
|
|
255
|
+
}
|
|
256
|
+
let hasTrustedPath = false;
|
|
257
|
+
for (const [key, value] of Object.entries(bound)) {
|
|
258
|
+
const k = key.toLowerCase();
|
|
259
|
+
if ((k === "path" || k === "pathext") && value !== undefined) {
|
|
260
|
+
merged[key] = value;
|
|
261
|
+
if (k === "path") hasTrustedPath = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// A trusted snapshot without PATH means "no trusted PATH", not "keep the ambient
|
|
265
|
+
// one". Install the empty value explicitly so executable resolution fails closed
|
|
266
|
+
// instead of silently re-inheriting a PATH the proof never attested.
|
|
267
|
+
if (!hasTrustedPath) merged.PATH = "";
|
|
268
|
+
return merged;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface NpmConfigIsolation {
|
|
272
|
+
/** Controlled cwd: contains the sentinel package.json, so npm's project-config walk stops here. */
|
|
273
|
+
readonly dir: string;
|
|
274
|
+
/** Distinct empty files: npm refuses to load one path at two configuration levels. */
|
|
275
|
+
readonly npmrc: string;
|
|
276
|
+
readonly globalNpmrc: string;
|
|
277
|
+
/** npm cache root under dir: keeps _cacache and _logs inside the owned root so a
|
|
278
|
+
* successful query cannot leave residue in the ambient ~/.npm. */
|
|
279
|
+
readonly cache: string;
|
|
280
|
+
readonly env: NodeJS.ProcessEnv;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A directory npm cannot read hostile configuration through.
|
|
285
|
+
*
|
|
286
|
+
* npm resolves project config from the nearest ancestor containing package.json (or
|
|
287
|
+
* node_modules/.git), so a bare temp dir is NOT enough — the walk would continue into
|
|
288
|
+
* the operator's real ancestors. The sentinel package.json anchors the walk here,
|
|
289
|
+
* where no .npmrc exists. --userconfig/--globalconfig replace the two file configs,
|
|
290
|
+
* and the env filter removes the env-var channel. What remains is exactly the pinned
|
|
291
|
+
* --registry flag plus deliberately supported proxy/CA env.
|
|
292
|
+
*/
|
|
293
|
+
function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): NpmConfigIsolation {
|
|
294
|
+
const ownsRoot = dir === undefined;
|
|
295
|
+
const root = dir ?? mkdtempSync(join(tmpdir(), "ocx-codex-cli-meta-"));
|
|
296
|
+
try {
|
|
297
|
+
writeFileSync(join(root, "package.json"), "{}\n");
|
|
298
|
+
const npmrc = join(root, "ocx-update.npmrc");
|
|
299
|
+
writeFileSync(npmrc, "");
|
|
300
|
+
const globalNpmrc = join(root, "ocx-update-global.npmrc");
|
|
301
|
+
writeFileSync(globalNpmrc, "");
|
|
302
|
+
const cache = join(root, "npm-cache");
|
|
303
|
+
return { dir: root, npmrc, globalNpmrc, cache, env: codexCliUpdateBoundEnv(boundEnv) };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
// A mid-setup failure must not leak a directory this call created; a
|
|
306
|
+
// caller-supplied directory stays the caller's responsibility.
|
|
307
|
+
if (ownsRoot) rmSync(root, { recursive: true, force: true });
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Registry-pinned, config-isolated argv for one npm call. */
|
|
313
|
+
function isolatedNpmArgs(args: readonly string[], isolation: NpmConfigIsolation): readonly string[] {
|
|
314
|
+
return [
|
|
315
|
+
...args,
|
|
316
|
+
"--registry=" + CODEX_CLI_REGISTRY,
|
|
317
|
+
"--userconfig=" + isolation.npmrc,
|
|
318
|
+
"--globalconfig=" + isolation.globalNpmrc,
|
|
319
|
+
"--cache=" + isolation.cache,
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolation): NpmTarget | null {
|
|
324
|
+
const target = npmTarget(isolatedNpmArgs(args, isolation), isolation.env);
|
|
325
|
+
if (!target) return null;
|
|
326
|
+
return {
|
|
327
|
+
bin: target.bin,
|
|
328
|
+
args: target.args,
|
|
329
|
+
options: { ...target.options, cwd: isolation.dir, env: isolation.env },
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Resolve the channel to one exact version plus its sha512 integrity token.
|
|
335
|
+
*
|
|
336
|
+
* OpenCodex's own updater treats a failed integrity query as `skipped` and proceeds
|
|
337
|
+
* best-effort. That is a defensible trade for the package we publish ourselves; it is not
|
|
338
|
+
* acceptable for a foreign package we are about to install on the operator's behalf, so
|
|
339
|
+
* every failure lane here is a refusal.
|
|
340
|
+
*/
|
|
341
|
+
export function resolveCodexCliUpdateTarget(
|
|
342
|
+
channel: CodexCliUpdateChannel,
|
|
343
|
+
spawn: typeof spawnSync = spawnSync,
|
|
344
|
+
/** Proof-bound launcher snapshot env; executable resolution reads only its PATH/PATHEXT. */
|
|
345
|
+
boundEnv?: NodeJS.ProcessEnv,
|
|
346
|
+
): CodexCliUpdateTarget {
|
|
347
|
+
// The pinned registry and the isolation directory together are the boundary: the
|
|
348
|
+
// version, the integrity token and the tarball URL all come from npmjs or the
|
|
349
|
+
// target is unresolved — a redirected answer can never be the evidence.
|
|
350
|
+
let isolation: NpmConfigIsolation;
|
|
351
|
+
try {
|
|
352
|
+
isolation = createNpmConfigIsolation(undefined, boundEnv);
|
|
353
|
+
} catch {
|
|
354
|
+
// Setup failures are a refusal lane like every other resolve failure, not an
|
|
355
|
+
// exception escaping the dry-run.
|
|
356
|
+
return Object.freeze({ kind: "unresolved" as const, reason: "npm configuration isolation setup failed" });
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const runView = (field: string, spec: string): SpawnSyncReturns<string> | null => {
|
|
360
|
+
const target = isolatedNpmTarget(["view", spec, field], isolation);
|
|
361
|
+
if (!target) return null;
|
|
362
|
+
return spawn(target.bin, target.args, {
|
|
363
|
+
encoding: "utf8",
|
|
364
|
+
timeout: REGISTRY_TIMEOUT_MS,
|
|
365
|
+
windowsHide: true,
|
|
366
|
+
...target.options,
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const versionRun = runView("version", `${CODEX_CLI_PACKAGE}@${channel}`);
|
|
371
|
+
if (!versionRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" });
|
|
372
|
+
// status === null covers a timeout as well as a spawn failure.
|
|
373
|
+
if (versionRun.status !== 0) {
|
|
374
|
+
return Object.freeze({ kind: "unresolved" as const, reason: `registry version query failed (status ${versionRun.status ?? "timeout"})` });
|
|
375
|
+
}
|
|
376
|
+
const version = (versionRun.stdout ?? "").trim();
|
|
377
|
+
if (!parseStrictSemver(version)) {
|
|
378
|
+
return Object.freeze({ kind: "unresolved" as const, reason: "registry returned no exact version" });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const integrityRun = runView("dist.integrity", `${CODEX_CLI_PACKAGE}@${version}`);
|
|
382
|
+
if (!integrityRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" });
|
|
383
|
+
if (integrityRun.status !== 0) {
|
|
384
|
+
return Object.freeze({ kind: "unresolved" as const, reason: `registry integrity query failed (status ${integrityRun.status ?? "timeout"})` });
|
|
385
|
+
}
|
|
386
|
+
// `dist.integrity` may arrive quoted or as a space-separated multi-hash list.
|
|
387
|
+
const tokens = (integrityRun.stdout ?? "").replace(/["']/g, "").trim().split(/\s+/).filter(Boolean);
|
|
388
|
+
const integrity = tokens.find(isSha512Integrity);
|
|
389
|
+
if (!integrity) {
|
|
390
|
+
return Object.freeze({ kind: "unresolved" as const, reason: "registry returned no sha512 integrity token" });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The digest binds the tarball's CONTENT; this binds its ORIGIN. A registry
|
|
394
|
+
// answer whose tarball lives outside the pinned registry is not the artifact
|
|
395
|
+
// the plan approved, even if its sha512 matched.
|
|
396
|
+
const tarballRun = runView("dist.tarball", `${CODEX_CLI_PACKAGE}@${version}`);
|
|
397
|
+
if (!tarballRun) return Object.freeze({ kind: "unresolved" as const, reason: "npm executable was not found on a trusted PATH entry" });
|
|
398
|
+
if (tarballRun.status !== 0) {
|
|
399
|
+
return Object.freeze({ kind: "unresolved" as const, reason: `registry tarball query failed (status ${tarballRun.status ?? "timeout"})` });
|
|
400
|
+
}
|
|
401
|
+
const tarball = (tarballRun.stdout ?? "").replace(/["']/g, "").trim();
|
|
402
|
+
let tarballOrigin: string | null = null;
|
|
403
|
+
try {
|
|
404
|
+
tarballOrigin = new URL(tarball).origin;
|
|
405
|
+
} catch {
|
|
406
|
+
tarballOrigin = null;
|
|
407
|
+
}
|
|
408
|
+
if (tarballOrigin !== CODEX_CLI_REGISTRY) {
|
|
409
|
+
return Object.freeze({ kind: "unresolved" as const, reason: "registry returned a tarball outside the official registry origin" });
|
|
410
|
+
}
|
|
411
|
+
return Object.freeze({ kind: "resolved" as const, version, integrity });
|
|
412
|
+
} finally {
|
|
413
|
+
// Cleanup is best-effort; a locked residue must not replace the plan result.
|
|
414
|
+
try { rmSync(isolation.dir, { recursive: true, force: true }); }
|
|
415
|
+
catch { /* the owned temporary root may remain, as documented */ }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function refusedPlan(
|
|
420
|
+
refusal: CodexCliUpdateRefusal,
|
|
421
|
+
report: CodexCliInstallReport,
|
|
422
|
+
channel: CodexCliUpdateChannel,
|
|
423
|
+
target: CodexCliUpdateTarget | null,
|
|
424
|
+
session: CodexCliUpdateSession,
|
|
425
|
+
): CodexCliUpdatePlan {
|
|
426
|
+
return Object.freeze({
|
|
427
|
+
schemaVersion: CODEX_CLI_UPDATE_SCHEMA_VERSION,
|
|
428
|
+
package: CODEX_CLI_PACKAGE,
|
|
429
|
+
channel,
|
|
430
|
+
applicable: false,
|
|
431
|
+
refusal,
|
|
432
|
+
planId: null,
|
|
433
|
+
provenance: report.provenance,
|
|
434
|
+
managed: report.managed,
|
|
435
|
+
installedVersion: report.packageVersion,
|
|
436
|
+
versionEvidence: report.versionEvidence.kind,
|
|
437
|
+
location: report.location,
|
|
438
|
+
targetVersion: target?.kind === "resolved" ? target.version : null,
|
|
439
|
+
targetIntegrity: target?.kind === "resolved" ? target.integrity : null,
|
|
440
|
+
session,
|
|
441
|
+
command: null,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const NOT_EVALUATED: CodexCliUpdateSession = Object.freeze({ state: "not-evaluated" as const, matches: null });
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Build the dry-run plan. Installs nothing and mutates no application state;
|
|
449
|
+
* registry evidence is gathered under a temporary isolated npm root whose cache,
|
|
450
|
+
* logs, npmrc and cwd are all inside it and removed best-effort afterwards.
|
|
451
|
+
*
|
|
452
|
+
* The refusal order is deliberate. Ownership and target questions are settled before the
|
|
453
|
+
* process table is read, so a machine that can never be updated by this workflow does not
|
|
454
|
+
* pay for an enumeration, and the reported refusal is the decisive one rather than
|
|
455
|
+
* whichever check happened to run first.
|
|
456
|
+
*/
|
|
457
|
+
export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {}): Promise<CodexCliUpdatePlan> {
|
|
458
|
+
const channel: CodexCliUpdateChannel = deps.channel ?? "latest";
|
|
459
|
+
const platform = deps.platform ?? process.platform;
|
|
460
|
+
const inspect = deps.inspect ?? inspectCodexCliInstall;
|
|
461
|
+
const report = await inspect(deps.inspectionDeps ?? {});
|
|
462
|
+
|
|
463
|
+
// Phase 1 performs no candidate filesystem I/O at all on Windows, so there is no
|
|
464
|
+
// ownership evidence to build a plan on. This stays dormant until the handle-bound
|
|
465
|
+
// provenance layer exists; pretending otherwise would require exactly the reads phase 1
|
|
466
|
+
// refused to perform.
|
|
467
|
+
if (platform === "win32" || report.reason === "windows_inspection_deferred") {
|
|
468
|
+
return refusedPlan("windows_inspection_deferred", report, channel, null, NOT_EVALUATED);
|
|
469
|
+
}
|
|
470
|
+
if (!report.managed || report.provenance !== "npm-global") {
|
|
471
|
+
return refusedPlan("not_managed", report, channel, null, NOT_EVALUATED);
|
|
472
|
+
}
|
|
473
|
+
// An advisory runtime string is what a candidate binary said about itself. Only
|
|
474
|
+
// package-manifest evidence states what is installed on disk, and only that can be
|
|
475
|
+
// compared with a registry version or read back after an install.
|
|
476
|
+
const installedVersion = report.versionEvidence.kind === "package-manifest" ? report.packageVersion : null;
|
|
477
|
+
const installedSemver = installedVersion ? parseStrictSemver(installedVersion) : null;
|
|
478
|
+
if (!installedVersion || !installedSemver) {
|
|
479
|
+
return refusedPlan("installed_version_unverified", report, channel, null, NOT_EVALUATED);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// The npm evidence channels are bound to the same environment snapshot the
|
|
483
|
+
// ownership inspection ran against: PATH/PATHEXT from the proof-bound launcher
|
|
484
|
+
// context, never the ambient ones a project dotenv could point at a fake npm.
|
|
485
|
+
// When the caller had no trusted snapshot, inspectionDeps.env is the fail-closed
|
|
486
|
+
// { PATH: "" } form, so this resolution cannot silently fall back to ambient.
|
|
487
|
+
const resolveTarget = deps.resolveTarget
|
|
488
|
+
?? (ch => resolveCodexCliUpdateTarget(ch, deps.spawnProcess ?? spawnSync, deps.inspectionDeps?.env));
|
|
489
|
+
const target = resolveTarget(channel);
|
|
490
|
+
if (target.kind !== "resolved") {
|
|
491
|
+
return refusedPlan("target_unresolved", report, channel, target, NOT_EVALUATED);
|
|
492
|
+
}
|
|
493
|
+
// Raw equality is not the gate: a resolved target must advance the installed
|
|
494
|
+
// version by semver precedence. Equal versions are already current and lower
|
|
495
|
+
// ones are refused rather than applied as a silent downgrade.
|
|
496
|
+
const targetSemver = parseStrictSemver(target.version);
|
|
497
|
+
if (!targetSemver) {
|
|
498
|
+
return refusedPlan("target_unresolved", report, channel, target, NOT_EVALUATED);
|
|
499
|
+
}
|
|
500
|
+
const versionOrder = compareStrictSemver(targetSemver, installedSemver);
|
|
501
|
+
if (versionOrder === 0) {
|
|
502
|
+
return refusedPlan("already_current", report, channel, target, NOT_EVALUATED);
|
|
503
|
+
}
|
|
504
|
+
if (versionOrder < 0) {
|
|
505
|
+
return refusedPlan("target_not_newer", report, channel, target, NOT_EVALUATED);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const scan = (deps.scanProcesses ?? scanCodexSessionProcesses)(deps.processIo ?? {});
|
|
509
|
+
if (scan.kind !== "observed") {
|
|
510
|
+
// An unreadable process table is not "no sessions". `listCodexAppServerProcesses`
|
|
511
|
+
// maps that failure to an empty list because its kill contract must never signal a
|
|
512
|
+
// process it could not verify; the update contract has to defer instead.
|
|
513
|
+
return refusedPlan("blocked_process_state_unknown", report, channel, target, Object.freeze({ state: "unknown" as const, matches: null }));
|
|
514
|
+
}
|
|
515
|
+
const matches = scan.processes.length;
|
|
516
|
+
if (matches > 0) {
|
|
517
|
+
return refusedPlan("blocked_active_session", report, channel, target, Object.freeze({ state: "active" as const, matches }));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return Object.freeze({
|
|
521
|
+
schemaVersion: CODEX_CLI_UPDATE_SCHEMA_VERSION,
|
|
522
|
+
package: CODEX_CLI_PACKAGE,
|
|
523
|
+
channel,
|
|
524
|
+
applicable: true,
|
|
525
|
+
refusal: null,
|
|
526
|
+
planId: codexCliUpdatePlanId({
|
|
527
|
+
platform,
|
|
528
|
+
provenance: report.provenance,
|
|
529
|
+
installedVersion,
|
|
530
|
+
installDigest: report.installDigest,
|
|
531
|
+
channel,
|
|
532
|
+
targetVersion: target.version,
|
|
533
|
+
targetIntegrity: target.integrity,
|
|
534
|
+
}),
|
|
535
|
+
provenance: report.provenance,
|
|
536
|
+
managed: report.managed,
|
|
537
|
+
installedVersion,
|
|
538
|
+
versionEvidence: report.versionEvidence.kind,
|
|
539
|
+
location: report.location,
|
|
540
|
+
targetVersion: target.version,
|
|
541
|
+
targetIntegrity: target.integrity,
|
|
542
|
+
session: Object.freeze({ state: "none" as const, matches: 0 }),
|
|
543
|
+
command: codexCliUpdateCommand(target.version),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
@@ -96,8 +96,29 @@ function acquire(configPath: string): ConfigWriteAcquireResult {
|
|
|
96
96
|
throw error;
|
|
97
97
|
}
|
|
98
98
|
const acquired = tryAcquire(`${target.canonical}.ocx-write.lock`);
|
|
99
|
-
if (acquired.ok)
|
|
100
|
-
|
|
99
|
+
if (!acquired.ok) return acquired;
|
|
100
|
+
try {
|
|
101
|
+
// A preceding writer may atomically replace the file before releasing the lock.
|
|
102
|
+
// Capture the inode only after acquisition, while retaining the canonical-path gate.
|
|
103
|
+
const current = destination(configPath);
|
|
104
|
+
if (current.canonical !== target.canonical || !stillHeld(acquired.handle)) {
|
|
105
|
+
release(acquired.handle);
|
|
106
|
+
return { ok: false, error: "unsafe", detail: acquired.handle.path };
|
|
107
|
+
}
|
|
108
|
+
// Preserve caller-owned unreadable/non-regular errors, but do not mistake a
|
|
109
|
+
// dangling link (realpath's fallback) or a fresh replacement for proven absence.
|
|
110
|
+
try {
|
|
111
|
+
const entry = lstatSync(current.canonical, { bigint: true });
|
|
112
|
+
if (entry.isSymbolicLink() || `${entry.dev}:${entry.ino}` !== current.inode) throw new ConfigWriteDestinationChanged();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || current.inode !== null) throw error;
|
|
115
|
+
}
|
|
116
|
+
destinations.set(acquired.handle, current);
|
|
117
|
+
return acquired;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
release(acquired.handle);
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
101
122
|
}
|
|
102
123
|
export function withConfigWriteLock<T>(configPath: string, run: (handle: LockHandle) => T): ConfigWriteLockOutcome<T> {
|
|
103
124
|
const acquired = acquire(configPath);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the Codex desktop app's model picker already shows the current catalog (#4761).
|
|
3
|
+
*
|
|
4
|
+
* `ocx system codex-restart` used to quit and relaunch the desktop shell every time, which
|
|
5
|
+
* discards everything that lives only in the client: composer drafts, an unsent picker
|
|
6
|
+
* selection, a pending approval prompt. Upstream's own restart (`codex app-server daemon
|
|
7
|
+
* restart`) restarts only the app-server and lets clients reconnect, and the desktop app
|
|
8
|
+
* already respawns its own app-server child when it exits.
|
|
9
|
+
*
|
|
10
|
+
* The one thing an app-server restart cannot refresh is the picker: the renderer builds
|
|
11
|
+
* the roster once when the shell launches, and `model_catalog_json` is startup-only. So
|
|
12
|
+
* the shell has to restart only when it launched before the catalog it should show was
|
|
13
|
+
* written. The catalog writer skips byte-identical rewrites to keep this mtime honest
|
|
14
|
+
* (`src/codex/catalog/retained-sync.ts`), which is what makes the comparison usable.
|
|
15
|
+
*
|
|
16
|
+
* The mtime only means something for the catalog `config.toml` actually binds. With no
|
|
17
|
+
* `model_catalog_json` the shell shows Codex's own roster, which no local file dates, so
|
|
18
|
+
* an unbound config is `unknown`. `config.toml`'s own mtime is no substitute: injection
|
|
19
|
+
* rewrites it on every sync even when the bytes are identical. A binding changed by THIS
|
|
20
|
+
* request's sync is caught by the caller comparing it across the sync; one changed by an
|
|
21
|
+
* earlier sync over a byte-identical catalog file is not observable here.
|
|
22
|
+
*
|
|
23
|
+
* Anything we cannot read answers `unknown`, and the caller falls back to the full
|
|
24
|
+
* desktop restart: a stale picker the operator asked us to fix is the failure this
|
|
25
|
+
* command exists to prevent, so missing evidence never skips it.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
28
|
+
import { activeCodexConfigPath, resolveActiveCodexConfigPath } from "../catalog/parsing";
|
|
29
|
+
import { readRootTomlString } from "../paths";
|
|
30
|
+
import { readProcessStartMsBatch } from "../app-server-processes";
|
|
31
|
+
import { listCodexDesktopShellPids, type DesktopAppRestartIo } from "../desktop-app-restart";
|
|
32
|
+
import { isTestHomeGuardArmed } from "../../lib/test-home-guard";
|
|
33
|
+
import type { CodexDesktopPickerState } from "../../lib/codex-restart-contract";
|
|
34
|
+
|
|
35
|
+
export type DesktopPickerState = CodexDesktopPickerState;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure decision. `shellStartsMs === null` means the shells could not be listed; a null
|
|
39
|
+
* entry means one shell's start time was unreadable.
|
|
40
|
+
*
|
|
41
|
+
* Strictly later, never equal: macOS `ps lstart` has one-second granularity, so a shell
|
|
42
|
+
* that started in the same second as the catalog write may predate it.
|
|
43
|
+
*/
|
|
44
|
+
export function classifyDesktopPicker(
|
|
45
|
+
shellStartsMs: readonly (number | null)[] | null,
|
|
46
|
+
catalogMtimeMs: number | null,
|
|
47
|
+
): DesktopPickerState {
|
|
48
|
+
if (shellStartsMs === null) return "unknown";
|
|
49
|
+
if (shellStartsMs.length === 0) return "not_running";
|
|
50
|
+
if (catalogMtimeMs === null) return "unknown";
|
|
51
|
+
if (shellStartsMs.some(start => start === null)) return "unknown";
|
|
52
|
+
return shellStartsMs.every(start => start! > catalogMtimeMs) ? "current" : "stale";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DesktopPickerIo extends DesktopAppRestartIo {
|
|
56
|
+
readStartMs?: (pids: readonly number[]) => Map<number, number | null>;
|
|
57
|
+
/** The catalog `config.toml` binds, or null when it binds none or cannot be read. */
|
|
58
|
+
catalogBinding?: () => string | null;
|
|
59
|
+
catalogMtimeMs?: (path: string) => number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The resolved `model_catalog_json` path, or null when config.toml binds no catalog. */
|
|
63
|
+
export function readCodexCatalogBinding(): string | null {
|
|
64
|
+
try {
|
|
65
|
+
const configPath = activeCodexConfigPath();
|
|
66
|
+
if (!existsSync(configPath)) return null;
|
|
67
|
+
const path = readRootTomlString(readFileSync(configPath, "utf-8"), "model_catalog_json");
|
|
68
|
+
return path ? resolveActiveCodexConfigPath(path) : null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function defaultCatalogMtimeMs(path: string): number | null {
|
|
75
|
+
try {
|
|
76
|
+
return statSync(path).mtimeMs;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readCodexDesktopPickerState(io: DesktopPickerIo = {}): DesktopPickerState {
|
|
83
|
+
// An armed test process without an injected executor answers `unknown`, which keeps the
|
|
84
|
+
// caller on the desktop-restart seam the test injected. Reading is harmless, but whether
|
|
85
|
+
// the developer's own Codex is open must not decide which branch a test takes.
|
|
86
|
+
if (!io.execFile && isTestHomeGuardArmed()) return "unknown";
|
|
87
|
+
const platform = io.platform ?? process.platform;
|
|
88
|
+
const pids = listCodexDesktopShellPids(io);
|
|
89
|
+
if (pids === null || pids.length === 0) return classifyDesktopPicker(pids, null);
|
|
90
|
+
const starts = (io.readStartMs ?? (list => readProcessStartMsBatch(list, platform)))(pids);
|
|
91
|
+
const binding = (io.catalogBinding ?? readCodexCatalogBinding)();
|
|
92
|
+
return classifyDesktopPicker(
|
|
93
|
+
pids.map(pid => starts.get(pid) ?? null),
|
|
94
|
+
binding === null ? null : (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(binding),
|
|
95
|
+
);
|
|
96
|
+
}
|