@yansigit/opencodex 2.31.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/AGENTS_INSTALL.md +109 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/claude-code-models.gif +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +587 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-BNJ7r4Gd.js +102 -0
- package/gui/dist/assets/index-CGoDO3uO.css +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +25 -0
- package/gui/dist/logo.png +0 -0
- package/gui/dist/provider-icons/alibaba-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity-color.svg +1 -0
- package/gui/dist/provider-icons/claude-color.svg +1 -0
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.svg +2 -0
- package/gui/dist/provider-icons/deepseek-color.svg +1 -0
- package/gui/dist/provider-icons/discord.svg +1 -0
- package/gui/dist/provider-icons/firepass-color.svg +1 -0
- package/gui/dist/provider-icons/fireworks-color.svg +1 -0
- package/gui/dist/provider-icons/gemini-color.svg +1 -0
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
- package/gui/dist/provider-icons/grok.svg +1 -0
- package/gui/dist/provider-icons/groq-color.svg +1 -0
- package/gui/dist/provider-icons/huggingface-color.svg +1 -0
- package/gui/dist/provider-icons/kimi-color.svg +1 -0
- package/gui/dist/provider-icons/kiro-color.svg +15 -0
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
- package/gui/dist/provider-icons/mistral-color.svg +1 -0
- package/gui/dist/provider-icons/moonshot-color.svg +1 -0
- package/gui/dist/provider-icons/nvidia-color.svg +1 -0
- package/gui/dist/provider-icons/ollama-color.svg +1 -0
- package/gui/dist/provider-icons/openai.svg +1 -0
- package/gui/dist/provider-icons/opencode.svg +2 -0
- package/gui/dist/provider-icons/openrouter-color.svg +1 -0
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/gui/dist/provider-icons/qianfan-color.svg +1 -0
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
- package/gui/dist/provider-icons/telegram.svg +1 -0
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/vllm-color.svg +1 -0
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
- package/package.json +108 -0
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic-image-guard.ts +251 -0
- package/src/adapters/anthropic-image-normalize.ts +518 -0
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +1327 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +121 -0
- package/src/adapters/client-fingerprint.ts +65 -0
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +601 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +104 -0
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/cursor-errors.ts +288 -0
- package/src/adapters/cursor/discovery.ts +333 -0
- package/src/adapters/cursor/effort-map.ts +151 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +250 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/kv-store.ts +52 -0
- package/src/adapters/cursor/live-models.ts +269 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +1653 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +333 -0
- package/src/adapters/cursor/message-mapper.ts +49 -0
- package/src/adapters/cursor/native-exec-common.ts +76 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +332 -0
- package/src/adapters/cursor/native-exec-mcp.ts +153 -0
- package/src/adapters/cursor/native-exec-network.ts +43 -0
- package/src/adapters/cursor/native-exec-shell.ts +547 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +663 -0
- package/src/adapters/cursor/protobuf-events.ts +1381 -0
- package/src/adapters/cursor/protobuf-request.ts +1032 -0
- package/src/adapters/cursor/request-builder.ts +461 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +735 -0
- package/src/adapters/cursor/tool-result-normalize.ts +92 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +79 -0
- package/src/adapters/cursor/types.ts +79 -0
- package/src/adapters/cursor.ts +322 -0
- package/src/adapters/google-antigravity-hosts.ts +48 -0
- package/src/adapters/google-antigravity-replay.ts +827 -0
- package/src/adapters/google-antigravity-tools.ts +105 -0
- package/src/adapters/google-antigravity-wire.ts +141 -0
- package/src/adapters/google-errors.ts +92 -0
- package/src/adapters/google-http.ts +460 -0
- package/src/adapters/google-tool-schema.ts +238 -0
- package/src/adapters/google-truncation.ts +24 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +1377 -0
- package/src/adapters/identity.ts +77 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +208 -0
- package/src/adapters/kiro-events.ts +197 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +112 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +224 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1936 -0
- package/src/adapters/mimo-free.ts +280 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1980 -0
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +1911 -0
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/run-turn-queue.ts +114 -0
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +154 -0
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/bridge.ts +1986 -0
- package/src/chat/inbound.ts +319 -0
- package/src/chat/outbound.ts +821 -0
- package/src/claude/agents-inject.ts +266 -0
- package/src/claude/alias.ts +149 -0
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/context-windows.ts +205 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p-paths.ts +84 -0
- package/src/claude/desktop-3p.ts +615 -0
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/gateway-cache.ts +107 -0
- package/src/claude/inbound-debug.ts +163 -0
- package/src/claude/inbound.ts +578 -0
- package/src/claude/model-info.ts +174 -0
- package/src/claude/outbound.ts +926 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +302 -0
- package/src/cli/account-auth.ts +250 -0
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +737 -0
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +299 -0
- package/src/cli/agent-driven.ts +70 -0
- package/src/cli/agent.ts +290 -0
- package/src/cli/catalog-prewarm.ts +27 -0
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +213 -0
- package/src/cli/claude.ts +355 -0
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +47 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +127 -0
- package/src/cli/config-command.ts +209 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/dispatch.ts +585 -0
- package/src/cli/doctor.ts +1202 -0
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +213 -0
- package/src/cli/help.ts +101 -0
- package/src/cli/index.ts +973 -0
- package/src/cli/init.ts +211 -0
- package/src/cli/integrations.ts +260 -0
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/lab.ts +607 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/minimax.ts +497 -0
- package/src/cli/models-runtime.ts +245 -0
- package/src/cli/models.ts +422 -0
- package/src/cli/observe.ts +206 -0
- package/src/cli/opencode.ts +588 -0
- package/src/cli/provider-replit.ts +232 -0
- package/src/cli/provider-runtime.ts +179 -0
- package/src/cli/provider.ts +492 -0
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +422 -0
- package/src/cli/replit-gateway-key-input.ts +138 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +328 -0
- package/src/cli/star-prompt.ts +211 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +328 -0
- package/src/cli/system-command.ts +112 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +199 -0
- package/src/cli/v2.ts +268 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +1704 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +47 -0
- package/src/codex/account-lifecycle.ts +172 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +195 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +544 -0
- package/src/codex/account-usability.ts +43 -0
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/agent-roles-sync.ts +225 -0
- package/src/codex/agent-roles.ts +238 -0
- package/src/codex/app-server-processes.ts +1143 -0
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +2147 -0
- package/src/codex/auth-collision.ts +109 -0
- package/src/codex/auth-context.ts +665 -0
- package/src/codex/autostart-health.ts +156 -0
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +436 -0
- package/src/codex/catalog/bundled.ts +549 -0
- package/src/codex/catalog/effort.ts +446 -0
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +664 -0
- package/src/codex/catalog/native-models.ts +72 -0
- package/src/codex/catalog/parsing.ts +650 -0
- package/src/codex/catalog/provider-fetch.ts +2064 -0
- package/src/codex/catalog/sync.ts +1883 -0
- package/src/codex/catalog-admission.ts +199 -0
- package/src/codex/catalog-refresh-status.ts +105 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +14 -0
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +651 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +1566 -0
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +407 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +108 -0
- package/src/codex/history-provider.ts +979 -0
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +220 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject-coordination.ts +290 -0
- package/src/codex/inject.ts +1733 -0
- package/src/codex/injected-marker.ts +106 -0
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +225 -0
- package/src/codex/log-guard/inspect.ts +506 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +88 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/main-account-cache.ts +56 -0
- package/src/codex/main-account.ts +68 -0
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +273 -0
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +178 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +492 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +682 -0
- package/src/codex/paths.ts +144 -0
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +295 -0
- package/src/codex/project-config-warnings.ts +425 -0
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +573 -0
- package/src/codex/refresh.ts +62 -0
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +1888 -0
- package/src/codex/runtime.ts +659 -0
- package/src/codex/shim.ts +2170 -0
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +784 -0
- package/src/codex/sync.ts +319 -0
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +298 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +160 -0
- package/src/combos/index.ts +45 -0
- package/src/combos/request.ts +94 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +398 -0
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +4041 -0
- package/src/fork/register.ts +3 -0
- package/src/generated/compatibility-version.json +3116 -0
- package/src/generated/model-metadata.ts +106 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +530 -0
- package/src/grok/inspect.ts +45 -0
- package/src/grok/status.ts +121 -0
- package/src/grok/sync.ts +66 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +149 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +955 -0
- package/src/images/plan.ts +143 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/index.ts +22 -0
- package/src/integrations/config-io.ts +269 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/omp-yaml-source.ts +358 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +159 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +361 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +691 -0
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +499 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +64 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +164 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +68 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +214 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +229 -0
- package/src/lab/events/validate.ts +781 -0
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +489 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +54 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +310 -0
- package/src/lab/ledger/store.ts +532 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +336 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +118 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +301 -0
- package/src/lab/observe/from-live.ts +117 -0
- package/src/lab/paths.ts +153 -0
- package/src/lab/projection/rebuild.ts +495 -0
- package/src/lab/projection/schema.ts +135 -0
- package/src/lab/projection/verdicts.ts +474 -0
- package/src/lab/projection/verification.ts +412 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +243 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lab/query/catalog.ts +101 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +45 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +112 -0
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +74 -0
- package/src/lib/abort.ts +146 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +195 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bounded-body.ts +346 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +184 -0
- package/src/lib/bun-stream-caps.ts +130 -0
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +364 -0
- package/src/lib/crash-guard.ts +344 -0
- package/src/lib/debug-log-buffer.ts +83 -0
- package/src/lib/debug-settings.ts +108 -0
- package/src/lib/debug.ts +31 -0
- package/src/lib/destination-policy.ts +380 -0
- package/src/lib/errors.ts +406 -0
- package/src/lib/eventstream-decoder.ts +253 -0
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/gcp-adc.ts +341 -0
- package/src/lib/injection-debug-log.ts +58 -0
- package/src/lib/lab-activation.ts +223 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +56 -0
- package/src/lib/lab-live-route-production.ts +130 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/local-management-capability.ts +100 -0
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/lib/open-url.ts +25 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/pinned-http.ts +270 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +168 -0
- package/src/lib/provider-outbound.ts +210 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +521 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/service-secrets.ts +25 -0
- package/src/lib/shadow-call.ts +61 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +364 -0
- package/src/lib/state-store-registrations.ts +119 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/token-estimate.ts +86 -0
- package/src/lib/tool-argument-integers.ts +202 -0
- package/src/lib/translator-budget.ts +400 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +392 -0
- package/src/lib/win-exec.ts +115 -0
- package/src/lib/win-paths.ts +68 -0
- package/src/lib/windows-atomic-replace.ts +156 -0
- package/src/lib/windows-elevation.ts +773 -0
- package/src/lib/windows-secret-acl.ts +854 -0
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +341 -0
- package/src/lib/winsw.ts +403 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
- package/src/oauth/account-import/index.ts +15 -0
- package/src/oauth/account-import/parser.ts +83 -0
- package/src/oauth/account-import/registry.ts +18 -0
- package/src/oauth/account-import/service.ts +75 -0
- package/src/oauth/account-import/types.ts +91 -0
- package/src/oauth/anthropic-routing.ts +594 -0
- package/src/oauth/anthropic.ts +188 -0
- package/src/oauth/antigravity-routing.ts +151 -0
- package/src/oauth/callback-server.ts +300 -0
- package/src/oauth/chatgpt.ts +161 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +252 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +262 -0
- package/src/oauth/health.ts +407 -0
- package/src/oauth/index.ts +1504 -0
- package/src/oauth/key-providers.ts +124 -0
- package/src/oauth/kimi.ts +227 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +621 -0
- package/src/oauth/local-token-detect.ts +130 -0
- package/src/oauth/log.ts +50 -0
- package/src/oauth/login-cli.ts +223 -0
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +728 -0
- package/src/oauth/token-guardian.ts +309 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -0
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +156 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/antigravity-models.ts +695 -0
- package/src/providers/antigravity-quota.ts +216 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +74 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +144 -0
- package/src/providers/context-cap.ts +82 -0
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +586 -0
- package/src/providers/fastwire.ts +501 -0
- package/src/providers/free-directory.ts +187 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +271 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +449 -0
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-sidecar.ts +243 -0
- package/src/providers/openai-tier-startup.ts +56 -0
- package/src/providers/openai-tiers.ts +423 -0
- package/src/providers/openai-virtual-models.ts +83 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +185 -0
- package/src/providers/quota.ts +2345 -0
- package/src/providers/registry.ts +2918 -0
- package/src/providers/replit/constants.ts +27 -0
- package/src/providers/replit/derive.ts +85 -0
- package/src/providers/replit/headers.ts +28 -0
- package/src/providers/replit/origin.ts +55 -0
- package/src/providers/replit/pair-install-response.ts +72 -0
- package/src/providers/replit/probe.ts +199 -0
- package/src/providers/replit/setup.ts +350 -0
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +103 -0
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/providers/xai-transport.ts +148 -0
- package/src/reasoning-effort.ts +183 -0
- package/src/responses/compaction.ts +142 -0
- package/src/responses/custom-tool-compat.ts +266 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +355 -0
- package/src/responses/parser.ts +838 -0
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +426 -0
- package/src/responses/schema.ts +165 -0
- package/src/responses/spill-store.ts +459 -0
- package/src/responses/state.ts +1433 -0
- package/src/responses/thought-signature-replay.ts +347 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/router.ts +761 -0
- package/src/routing/analytics.ts +378 -0
- package/src/routing/capability.ts +244 -0
- package/src/routing/compatibility/assemble.ts +73 -0
- package/src/routing/compatibility/behavior.ts +278 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +495 -0
- package/src/routing/health.ts +412 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +547 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +53 -0
- package/src/server/auth-cors.ts +751 -0
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +442 -0
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +426 -0
- package/src/server/claude-messages.ts +1030 -0
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +190 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +152 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +568 -0
- package/src/server/index.ts +1813 -0
- package/src/server/lifecycle.ts +498 -0
- package/src/server/live.ts +717 -0
- package/src/server/local-management-read-client.ts +90 -0
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/agent-settings-routes.ts +1433 -0
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/api-key-usage.ts +193 -0
- package/src/server/management/body.ts +41 -0
- package/src/server/management/combo-routes.ts +263 -0
- package/src/server/management/config-routes.ts +835 -0
- package/src/server/management/context.ts +113 -0
- package/src/server/management/integration-routes.ts +498 -0
- package/src/server/management/lab-automation-routes.ts +206 -0
- package/src/server/management/lab-routes.ts +563 -0
- package/src/server/management/logs-usage-routes.ts +586 -0
- package/src/server/management/model-routes.ts +560 -0
- package/src/server/management/model-rows.ts +163 -0
- package/src/server/management/native-integration-routes.ts +769 -0
- package/src/server/management/oauth-account-routes.ts +637 -0
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +1033 -0
- package/src/server/management/replit-provider-routes.ts +86 -0
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +385 -0
- package/src/server/management/shared.ts +286 -0
- package/src/server/management/sidebar-routes.ts +106 -0
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +435 -0
- package/src/server/management/system-routes.ts +194 -0
- package/src/server/management/usage-summary-cache.ts +94 -0
- package/src/server/management/vision-sidecar-options.ts +167 -0
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/management-api.ts +314 -0
- package/src/server/management-auth.ts +482 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +156 -0
- package/src/server/proxy-liveness.ts +328 -0
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +353 -0
- package/src/server/relay.ts +1209 -0
- package/src/server/request-decompress.ts +239 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1259 -0
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +465 -0
- package/src/server/responses/collaboration.ts +551 -0
- package/src/server/responses/compact.ts +771 -0
- package/src/server/responses/core.ts +5389 -0
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/encrypted-payload.ts +331 -0
- package/src/server/responses/fetch-helpers.ts +232 -0
- package/src/server/responses/input-admission.ts +185 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/passthrough-error.ts +78 -0
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +251 -0
- package/src/server/responses/terminal-guard.ts +251 -0
- package/src/server/responses/upstream-error.ts +53 -0
- package/src/server/responses/ws-upstream.ts +308 -0
- package/src/server/responses-custom-tool-repair.ts +282 -0
- package/src/server/responses-image-gen-repair.ts +132 -0
- package/src/server/responses-item-id-repair.ts +272 -0
- package/src/server/responses-json-events.ts +90 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses-terminal-repair.ts +342 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/responses.ts +25 -0
- package/src/server/search.ts +201 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +263 -0
- package/src/server/startup-action-control.ts +315 -0
- package/src/server/startup-health-cache.ts +131 -0
- package/src/server/system-env.ts +484 -0
- package/src/server/windows-tcp-drop.ts +184 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/server/ws-bridge.ts +472 -0
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +3575 -0
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/stall-timeout.ts +20 -0
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +3085 -0
- package/src/storage/policy-job.ts +457 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +59 -0
- package/src/storage/policy.ts +527 -0
- package/src/storage/restore-job.ts +299 -0
- package/src/storage/restore-worker.ts +58 -0
- package/src/storage/scanner.ts +238 -0
- package/src/storage/storage-mutation-coordinator.ts +139 -0
- package/src/storage/worker-lifecycle.ts +215 -0
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +364 -0
- package/src/tray/windows.ts +757 -0
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +876 -0
- package/src/types/provider.ts +545 -0
- package/src/types/request.ts +384 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +106 -0
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +415 -0
- package/src/update/job.ts +1887 -0
- package/src/update/notify.ts +263 -0
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +259 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +625 -0
- package/src/usage/debug.ts +97 -0
- package/src/usage/expected-prices.ts +416 -0
- package/src/usage/log.ts +1223 -0
- package/src/usage/summary.ts +753 -0
- package/src/usage/totals.ts +14 -0
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/anthropic-describe.ts +189 -0
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +131 -0
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +681 -0
- package/src/vision/reasoning.ts +55 -0
- package/src/vision/routed-describe.ts +175 -0
- package/src/vision/timeout-bounds.ts +9 -0
- package/src/web-search/anthropic-executor.ts +195 -0
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +88 -0
- package/src/web-search/executor.ts +113 -0
- package/src/web-search/format-result.ts +89 -0
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +331 -0
- package/src/web-search/loop.ts +896 -0
- package/src/web-search/parse.ts +315 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/synthetic-tool.ts +47 -0
- package/src/web-search/xai-executor.ts +219 -0
|
@@ -0,0 +1,1887 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
atomicWriteFile,
|
|
7
|
+
getConfigDir,
|
|
8
|
+
loadConfig,
|
|
9
|
+
readPid,
|
|
10
|
+
readRuntimePort,
|
|
11
|
+
removePid,
|
|
12
|
+
removeRuntimePort,
|
|
13
|
+
verifyPidIdentity,
|
|
14
|
+
} from "../config";
|
|
15
|
+
import { isProcessAlive, killProxy } from "../lib/process-control";
|
|
16
|
+
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
17
|
+
import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers";
|
|
18
|
+
import {
|
|
19
|
+
buildWindowsElevatedArgumentList,
|
|
20
|
+
resolveTrustedWindowsPowerShellExe,
|
|
21
|
+
} from "../lib/windows-elevation";
|
|
22
|
+
import { stopWinswService } from "../lib/winsw";
|
|
23
|
+
import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } from "../server/port-reclaim";
|
|
24
|
+
import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop";
|
|
25
|
+
import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness";
|
|
26
|
+
import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service";
|
|
27
|
+
import {
|
|
28
|
+
type Channel,
|
|
29
|
+
type Installer,
|
|
30
|
+
PKG,
|
|
31
|
+
checkUpdatePackageIntegrity,
|
|
32
|
+
currentVersion,
|
|
33
|
+
defaultUpdateTag,
|
|
34
|
+
detectInstall,
|
|
35
|
+
latestVersion,
|
|
36
|
+
updateCommand,
|
|
37
|
+
updateCommandStr,
|
|
38
|
+
} from "./index";
|
|
39
|
+
import { isNewer } from "./notify";
|
|
40
|
+
import { isRealBunBinary } from "../lib/bun-binary-validator.mjs";
|
|
41
|
+
import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
|
|
42
|
+
import {
|
|
43
|
+
npmCachePreflightFailureMessage,
|
|
44
|
+
runNpmCachePreflight,
|
|
45
|
+
type NpmCachePreflightReason,
|
|
46
|
+
} from "./npm-cache-preflight.mjs";
|
|
47
|
+
|
|
48
|
+
const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest";
|
|
49
|
+
const UPDATE_JOB_FILENAME = "update-job.json";
|
|
50
|
+
const UPDATE_TIMEOUT_MS = 180_000;
|
|
51
|
+
const RESTART_TIMEOUT_MS = 60_000;
|
|
52
|
+
const RESTART_HEALTH_TIMEOUT_MS = 30_000;
|
|
53
|
+
const RESTART_STABILITY_WINDOW_MS = 15_000;
|
|
54
|
+
/** Legacy active records did not persist a worker PID, so age is their only safe recovery signal. */
|
|
55
|
+
export const UPDATE_JOB_LEGACY_STALE_MS = 10 * 60_000;
|
|
56
|
+
/** How long update restart waits for the captured port to become bindable after stop. */
|
|
57
|
+
export const RESTART_PORT_RECLAIM_MS = 30_000;
|
|
58
|
+
|
|
59
|
+
export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed";
|
|
60
|
+
|
|
61
|
+
export interface UpdateCheckResult {
|
|
62
|
+
currentVersion: string;
|
|
63
|
+
latestVersion: string | null;
|
|
64
|
+
channel: Channel;
|
|
65
|
+
installer: Installer;
|
|
66
|
+
updateAvailable: boolean;
|
|
67
|
+
canUpdate: boolean;
|
|
68
|
+
command: string;
|
|
69
|
+
releaseNotesUrl: string;
|
|
70
|
+
reason?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface UpdateJobState {
|
|
74
|
+
id: string;
|
|
75
|
+
status: UpdateJobStatus;
|
|
76
|
+
startedAt: string;
|
|
77
|
+
updatedAt: string;
|
|
78
|
+
currentVersion: string;
|
|
79
|
+
latestVersion: string | null;
|
|
80
|
+
channel: Channel;
|
|
81
|
+
installer: Installer;
|
|
82
|
+
restart: boolean;
|
|
83
|
+
command: string;
|
|
84
|
+
releaseNotesUrl: string;
|
|
85
|
+
log: string[];
|
|
86
|
+
pid?: number;
|
|
87
|
+
error?: string;
|
|
88
|
+
exitCode?: number | null;
|
|
89
|
+
signal?: string | null;
|
|
90
|
+
restarted?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class UpdateJobError extends Error {
|
|
94
|
+
constructor(message: string, readonly status = 400, readonly code = "update_error") {
|
|
95
|
+
super(message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface UpdateCheckDeps {
|
|
100
|
+
currentVersion: () => string;
|
|
101
|
+
detectInstall: () => Installer;
|
|
102
|
+
latestVersion: (tag: Channel) => string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface UpdateWorkerProcess {
|
|
106
|
+
pid?: number;
|
|
107
|
+
unref(): void;
|
|
108
|
+
once(event: "error", listener: (error: Error) => void): unknown;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface StartUpdateJobDeps {
|
|
112
|
+
checkForUpdateFn: (channel: Channel) => UpdateCheckResult;
|
|
113
|
+
spawnWorkerFn: (jobId: string, channel: Channel, restart: boolean) => UpdateWorkerProcess;
|
|
114
|
+
isProcessAliveFn: (pid: number) => boolean;
|
|
115
|
+
nowMs: () => number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const defaultCheckDeps: UpdateCheckDeps = {
|
|
119
|
+
currentVersion,
|
|
120
|
+
detectInstall,
|
|
121
|
+
latestVersion,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function nodeBin(): string {
|
|
125
|
+
return process.platform === "win32" ? "node.exe" : "node";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Strict bind script: exit 0 only after listen+close. Any listen error (including
|
|
130
|
+
* Windows ghost-TCB failures under Bun) is busy — matches published `ocx start`
|
|
131
|
+
* probes that treat every listen error as unavailable.
|
|
132
|
+
*/
|
|
133
|
+
function strictBindProbeScript(port: number, hostname: string): string {
|
|
134
|
+
return [
|
|
135
|
+
"const net=require('net');",
|
|
136
|
+
"const s=net.createServer();",
|
|
137
|
+
"s.once('error',()=>process.exit(2));",
|
|
138
|
+
`s.listen(${Math.trunc(port)},${JSON.stringify(hostname)},()=>s.close(()=>process.exit(0)));`,
|
|
139
|
+
"setTimeout(()=>process.exit(3),2500);",
|
|
140
|
+
].join("");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function spawnBindProbe(bin: string, script: string): boolean {
|
|
144
|
+
try {
|
|
145
|
+
const r = spawnSync(bin, ["-e", script], {
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
timeout: 4000,
|
|
148
|
+
stdio: "ignore",
|
|
149
|
+
});
|
|
150
|
+
return r.status === 0;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Live global package Bun — not the npm rename tree the update worker may still
|
|
158
|
+
* be executing from (`@bitkyc08/.opencodex-*`). Reject the tiny postinstall
|
|
159
|
+
* stub so probes fall back to the worker runtime instead of failing forever.
|
|
160
|
+
*/
|
|
161
|
+
function livePackageBunPath(): string | null {
|
|
162
|
+
const launcher = packageLauncherPath();
|
|
163
|
+
const root = join(dirname(launcher), "..");
|
|
164
|
+
for (const name of ["bun.exe", "bun"]) {
|
|
165
|
+
const candidate = join(root, "node_modules", "bun", "bin", name);
|
|
166
|
+
if (isRealBunBinary(candidate)) return candidate;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Port is free for post-update `ocx start` only when the runtime that will
|
|
173
|
+
* actually execute the start can bind. Prefer live package Bun; fall back to the
|
|
174
|
+
* worker runtime. Do not require a separate `node` binary (Bun-only installs).
|
|
175
|
+
*/
|
|
176
|
+
async function strictRuntimePortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
|
|
177
|
+
const script = strictBindProbeScript(port, hostname);
|
|
178
|
+
const bun = livePackageBunPath();
|
|
179
|
+
if (bun) return spawnBindProbe(bun, script);
|
|
180
|
+
return spawnBindProbe(process.execPath, script);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Wait until netstat reports no LISTEN owners on `port` AND the start runtime
|
|
185
|
+
* can bind. Dead PIDs still appear as holders while the ghost TCB lives;
|
|
186
|
+
* SetTcpEntry is a no-op without elevation (rc 317), so wait them out.
|
|
187
|
+
*/
|
|
188
|
+
async function waitForGhostListenClear(
|
|
189
|
+
port: number,
|
|
190
|
+
hostname: string,
|
|
191
|
+
listPids: (port: number) => number[],
|
|
192
|
+
timeoutMs: number,
|
|
193
|
+
sleep: (ms: number) => Promise<void>,
|
|
194
|
+
aliveFn: (pid: number) => boolean = isProcessAlive,
|
|
195
|
+
): Promise<{ ok: boolean; accessDenied: boolean }> {
|
|
196
|
+
const deadline = Date.now() + timeoutMs;
|
|
197
|
+
let accessDenied = false;
|
|
198
|
+
while (Date.now() < deadline) {
|
|
199
|
+
const holders = listPids(port).filter(pid => pid !== process.pid);
|
|
200
|
+
const liveHolders = holders.filter(pid => aliveFn(pid));
|
|
201
|
+
// Never SetTcpEntry while a live process still owns the port (foreign or ocx).
|
|
202
|
+
if (process.platform === "win32" && liveHolders.length === 0) {
|
|
203
|
+
try {
|
|
204
|
+
const drop = dropWindowsTcpRowsForLocalPort(port);
|
|
205
|
+
if (drop.accessDenied > 0) accessDenied = true;
|
|
206
|
+
} catch { /* best-effort */ }
|
|
207
|
+
}
|
|
208
|
+
if (holders.length === 0 && await strictRuntimePortAvailable(port, hostname)) {
|
|
209
|
+
return { ok: true, accessDenied };
|
|
210
|
+
}
|
|
211
|
+
await sleep(500);
|
|
212
|
+
}
|
|
213
|
+
return { ok: false, accessDenied };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function packageLauncherPath(): string {
|
|
217
|
+
// This module lives at src/update/job.ts — the launcher is <pkg-root>/bin/ocx.mjs.
|
|
218
|
+
// After `npm install -g`, import.meta.url can still point at npm's renamed temp
|
|
219
|
+
// tree (`@bitkyc08/.opencodex-*`). Prefer the live package path when that happens.
|
|
220
|
+
const fromMeta = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "ocx.mjs");
|
|
221
|
+
if (!/[\\/]\.opencodex-/i.test(fromMeta) && existsSync(fromMeta)) return fromMeta;
|
|
222
|
+
const live = fromMeta.replace(/[\\/]@([^\\/]+)[\\/]\.opencodex-[^\\/]+/i, `${sep}@$1${sep}opencodex`);
|
|
223
|
+
if (live !== fromMeta && existsSync(live)) return live;
|
|
224
|
+
return fromMeta;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function formatCommand(bin: string, args: string[]): string {
|
|
228
|
+
return `${bin} ${args.join(" ")}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function manualSourceCommand(): string {
|
|
232
|
+
return "git pull && bun install && bun run build:gui";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function normalizeUpdateChannel(raw: string | null | undefined, current = currentVersion()): Channel {
|
|
236
|
+
return raw === "latest" || raw === "preview" ? raw : defaultUpdateTag(current);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function updateJobPath(): string {
|
|
240
|
+
return join(getConfigDir(), UPDATE_JOB_FILENAME);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function ensureJobDir(): void {
|
|
244
|
+
const dir = getConfigDir();
|
|
245
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Describe external text without reproducing it.
|
|
250
|
+
*
|
|
251
|
+
* Use this wherever an `Error.message`, a vendor stream, or any string this module did not
|
|
252
|
+
* compose would otherwise be interpolated into a persisted field. The result names the error's
|
|
253
|
+
* TYPE and size — enough to tell a reader what class of failure occurred — and never its text,
|
|
254
|
+
* which is where the paths and account names live.
|
|
255
|
+
*/
|
|
256
|
+
/**
|
|
257
|
+
* A version string we are willing to repeat in a persisted field.
|
|
258
|
+
*
|
|
259
|
+
* Semver plus an optional prerelease/build tail, capped in length. Anything else is dropped
|
|
260
|
+
* rather than logged: `/healthz` is answered by whatever holds the port, so its `version` is
|
|
261
|
+
* external input on the same footing as an error message.
|
|
262
|
+
*/
|
|
263
|
+
function isVersionLike(value: unknown): value is string {
|
|
264
|
+
return typeof value === "string"
|
|
265
|
+
&& value.length <= 64
|
|
266
|
+
&& /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function withheldSummary(error: unknown): string {
|
|
270
|
+
// `error.name` is writable, so it is external text like the message. A fixed classification
|
|
271
|
+
// is the only part of an unknown error we can state without repeating something we were
|
|
272
|
+
// handed: `new Error(...)` with `error.name = "Jane Doe"` was persisting the name verbatim.
|
|
273
|
+
const name = error instanceof Error ? "Error" : typeof error;
|
|
274
|
+
// NO MESSAGE TEXT, ever. An earlier version kept messages that carried no path, which sounds
|
|
275
|
+
// reasonable and is wrong: `spawn denied for Jane Doe` has no path in it and still names a
|
|
276
|
+
// person. There is no test on message CONTENT that separates a diagnostic from an identity,
|
|
277
|
+
// so the message does not cross this boundary at all.
|
|
278
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
279
|
+
// Only recognized codes — an arbitrary uppercase `error.code` can be attacker-shaped too.
|
|
280
|
+
const codeNote = typeof code === "string" && NPM_ERROR_CODES.has(code) ? ` ${code}` : "";
|
|
281
|
+
const text = error instanceof Error ? error.message : String(error ?? "");
|
|
282
|
+
// Node's own errors are structured the same way npm's output is: `syscall` and `errno` are
|
|
283
|
+
// named properties, not prose. Reading those gives a user the actual cause —
|
|
284
|
+
// `Error EACCES · syscall: mkdir · errno: -13` — without repeating a message that could name
|
|
285
|
+
// a person or a path. Both are shape-validated: a syscall is a short lowercase identifier and
|
|
286
|
+
// an errno is an integer, so neither can carry arbitrary text.
|
|
287
|
+
const parts = [`${name}${codeNote}`];
|
|
288
|
+
const syscall = (error as { syscall?: unknown } | null)?.syscall;
|
|
289
|
+
// Same explicit vocabulary as the npm field: a shape check accepts `janedoe`.
|
|
290
|
+
if (typeof syscall === "string" && POSIX_SYSCALLS.has(syscall)) parts.push(`syscall: ${syscall}`);
|
|
291
|
+
const errno = (error as { errno?: unknown } | null)?.errno;
|
|
292
|
+
if (typeof errno === "number" && Number.isInteger(errno)) parts.push(`errno: ${errno}`);
|
|
293
|
+
parts.push(`${Buffer.byteLength(text, "utf8")} bytes withheld`);
|
|
294
|
+
return parts.join(" · ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Decide, per field, whether the value is ours to keep.
|
|
299
|
+
*
|
|
300
|
+
* `log` and `error` are composed from this module's own templates; every place that would have
|
|
301
|
+
* interpolated external text now calls `withheldSummary()` first, so the strings arriving here
|
|
302
|
+
* are ours by construction. `releaseNotesUrl` is compared against the module constant rather
|
|
303
|
+
* than pattern-matched, which is what stops a URL-shaped value from smuggling a path.
|
|
304
|
+
* `command` is rendered from validated parts.
|
|
305
|
+
*/
|
|
306
|
+
function brandOwnComposedText(key: string, value: unknown): unknown {
|
|
307
|
+
if (key === "releaseNotesUrl") {
|
|
308
|
+
return value === RELEASE_NOTES_URL ? value : "";
|
|
309
|
+
}
|
|
310
|
+
if (key === "command") {
|
|
311
|
+
// Render the command shape first, then apply the same path test as every other field. The
|
|
312
|
+
// renderer only understands space-separated arguments; anything else reaching this field is
|
|
313
|
+
// not a command we built and must not be trusted because of where it was stored.
|
|
314
|
+
return typeof value === "string" ? withholdIfPathBearing(renderSafeCommand(value)) : value;
|
|
315
|
+
}
|
|
316
|
+
// `log` and `error` are ours by construction, but a caller can still slip external text in by
|
|
317
|
+
// interpolating it. Withhold any value that carries an absolute path of any form — that is a
|
|
318
|
+
// narrow, unambiguous test on strings we already control, not the free-text classification
|
|
319
|
+
// that failed nine times.
|
|
320
|
+
if (typeof value === "string") return withholdIfPathBearing(value);
|
|
321
|
+
if (Array.isArray(value)) return value.map(item => (typeof item === "string" ? withholdIfPathBearing(item) : item));
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Absolute paths cannot appear in text this module composed; if one does, it came from outside. */
|
|
326
|
+
function withholdIfPathBearing(value: string): string {
|
|
327
|
+
const pathBearing = /[A-Za-z]:[\\/]/.test(value) // C:\ or C:/
|
|
328
|
+
|| /\\\\/.test(value) // \\server\share
|
|
329
|
+
|| /\\/.test(value) // any backslash
|
|
330
|
+
|| /~[\w.-]*\//.test(value) // ~/ or ~user/ anywhere
|
|
331
|
+
|| /[%$][A-Za-z_]/.test(value) // %APPDATA%, $HOME
|
|
332
|
+
|| /\/[\w.\-~%]+\//.test(value) // any two-segment path run
|
|
333
|
+
|| /\b(?:Users|home|Documents and Settings|AppData|Profiles)\b/i.test(value)
|
|
334
|
+
|| /\r?\n/.test(value); // multi-line vendor output
|
|
335
|
+
if (!pathBearing) return value;
|
|
336
|
+
return `<withheld: ${Buffer.byteLength(value, "utf8")} bytes, may contain local paths>`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Keep a command readable without persisting the launcher path it contains.
|
|
341
|
+
*
|
|
342
|
+
* The real npm worker command is `node /Users/<name>/.../bin/ocx.mjs update --tag latest`, so
|
|
343
|
+
* the account name is inside it by construction. Absolute path arguments are replaced with a
|
|
344
|
+
* placeholder and everything else — the binary name, the flags, the tag — is kept, which is the
|
|
345
|
+
* part a reader actually needs.
|
|
346
|
+
*/
|
|
347
|
+
function renderSafeCommand(value: string): string {
|
|
348
|
+
if (!value) return value;
|
|
349
|
+
// Rebuild from a recognized shape rather than filtering the string we were handed. Content
|
|
350
|
+
// cannot distinguish `npm install Mary-Jane` — an account name — from a legitimate package
|
|
351
|
+
// argument, so anything that is not this exact shape is withheld by the caller's path test.
|
|
352
|
+
const parts = value.trim().split(/\s+/);
|
|
353
|
+
const tool = parts[0] === "$" ? parts[1] : parts[0];
|
|
354
|
+
if (tool !== undefined && /^(?:npm|bun|pnpm|yarn|node)$/.test(tool)) {
|
|
355
|
+
const rendered = parts.map(part =>
|
|
356
|
+
/^(?:[A-Za-z]:[\\/]|[\\/]|~|\\\\)/.test(part) ? "<path>" : part);
|
|
357
|
+
// Only fixed flags, our own package spec, and placeholders survive; a bare word that is not
|
|
358
|
+
// one of those is treated as unknown input and the whole value is withheld.
|
|
359
|
+
const allowed = rendered.every(part =>
|
|
360
|
+
part === "$" || part === "<path>"
|
|
361
|
+
|| /^(?:npm|bun|pnpm|yarn|node)$/.test(part)
|
|
362
|
+
|| /^-{1,2}[\w-]+$/.test(part)
|
|
363
|
+
|| /^(?:install|add|update|i)$/.test(part)
|
|
364
|
+
|| /^opencodex(?:@[\w.\-]+)?$/.test(part)
|
|
365
|
+
|| /^(?:latest|preview|next|beta)$/.test(part)
|
|
366
|
+
|| /^\d[\w.\-]*$/.test(part));
|
|
367
|
+
if (allowed) return rendered.join(" ");
|
|
368
|
+
}
|
|
369
|
+
return `<withheld: ${Buffer.byteLength(value, "utf8")} bytes, unrecognized command shape>`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Fields that can carry free-form text and therefore need checking at the write boundary.
|
|
374
|
+
*
|
|
375
|
+
* The rest of the record is a closed vocabulary — statuses, channels, installers, versions, an
|
|
376
|
+
* id, timestamps — so checking it only risks mangling values that were never a disclosure
|
|
377
|
+
* route. Naming the risky fields keeps the boundary narrow and auditable.
|
|
378
|
+
*/
|
|
379
|
+
const FREE_TEXT_JOB_FIELDS = new Set(["command", "error", "log", "releaseNotesUrl"]);
|
|
380
|
+
|
|
381
|
+
/** Apply the per-field rule at the single point where a job reaches disk. */
|
|
382
|
+
function sanitizePersistedUpdateJob(job: UpdateJobState): UpdateJobState {
|
|
383
|
+
return Object.fromEntries(
|
|
384
|
+
Object.entries(job).map(([key, item]) => [
|
|
385
|
+
key,
|
|
386
|
+
FREE_TEXT_JOB_FIELDS.has(key) ? brandOwnComposedText(key, item) : item,
|
|
387
|
+
]),
|
|
388
|
+
) as UpdateJobState;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function writeJob(job: UpdateJobState): void {
|
|
392
|
+
ensureJobDir();
|
|
393
|
+
atomicWriteFile(updateJobPath(), `${JSON.stringify(sanitizePersistedUpdateJob(job), null, 2)}\n`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function readUpdateJob(jobId?: string | null): UpdateJobState | null {
|
|
397
|
+
try {
|
|
398
|
+
const parsed = JSON.parse(readFileSync(updateJobPath(), "utf8")) as UpdateJobState;
|
|
399
|
+
if (jobId && parsed.id !== jobId) return null;
|
|
400
|
+
if (!parsed || typeof parsed.id !== "string" || typeof parsed.status !== "string") return null;
|
|
401
|
+
return parsed;
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Log lines are composed by this module, so brand them here rather than at nineteen call sites.
|
|
409
|
+
*
|
|
410
|
+
* The one thing a caller must never do is interpolate external text into a log line — an
|
|
411
|
+
* `Error.message`, a vendor stream, a path we were handed. Those go through
|
|
412
|
+
* `withheldSummary()`, which produces a branded description WITHOUT the text itself.
|
|
413
|
+
*/
|
|
414
|
+
function updateJob(job: UpdateJobState, patch: Partial<UpdateJobState>, logLine?: string): UpdateJobState {
|
|
415
|
+
const current = readUpdateJob(job.id) ?? job;
|
|
416
|
+
const next = {
|
|
417
|
+
...current,
|
|
418
|
+
...patch,
|
|
419
|
+
updatedAt: new Date().toISOString(),
|
|
420
|
+
log: logLine ? [...current.log, logLine] : current.log,
|
|
421
|
+
};
|
|
422
|
+
writeJob(next);
|
|
423
|
+
return next;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function updateExecutionCommand(
|
|
427
|
+
installer: Installer,
|
|
428
|
+
channel: Channel,
|
|
429
|
+
launcher = packageLauncherPath(),
|
|
430
|
+
resolvedVersion?: string | null,
|
|
431
|
+
): { bin: string; args: string[]; display: string } {
|
|
432
|
+
if (installer === "npm") {
|
|
433
|
+
const bin = nodeBin();
|
|
434
|
+
const args = [launcher, "update", "--tag", channel];
|
|
435
|
+
// The Node launcher self-update re-resolves the tag at its own time — a residual
|
|
436
|
+
// divergence window this path cannot close (documented, not claimed immutable).
|
|
437
|
+
return { bin, args, display: formatCommand(bin, args) };
|
|
438
|
+
}
|
|
439
|
+
if (installer === "bun") {
|
|
440
|
+
const command = updateCommand(installer, channel, resolvedVersion);
|
|
441
|
+
const bin = process.platform === "win32" ? process.execPath : command.bin;
|
|
442
|
+
const { args } = command;
|
|
443
|
+
return { bin, args, display: updateCommandStr(installer, channel, resolvedVersion) };
|
|
444
|
+
}
|
|
445
|
+
return { bin: "sh", args: ["-lc", manualSourceCommand()], display: manualSourceCommand() };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function restartCommand(
|
|
449
|
+
serviceInstalled: boolean,
|
|
450
|
+
installer: Installer,
|
|
451
|
+
launcher = packageLauncherPath(),
|
|
452
|
+
port?: number,
|
|
453
|
+
serviceArgs?: string[],
|
|
454
|
+
): { mode: "service" | "proxy"; bin: string; args: string[]; display: string } {
|
|
455
|
+
const mode = serviceInstalled ? "service" : "proxy";
|
|
456
|
+
const pinPort = !serviceInstalled && typeof port === "number" && Number.isFinite(port) && port > 0;
|
|
457
|
+
const startArgs = pinPort
|
|
458
|
+
? [launcher, "start", "--port", String(Math.trunc(port))]
|
|
459
|
+
: [launcher, "start"];
|
|
460
|
+
// Default to the non-registering refresh: an update path reaching here has an already
|
|
461
|
+
// installed service, and `install` would demand elevation on Windows scheduler backends.
|
|
462
|
+
const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "repair"])] : startArgs;
|
|
463
|
+
if (installer === "npm") {
|
|
464
|
+
const bin = nodeBin();
|
|
465
|
+
const args = svcArgs;
|
|
466
|
+
return { mode, bin, args, display: formatCommand(bin, args) };
|
|
467
|
+
}
|
|
468
|
+
// bun/source installs: restart via the current runtime executable + package launcher (both real
|
|
469
|
+
// .exe files), NOT the `ocx.cmd` shim. Spawning a `.cmd` shell-less throws EINVAL on Windows
|
|
470
|
+
// Node/Bun ≥18.20/20.12 (CVE-2024-27980 hardening) — the same class the npm path (nodeBin) avoids.
|
|
471
|
+
const bin = process.execPath;
|
|
472
|
+
const args = svcArgs;
|
|
473
|
+
return { mode, bin, args, display: formatCommand(bin, args) };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function checkForUpdate(
|
|
477
|
+
requestedChannel?: Channel,
|
|
478
|
+
deps: UpdateCheckDeps = defaultCheckDeps,
|
|
479
|
+
): UpdateCheckResult {
|
|
480
|
+
const current = deps.currentVersion();
|
|
481
|
+
const installer = deps.detectInstall();
|
|
482
|
+
const channel = requestedChannel ?? normalizeUpdateChannel(null, current);
|
|
483
|
+
const latest = installer === "source" ? null : deps.latestVersion(channel);
|
|
484
|
+
const updateAvailable = !!latest && isNewer(latest, current, channel);
|
|
485
|
+
let reason: string | undefined;
|
|
486
|
+
let command = installer === "source" ? manualSourceCommand() : updateExecutionCommand(installer, channel).display;
|
|
487
|
+
|
|
488
|
+
if (installer === "source") {
|
|
489
|
+
reason = "source_checkout";
|
|
490
|
+
command = manualSourceCommand();
|
|
491
|
+
} else if (!latest) {
|
|
492
|
+
reason = "latest_unavailable";
|
|
493
|
+
} else if (!updateAvailable) {
|
|
494
|
+
reason = "already_latest";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
currentVersion: current,
|
|
499
|
+
latestVersion: latest,
|
|
500
|
+
channel,
|
|
501
|
+
installer,
|
|
502
|
+
updateAvailable,
|
|
503
|
+
canUpdate: installer !== "source" && updateAvailable,
|
|
504
|
+
command,
|
|
505
|
+
releaseNotesUrl: RELEASE_NOTES_URL,
|
|
506
|
+
...(reason ? { reason } : {}),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function newJobId(): string {
|
|
511
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* [Decision Log]
|
|
516
|
+
* - Purpose: recover dashboard updates after a detached worker dies without unlocking concurrent live updates.
|
|
517
|
+
* - Existing constraints: legacy records have no PID, while a healthy update may legitimately run for minutes.
|
|
518
|
+
* - Alternatives considered: clear every active record by age, or require operators to delete the file manually.
|
|
519
|
+
* - Chosen approach: trust PID liveness first and use a conservative age limit only for legacy no-PID records.
|
|
520
|
+
* - Why: age-only recovery can start two installers, while never recovering leaves the dashboard permanently blocked.
|
|
521
|
+
* - Impact: live PID records remain locked regardless of age; dead PIDs recover immediately; legacy records recover after ten minutes.
|
|
522
|
+
*/
|
|
523
|
+
export function staleActiveUpdateJobReason(
|
|
524
|
+
job: Pick<UpdateJobState, "status" | "pid" | "updatedAt">,
|
|
525
|
+
now = Date.now(),
|
|
526
|
+
isAlive: (pid: number) => boolean = isProcessAlive,
|
|
527
|
+
): string | null {
|
|
528
|
+
if (job.status !== "running" && job.status !== "restarting") return null;
|
|
529
|
+
if (typeof job.pid === "number" && Number.isSafeInteger(job.pid) && job.pid > 0) {
|
|
530
|
+
return isAlive(job.pid) ? null : `update worker PID ${job.pid} is no longer running`;
|
|
531
|
+
}
|
|
532
|
+
const updatedAt = Date.parse(job.updatedAt);
|
|
533
|
+
if (Number.isFinite(updatedAt) && now - updatedAt >= UPDATE_JOB_LEGACY_STALE_MS) {
|
|
534
|
+
return "legacy active update record has no worker PID and exceeded the stale window";
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Spawn the GUI update worker without inheriting the proxy's LISTEN socket.
|
|
541
|
+
*
|
|
542
|
+
* On Windows, `spawn(..., { detached: true, stdio: "ignore" })` still inherits
|
|
543
|
+
* inheritable handles — including Bun.serve's LISTEN socket. After stop-first
|
|
544
|
+
* update kills the proxy PID, netstat keeps showing that dead PID as LISTENING
|
|
545
|
+
* until every inheriting child exits. The update worker was that child, so the
|
|
546
|
+
* port stayed busy for the whole job. Launch via PowerShell Start-Process so
|
|
547
|
+
* the worker is a fresh process tree with no inherited LISTEN handle.
|
|
548
|
+
*/
|
|
549
|
+
export function spawnGuiUpdateWorker(
|
|
550
|
+
jobId: string,
|
|
551
|
+
channel: Channel,
|
|
552
|
+
restart: boolean,
|
|
553
|
+
): UpdateWorkerProcess {
|
|
554
|
+
const args = selfLaunchArgv([
|
|
555
|
+
"__gui-update-worker",
|
|
556
|
+
jobId,
|
|
557
|
+
channel,
|
|
558
|
+
restart ? "restart" : "no-restart",
|
|
559
|
+
]);
|
|
560
|
+
if (process.platform !== "win32") {
|
|
561
|
+
return spawn(process.execPath, args, {
|
|
562
|
+
detached: true,
|
|
563
|
+
stdio: "ignore",
|
|
564
|
+
windowsHide: true,
|
|
565
|
+
env: { ...process.env, OCX_SERVICE: "1" },
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Single -ArgumentList string with CommandLineToArgvW quoting so paths with
|
|
570
|
+
// spaces survive Start-Process's space-join (array elements lose outer quotes).
|
|
571
|
+
const psQuote = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
572
|
+
const argumentList = buildWindowsElevatedArgumentList(args);
|
|
573
|
+
const ps = [
|
|
574
|
+
`$env:OCX_SERVICE = '1'`,
|
|
575
|
+
`$p = Start-Process -FilePath ${psQuote(process.execPath)} -ArgumentList ${psQuote(argumentList)} -WindowStyle Hidden -PassThru`,
|
|
576
|
+
`if (-not $p) { exit 1 }`,
|
|
577
|
+
`Write-Output $p.Id`,
|
|
578
|
+
].join("; ");
|
|
579
|
+
const launched = spawnSync(
|
|
580
|
+
resolveTrustedWindowsPowerShellExe(),
|
|
581
|
+
["-NoProfile", "-NoLogo", "-NonInteractive", "-Command", ps],
|
|
582
|
+
{ encoding: "utf8", windowsHide: true, timeout: 15_000 },
|
|
583
|
+
);
|
|
584
|
+
const pid = Number(String(launched.stdout ?? "").trim().split(/\r?\n/).pop());
|
|
585
|
+
if (launched.status !== 0 || !Number.isSafeInteger(pid) || pid <= 0) {
|
|
586
|
+
const detail = String(launched.stderr ?? launched.stdout ?? "").trim() || `status ${launched.status}`;
|
|
587
|
+
throw new Error(`Windows update worker Start-Process failed: ${detail}`);
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
pid,
|
|
591
|
+
unref() { /* Start-Process already detached */ },
|
|
592
|
+
once() { /* startup errors are not wired across Start-Process */ },
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const defaultStartUpdateJobDeps: StartUpdateJobDeps = {
|
|
597
|
+
checkForUpdateFn: channel => checkForUpdate(channel),
|
|
598
|
+
spawnWorkerFn: spawnGuiUpdateWorker,
|
|
599
|
+
isProcessAliveFn: isProcessAlive,
|
|
600
|
+
nowMs: Date.now,
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
export function startUpdateJob(
|
|
604
|
+
channel: Channel,
|
|
605
|
+
restart: boolean,
|
|
606
|
+
deps: Partial<StartUpdateJobDeps> = {},
|
|
607
|
+
): UpdateJobState {
|
|
608
|
+
const resolvedDeps = { ...defaultStartUpdateJobDeps, ...deps };
|
|
609
|
+
const running = readUpdateJob();
|
|
610
|
+
if (running?.status === "running" || running?.status === "restarting") {
|
|
611
|
+
const staleReason = staleActiveUpdateJobReason(
|
|
612
|
+
running,
|
|
613
|
+
resolvedDeps.nowMs(),
|
|
614
|
+
resolvedDeps.isProcessAliveFn,
|
|
615
|
+
);
|
|
616
|
+
if (!staleReason) {
|
|
617
|
+
throw new UpdateJobError("An update job is already running", 409, "update_already_running");
|
|
618
|
+
}
|
|
619
|
+
updateJob(
|
|
620
|
+
running,
|
|
621
|
+
{ status: "failed", error: `Recovered stale update job: ${staleReason}.`, exitCode: null },
|
|
622
|
+
`Recovered stale update job: ${staleReason}.`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const check = resolvedDeps.checkForUpdateFn(channel);
|
|
627
|
+
if (!check.canUpdate) {
|
|
628
|
+
throw new UpdateJobError(check.reason ?? "No update is available", 409, check.reason ?? "update_unavailable");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const id = newJobId();
|
|
632
|
+
const now = new Date(resolvedDeps.nowMs()).toISOString();
|
|
633
|
+
const job: UpdateJobState = {
|
|
634
|
+
id,
|
|
635
|
+
status: "running",
|
|
636
|
+
startedAt: now,
|
|
637
|
+
updatedAt: now,
|
|
638
|
+
currentVersion: check.currentVersion,
|
|
639
|
+
latestVersion: check.latestVersion,
|
|
640
|
+
channel: check.channel,
|
|
641
|
+
installer: check.installer,
|
|
642
|
+
restart,
|
|
643
|
+
command: check.command,
|
|
644
|
+
releaseNotesUrl: check.releaseNotesUrl,
|
|
645
|
+
log: [`Update job queued for ${check.currentVersion} -> ${check.latestVersion}.`],
|
|
646
|
+
};
|
|
647
|
+
writeJob(job);
|
|
648
|
+
|
|
649
|
+
let child: UpdateWorkerProcess;
|
|
650
|
+
try {
|
|
651
|
+
child = resolvedDeps.spawnWorkerFn(id, channel, restart);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
updateJob(job, { status: "failed", error: `Could not start update worker: ${withheldSummary(error)}` }, "Update worker failed to start.");
|
|
654
|
+
throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed");
|
|
655
|
+
}
|
|
656
|
+
if (typeof child.pid !== "number" || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
|
|
657
|
+
updateJob(job, { status: "failed", error: "Could not start update worker: no worker PID was returned." }, "Update worker failed to start.");
|
|
658
|
+
throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed");
|
|
659
|
+
}
|
|
660
|
+
const startedJob = updateJob(job, { pid: child.pid }, `Update worker started as PID ${child.pid}.`);
|
|
661
|
+
child.once("error", error => {
|
|
662
|
+
const current = readUpdateJob(id);
|
|
663
|
+
if (!current || current.pid !== child.pid || (current.status !== "running" && current.status !== "restarting")) return;
|
|
664
|
+
updateJob(
|
|
665
|
+
current,
|
|
666
|
+
{ status: "failed", error: `Update worker failed to start: ${withheldSummary(error)}` },
|
|
667
|
+
"Update worker emitted a startup error.",
|
|
668
|
+
);
|
|
669
|
+
});
|
|
670
|
+
child.unref();
|
|
671
|
+
return startedJob;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Run an update step and record WHAT HAPPENED, not what the tool printed.
|
|
676
|
+
*
|
|
677
|
+
* Raw installer output used to be persisted verbatim, which put local paths and account names
|
|
678
|
+
* into a stored file. Six rounds of trying to sanitize it after the fact each produced a new
|
|
679
|
+
* leak — a wrap inside the keyword, a wrap inside the account name, an indented continuation,
|
|
680
|
+
* three consecutive wraps, an empty continuation line. Every fix was an attempt to reconstruct
|
|
681
|
+
* arbitrary multi-line text well enough to match it, and that is not a problem a redactor can
|
|
682
|
+
* win: the leak surface is whatever npm decides to print.
|
|
683
|
+
*
|
|
684
|
+
* So the raw stream is no longer persisted at all. The job keeps the command, its exit status,
|
|
685
|
+
* and a bounded, structured summary — enough to tell a user which step failed and how, with no
|
|
686
|
+
* free-form vendor text passing through the boundary. Detailed output stays ephemeral.
|
|
687
|
+
*/
|
|
688
|
+
function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], timeout: number): { status: number | null; signal: NodeJS.Signals | null } {
|
|
689
|
+
job = updateJob(job, {}, `$ ${formatCommand(bin, args)}`);
|
|
690
|
+
const result = spawnSync(bin, args, {
|
|
691
|
+
encoding: "utf8",
|
|
692
|
+
timeout,
|
|
693
|
+
windowsHide: true,
|
|
694
|
+
});
|
|
695
|
+
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
696
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
697
|
+
const summary = summarizeCommandOutput(stdout, stderr, result.status, result.signal);
|
|
698
|
+
if (summary) updateJob(job, {}, summary);
|
|
699
|
+
return { status: result.status, signal: result.signal };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Recognized npm/libc error codes, as an explicit set.
|
|
704
|
+
*
|
|
705
|
+
* A shape pattern like `E[A-Z]{3,}` is NOT a vocabulary: `C:\Users\ERROR\.npm` matches it, and
|
|
706
|
+
* the summary then re-emits the username the withheld output was protecting. Only codes on this
|
|
707
|
+
* list are surfaced, and only when they appear in npm's canonical `code <CODE>` position.
|
|
708
|
+
*/
|
|
709
|
+
const NPM_ERROR_CODES = new Set([
|
|
710
|
+
"EACCES", "EPERM", "ENOENT", "EEXIST", "ENOTDIR", "EISDIR", "EMFILE", "ENFILE",
|
|
711
|
+
"ENOSPC", "EROFS", "EXDEV", "ELOOP", "ENAMETOOLONG", "ENOTEMPTY", "EBUSY",
|
|
712
|
+
"EAGAIN", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN",
|
|
713
|
+
"EPROTO", "ECONNABORTED", "EHOSTUNREACH", "ENETUNREACH", "EPIPE",
|
|
714
|
+
"E401", "E403", "E404", "E409", "E429", "E500", "E503",
|
|
715
|
+
"EINTEGRITY", "ERESOLVE", "ETARGET", "EPUBLISHCONFLICT", "ENEEDAUTH",
|
|
716
|
+
"EUSAGE", "EJSONPARSE", "EOTP", "EINVALIDTYPE", "ELIFECYCLE",
|
|
717
|
+
"ERR_SOCKET_TIMEOUT", "ERR_INVALID_ARG_TYPE", "ERR_MODULE_NOT_FOUND",
|
|
718
|
+
]);
|
|
719
|
+
|
|
720
|
+
/** npm prints `npm ERR! code EACCES`; anchor on that position rather than scanning free text. */
|
|
721
|
+
const NPM_CODE_RECORD = /^\s*npm\s+ERR!\s+code\s+([A-Z][A-Z0-9_]{2,})\s*$/gm;
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* npm's failure output is STRUCTURED, not prose: `npm error <field> <value>`, one field per
|
|
725
|
+
* line (`npm ERR!` on npm 9 and earlier). That is what makes a useful summary possible without
|
|
726
|
+
* reproducing text — we can read named fields and keep the ones whose value cannot be a path.
|
|
727
|
+
*
|
|
728
|
+
* Fields kept, with a real example of each:
|
|
729
|
+
* code E404, EACCES, ETARGET the single most useful line for diagnosis
|
|
730
|
+
* syscall mkdir, open, getaddrinfo what npm was doing
|
|
731
|
+
* errno -13 the OS errno
|
|
732
|
+
* notarget No matching version ... version-resolution explanation, no path
|
|
733
|
+
* 404 404 Not Found - GET <url> registry URL, no local path
|
|
734
|
+
*
|
|
735
|
+
* Deliberately NOT kept: `path`, `dest`, `file`, `stack`, and the bare `Error: ...` line —
|
|
736
|
+
* every one of those is a filesystem path by definition. `A complete log of this run can be
|
|
737
|
+
* found in: <path>` is dropped for the same reason.
|
|
738
|
+
*/
|
|
739
|
+
const NPM_FIELD_LINE = /^\s*npm\s+(?:error|ERR!)\s+([a-z0-9]+)\s+(.*)$/gim;
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* POSIX syscall names npm actually reports. An explicit vocabulary, not a shape.
|
|
743
|
+
*
|
|
744
|
+
* `^[a-z][a-z0-9_]{1,20}$` accepts `janedoe`, which is the whole problem: allowlisting the
|
|
745
|
+
* FIELD NAME while leaving its VALUE free-form just moves the leak one level in.
|
|
746
|
+
*/
|
|
747
|
+
const POSIX_SYSCALLS = new Set([
|
|
748
|
+
"open", "openat", "close", "read", "write", "stat", "lstat", "fstat", "mkdir", "rmdir",
|
|
749
|
+
"unlink", "rename", "symlink", "readlink", "link", "chmod", "chown", "utimes", "access",
|
|
750
|
+
"scandir", "readdir", "copyfile", "realpath", "futime", "ftruncate", "fchmod", "fchown",
|
|
751
|
+
"connect", "getaddrinfo", "getnameinfo", "socket", "bind", "listen", "accept", "send",
|
|
752
|
+
"recv", "shutdown", "spawn", "spawnSync", "kill", "watch", "lchown", "lutimes", "mkdtemp",
|
|
753
|
+
]);
|
|
754
|
+
|
|
755
|
+
/** Per-field value contracts. A field is only kept when its value satisfies its own rule. */
|
|
756
|
+
const KNOWN_REGISTRY_HOSTS = new Set([
|
|
757
|
+
"registry.npmjs.org",
|
|
758
|
+
"registry.yarnpkg.com",
|
|
759
|
+
"registry.npmmirror.com",
|
|
760
|
+
"npm.pkg.github.com",
|
|
761
|
+
]);
|
|
762
|
+
|
|
763
|
+
const NPM_FIELD_VALIDATORS: Record<string, (value: string) => string | null> = {
|
|
764
|
+
// A recognized code, nothing else.
|
|
765
|
+
code: value => (NPM_ERROR_CODES.has(value) ? value : null),
|
|
766
|
+
// A known syscall name, nothing else.
|
|
767
|
+
syscall: value => (POSIX_SYSCALLS.has(value) ? value : null),
|
|
768
|
+
// An integer, rendered from the parsed number so the original string never passes through.
|
|
769
|
+
errno: value => (/^-?\d{1,10}$/.test(value) ? String(Number(value)) : null),
|
|
770
|
+
// Version resolution: the FACT only.
|
|
771
|
+
//
|
|
772
|
+
// Two narrowing attempts failed here and the second is the instructive one. Extracting any
|
|
773
|
+
// `name@version` also matched `jane.doe@example.com`. Pinning the NAME to our own package
|
|
774
|
+
// still left the VERSION free: `@bitkyc08/opencodex@99.99.99-JaneDoe` is a valid-looking
|
|
775
|
+
// spec, and a semver prerelease identifier can encode anything — the same lesson the
|
|
776
|
+
// `/healthz` version taught in round 13.
|
|
777
|
+
//
|
|
778
|
+
// There is no trusted resolved version available at this call site, so the spec is not
|
|
779
|
+
// rendered at all. `code: ETARGET` plus this fact already tells a user their requested
|
|
780
|
+
// version does not exist, which is the diagnostic that matters.
|
|
781
|
+
notarget: () => "no matching version",
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* HTTP status lines carry a registry URL. Render it from parsed parts rather than echoing the
|
|
786
|
+
* line: a URL can embed userinfo (`https://Jane:pw@host/`) or a path, and the raw text also
|
|
787
|
+
* defeats the path test because `https:/` looks like a drive letter.
|
|
788
|
+
*/
|
|
789
|
+
function npmHttpStatusValue(field: string, value: string): string | null {
|
|
790
|
+
const url = /\bhttps?:\/\/[^\s]+/.exec(value)?.[0];
|
|
791
|
+
if (!url) return `HTTP ${field}`;
|
|
792
|
+
let parsed: URL;
|
|
793
|
+
try { parsed = new URL(url); } catch { return `HTTP ${field}`; }
|
|
794
|
+
// Only hosts we can name in advance. A shape check (`^[\w.-]+$`) accepts
|
|
795
|
+
// `janedoe.example`, a numeric host, or a punycode host — an arbitrary hostname is a
|
|
796
|
+
// disclosure channel, not a diagnostic. Knowing it was the public registry versus "some
|
|
797
|
+
// other host" is the part that helps, and that fits in an allowlist.
|
|
798
|
+
return KNOWN_REGISTRY_HOSTS.has(parsed.hostname.toLowerCase()) && !parsed.username && !parsed.password
|
|
799
|
+
? `HTTP ${field} from ${parsed.hostname.toLowerCase()}`
|
|
800
|
+
: `HTTP ${field}`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Extract the diagnostic fields npm names explicitly.
|
|
805
|
+
*
|
|
806
|
+
* Each kept value still passes `withholdIfPathBearing` before it is used: a registry URL is
|
|
807
|
+
* fine, but `syscall` and friends are only safe by convention, and a convention is not a
|
|
808
|
+
* guarantee. Values are length-capped so a hostile responder cannot pad the record.
|
|
809
|
+
*/
|
|
810
|
+
function npmDiagnosticFields(text: string): string[] {
|
|
811
|
+
const seen = new Map<string, string>();
|
|
812
|
+
for (const match of text.matchAll(NPM_FIELD_LINE)) {
|
|
813
|
+
const field = match[1]!.toLowerCase();
|
|
814
|
+
const value = match[2]!.trim();
|
|
815
|
+
if (seen.has(field) || !value || value.length > 160) continue;
|
|
816
|
+
// Every kept field is RENDERED from a validated value, never echoed. Allowlisting the field
|
|
817
|
+
// name alone left the value free-form, so `npm error syscall janedoe` walked straight
|
|
818
|
+
// through — the field was recognized and the value was never checked against anything.
|
|
819
|
+
const validate = NPM_FIELD_VALIDATORS[field];
|
|
820
|
+
const rendered = validate
|
|
821
|
+
? validate(value)
|
|
822
|
+
: (/^(?:404|401|403|409|429)$/.test(field) ? npmHttpStatusValue(field, value) : null);
|
|
823
|
+
if (rendered === null) continue;
|
|
824
|
+
seen.set(field, rendered);
|
|
825
|
+
}
|
|
826
|
+
return [...seen].map(([field, value]) => `${field}: ${value}`);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Build a structured, path-free summary of a command's result.
|
|
831
|
+
*
|
|
832
|
+
* Only three things cross the boundary: how the process ended, how much it printed, and any
|
|
833
|
+
* recognized error codes. None of those can carry a filesystem path or an account name.
|
|
834
|
+
*/
|
|
835
|
+
export function summarizeCommandOutput(
|
|
836
|
+
stdout: string,
|
|
837
|
+
stderr: string,
|
|
838
|
+
status: number | null,
|
|
839
|
+
signal: NodeJS.Signals | null,
|
|
840
|
+
): string | null {
|
|
841
|
+
if (!stdout && !stderr && status === 0) return null;
|
|
842
|
+
|
|
843
|
+
const parts: string[] = [];
|
|
844
|
+
parts.push(signal ? `terminated by ${signal}` : `exit ${status ?? "null"}`);
|
|
845
|
+
|
|
846
|
+
// Read npm's own named fields rather than reproducing its text. This is what makes a failed
|
|
847
|
+
// update diagnosable again: `code: E404 · 404: 404 Not Found - GET https://registry...` tells
|
|
848
|
+
// a user exactly what happened, and none of it can be a local path.
|
|
849
|
+
const fields = npmDiagnosticFields(`${stderr}\n${stdout}`);
|
|
850
|
+
if (fields.length > 0) parts.push(...fields);
|
|
851
|
+
|
|
852
|
+
const bytes = Buffer.byteLength(stdout, "utf8") + Buffer.byteLength(stderr, "utf8");
|
|
853
|
+
if (bytes > 0) {
|
|
854
|
+
parts.push(fields.length > 0
|
|
855
|
+
? `${bytes} bytes of full output withheld`
|
|
856
|
+
: `${bytes} bytes of output withheld (no recognized diagnostic fields)`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return parts.join(" · ");
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Tear down anything that would make `ocx start` exit 1 with "already running"
|
|
864
|
+
* (service wrapper respawn, stale pidfile + live /healthz) before a pinned spawn.
|
|
865
|
+
*/
|
|
866
|
+
function preparePortForPinnedStart(
|
|
867
|
+
job: UpdateJobState,
|
|
868
|
+
port: number,
|
|
869
|
+
listPids: (port: number) => number[],
|
|
870
|
+
aliveFn: (pid: number) => boolean,
|
|
871
|
+
verifyOcx: (pid: number) => number | null = verifyPidIdentity,
|
|
872
|
+
): void {
|
|
873
|
+
stopWindowsServiceWrappersBestEffort();
|
|
874
|
+
const pid = readPid();
|
|
875
|
+
if (pid) {
|
|
876
|
+
updateJob(job, {}, `Clearing pre-start proxy PID ${pid} before pinned start.`);
|
|
877
|
+
try { killProxy(pid); } catch { /* best-effort */ }
|
|
878
|
+
removePid(pid);
|
|
879
|
+
} else {
|
|
880
|
+
removePid();
|
|
881
|
+
}
|
|
882
|
+
removeRuntimePort();
|
|
883
|
+
for (const holder of listPids(port)) {
|
|
884
|
+
if (holder === process.pid || !aliveFn(holder)) continue;
|
|
885
|
+
if (verifyOcx(holder) !== holder) {
|
|
886
|
+
updateJob(
|
|
887
|
+
job,
|
|
888
|
+
{},
|
|
889
|
+
`Leaving foreign listen holder PID ${holder} on port ${port}; refusing collateral kill.`,
|
|
890
|
+
);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
updateJob(job, {}, `Stopping live ocx listen holder PID ${holder} on port ${port} before pinned start.`);
|
|
894
|
+
try { killProxy(holder); } catch { /* best-effort */ }
|
|
895
|
+
}
|
|
896
|
+
// Match reclaimListenPort: never SetTcpEntry while a live holder remains.
|
|
897
|
+
const liveRemain = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
|
|
898
|
+
if (process.platform === "win32" && liveRemain.length === 0) {
|
|
899
|
+
try { dropWindowsTcpRowsForLocalPort(port); } catch { /* best-effort */ }
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function spawnDetachedStart(
|
|
904
|
+
job: UpdateJobState,
|
|
905
|
+
installer: Installer,
|
|
906
|
+
port?: number,
|
|
907
|
+
): ChildProcess {
|
|
908
|
+
const cmd = restartCommand(false, installer, packageLauncherPath(), port);
|
|
909
|
+
const env = { ...process.env };
|
|
910
|
+
delete env.OCX_SERVICE;
|
|
911
|
+
updateJob(job, {}, `$ ${cmd.display}`);
|
|
912
|
+
let stdio: "ignore" | [ "ignore", number, number ] = "ignore";
|
|
913
|
+
let logFd: number | undefined;
|
|
914
|
+
try {
|
|
915
|
+
const logPath = join(getConfigDir(), "update-pinned-start.log");
|
|
916
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
917
|
+
logFd = openSync(logPath, "a");
|
|
918
|
+
writeSync(logFd, `\n--- ${new Date().toISOString()} ---\n$ ${cmd.display}\n`);
|
|
919
|
+
stdio = ["ignore", logFd, logFd];
|
|
920
|
+
} catch { /* fall back to ignored stdio */ }
|
|
921
|
+
const child = spawn(cmd.bin, cmd.args, {
|
|
922
|
+
detached: true,
|
|
923
|
+
stdio,
|
|
924
|
+
windowsHide: true,
|
|
925
|
+
env,
|
|
926
|
+
});
|
|
927
|
+
child.once("error", err => {
|
|
928
|
+
try {
|
|
929
|
+
updateJob(job, {}, `Pinned start spawn error: ${withheldSummary(err)}`);
|
|
930
|
+
} catch { /* best-effort */ }
|
|
931
|
+
});
|
|
932
|
+
// Foreground `ocx start` keeps the listen process; EADDRINUSE/ghost races exit quickly
|
|
933
|
+
// with stdio ignored — surface that so the job log explains a silent miss.
|
|
934
|
+
child.once("exit", (code, signal) => {
|
|
935
|
+
if (code === 0 && !signal) return;
|
|
936
|
+
try {
|
|
937
|
+
updateJob(
|
|
938
|
+
job,
|
|
939
|
+
{},
|
|
940
|
+
`Pinned start exited early (code=${code ?? "null"} signal=${signal ?? "null"}).`,
|
|
941
|
+
);
|
|
942
|
+
} catch { /* best-effort */ }
|
|
943
|
+
});
|
|
944
|
+
child.unref();
|
|
945
|
+
return child;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Identity snapshot used to prove an npm self-update actually replaced the pre-update process. */
|
|
949
|
+
export interface RestartProxyIdentity {
|
|
950
|
+
pid: number | null;
|
|
951
|
+
version?: string;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Test seam: the wait/spawn pair is injectable so the restart path is verifiable. */
|
|
955
|
+
export interface RestartIo {
|
|
956
|
+
waitForPort?: typeof reclaimListenPort;
|
|
957
|
+
spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void;
|
|
958
|
+
serviceInstalledFn?: () => boolean;
|
|
959
|
+
/**
|
|
960
|
+
* After a service reinstall exits 0, only trust the service path when this is true.
|
|
961
|
+
* Defaults to {@link isServiceViable} — installed-but-stale assets must fall through
|
|
962
|
+
* to a direct proxy start so dashboard updates never leave /healthz dead.
|
|
963
|
+
*/
|
|
964
|
+
serviceViableFn?: () => boolean;
|
|
965
|
+
probeProxy?: (port: number, hostname?: string) => Promise<boolean>;
|
|
966
|
+
/** Richer /healthz read for update-correlated restart evidence (pid + version). */
|
|
967
|
+
probeProxyIdentity?: (port: number, hostname?: string) => Promise<RestartProxyIdentity | null>;
|
|
968
|
+
/** Override the /healthz appearance window (default {@link RESTART_HEALTH_TIMEOUT_MS}). */
|
|
969
|
+
healthTimeoutMs?: number;
|
|
970
|
+
/**
|
|
971
|
+
* Override the window for deciding whether a service-managed restart actually
|
|
972
|
+
* served (default {@link SERVICE_RECOVERY_HEALTH_MS}). Distinct from
|
|
973
|
+
* {@link healthTimeoutMs}, which is the FINAL /healthz appearance window consumed
|
|
974
|
+
* by awaitRestartedProxyHealthy: this one only chooses whether to ALSO attempt a
|
|
975
|
+
* direct start, so coupling them would let a test tightening one silently retune
|
|
976
|
+
* the other.
|
|
977
|
+
*/
|
|
978
|
+
serviceHealthTimeoutMs?: number;
|
|
979
|
+
sleepMs?: (ms: number) => Promise<void>;
|
|
980
|
+
now?: () => number;
|
|
981
|
+
/** Test seam — defaults to process.platform so the Windows-only branch is reachable off Windows. */
|
|
982
|
+
platform?: NodeJS.Platform;
|
|
983
|
+
/** Service-mode install/reinstall command (defaults to spawnSync via runLoggedCommand). */
|
|
984
|
+
runService?: (
|
|
985
|
+
job: UpdateJobState,
|
|
986
|
+
bin: string,
|
|
987
|
+
args: string[],
|
|
988
|
+
) => { status: number | null; signal?: NodeJS.Signals | null };
|
|
989
|
+
/** Override the explicit restart path (used by finishGuiUpdateRestart tests). */
|
|
990
|
+
restartAfterUpdateFn?: (
|
|
991
|
+
job: UpdateJobState,
|
|
992
|
+
captured?: { port: number; hostname: string; oldPid?: number },
|
|
993
|
+
io?: RestartIo,
|
|
994
|
+
) => Promise<void>;
|
|
995
|
+
/**
|
|
996
|
+
* PIDs currently LISTENing on the captured port. Used to widen the post-update
|
|
997
|
+
* kill allowlist beyond the pre-update PID (Windows often leaves a respawned
|
|
998
|
+
* ocx child that would otherwise be treated as a protected listener).
|
|
999
|
+
*/
|
|
1000
|
+
listListenPidsFn?: (port: number) => number[];
|
|
1001
|
+
/**
|
|
1002
|
+
* Full listen-PID scan (ok/fail). When omitted, {@link scanListenPids} is used
|
|
1003
|
+
* so a probe failure is not mistaken for "no listeners".
|
|
1004
|
+
*/
|
|
1005
|
+
scanListenPidsFn?: (port: number) => ListenPidScan;
|
|
1006
|
+
/** Identity check for listeners discovered via {@link listListenPidsFn}. */
|
|
1007
|
+
verifyOcxFn?: (pid: number) => number | null;
|
|
1008
|
+
/** Liveness check when deciding whether a reclaim timeout still has live holders. */
|
|
1009
|
+
isAliveFn?: (pid: number) => boolean;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Health window for deciding whether a service-managed restart served, before
|
|
1014
|
+
* falling back to a direct start. Deliberately shorter than the final verdict
|
|
1015
|
+
* window: being wrong here costs one extra start attempt; being wrong the other way
|
|
1016
|
+
* leaves the user with no proxy at all.
|
|
1017
|
+
*
|
|
1018
|
+
* It runs AFTER the child's own 20s install probe (SERVICE_INSTALL_HEALTH_MS on
|
|
1019
|
+
* macOS/Linux), so a reinstall that exits 0 but never serves spends up to 45s before
|
|
1020
|
+
* the fallback — inside RESTART_TIMEOUT_MS of 60s. That is why this is 25s, not more.
|
|
1021
|
+
*/
|
|
1022
|
+
export const SERVICE_RECOVERY_HEALTH_MS = 25_000;
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Whether the reinstalled service actually produced a listener on the captured target.
|
|
1026
|
+
*
|
|
1027
|
+
* Not a duplicate of the child's own check: since WP2 the child asserts the port on
|
|
1028
|
+
* macOS/Linux, but Windows still reports success from registration alone, a flapping
|
|
1029
|
+
* supervisor can satisfy a single probe, and the child may be a CLI older than that
|
|
1030
|
+
* change. `isServiceViable()` cannot see any of those — it reads registration state.
|
|
1031
|
+
*/
|
|
1032
|
+
async function serviceRestartServed(
|
|
1033
|
+
job: UpdateJobState,
|
|
1034
|
+
port: number,
|
|
1035
|
+
hostname: string,
|
|
1036
|
+
io: RestartIo = {},
|
|
1037
|
+
): Promise<boolean> {
|
|
1038
|
+
const probe = io.probeProxy ?? (async (p: number, h?: string) => (
|
|
1039
|
+
!!(await proxyIdentityAt(p, { hostname: h }))
|
|
1040
|
+
));
|
|
1041
|
+
const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
|
1042
|
+
const now = io.now ?? (() => Date.now());
|
|
1043
|
+
const deadline = now() + (io.serviceHealthTimeoutMs ?? SERVICE_RECOVERY_HEALTH_MS);
|
|
1044
|
+
for (;;) {
|
|
1045
|
+
if (await probe(port, hostname)) {
|
|
1046
|
+
updateJob(job, {}, `Service-managed proxy answered on ${hostname}:${port}.`);
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
if (now() >= deadline) return false;
|
|
1050
|
+
await sleep(500);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function restartAfterUpdate(
|
|
1055
|
+
job: UpdateJobState,
|
|
1056
|
+
captured?: { port: number; hostname: string; oldPid?: number },
|
|
1057
|
+
io: RestartIo = {},
|
|
1058
|
+
): Promise<void> {
|
|
1059
|
+
const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
|
|
1060
|
+
const config = loadConfig();
|
|
1061
|
+
// The stop-first update flow has already cleared pid/runtime state by the time we run,
|
|
1062
|
+
// so the pre-update capture (taken before the update command) is the authoritative
|
|
1063
|
+
// port to wait on; config is only the cold-start fallback.
|
|
1064
|
+
const port = captured?.port ?? config.port ?? 10100;
|
|
1065
|
+
const hostname = captured?.hostname ?? config.hostname ?? "127.0.0.1";
|
|
1066
|
+
const oldPid = typeof captured?.oldPid === "number" && captured.oldPid > 0
|
|
1067
|
+
? captured.oldPid
|
|
1068
|
+
: undefined;
|
|
1069
|
+
let svcArgs: string[] | undefined;
|
|
1070
|
+
if (serviceInstalled) {
|
|
1071
|
+
try {
|
|
1072
|
+
const { serviceReinstallArgs } = await import("../service");
|
|
1073
|
+
svcArgs = serviceReinstallArgs();
|
|
1074
|
+
} catch { /* fallback to default service install */ }
|
|
1075
|
+
}
|
|
1076
|
+
const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs);
|
|
1077
|
+
const waitFn = io.waitForPort ?? reclaimListenPort;
|
|
1078
|
+
const listPids = io.listListenPidsFn ?? listListenPids;
|
|
1079
|
+
const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity;
|
|
1080
|
+
const aliveFn = io.isAliveFn ?? isProcessAlive;
|
|
1081
|
+
// Pre-update PID plus any ocx still LISTENing on the captured port. After a
|
|
1082
|
+
// stop-first npm self-update Windows often leaves a respawned bun/node child
|
|
1083
|
+
// that is not the captured PID; treating it as protected blocks reclaim and
|
|
1084
|
+
// the direct-start fallback never binds.
|
|
1085
|
+
const reclaimKillAllowlist = (): number[] => {
|
|
1086
|
+
const allow = new Set<number>();
|
|
1087
|
+
if (oldPid != null) allow.add(oldPid);
|
|
1088
|
+
for (const pid of listPids(port)) {
|
|
1089
|
+
if (pid === process.pid) continue;
|
|
1090
|
+
if (verifyOcx(pid) === pid) allow.add(pid);
|
|
1091
|
+
}
|
|
1092
|
+
return [...allow];
|
|
1093
|
+
};
|
|
1094
|
+
const reclaimOptsFor = (onlyKillPids: number[]) => ({
|
|
1095
|
+
timeoutMs: RESTART_PORT_RECLAIM_MS,
|
|
1096
|
+
intervalMs: 100,
|
|
1097
|
+
scanIntervalMs: 500,
|
|
1098
|
+
killOcxHolders: true,
|
|
1099
|
+
// Windows scheduler wrappers can mint a *new* bun PID during the wait; keep
|
|
1100
|
+
// killing every ocx listener on this port, not only the pre-wait snapshot.
|
|
1101
|
+
// npm rename trees under `@bitkyc08/.opencodex-*` are classified as ocx by
|
|
1102
|
+
// isOcxStartCommandLine — never kill unknown foreign claimants on this port.
|
|
1103
|
+
killAllOcxOnPort: true,
|
|
1104
|
+
onlyKillPids,
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
if (serviceInstalled) {
|
|
1108
|
+
// schtasks /end often leaves the hidden cmd/wscript wrapper alive; its :loop
|
|
1109
|
+
// respawns `ocx start` a few seconds later and races port reclaim. End the
|
|
1110
|
+
// task again and best-effort kill those wrappers before we touch the socket.
|
|
1111
|
+
stopWindowsServiceWrappersBestEffort();
|
|
1112
|
+
// Stop-first update already unloaded the service; reclaim the socket, then
|
|
1113
|
+
// reinstall wrappers that bake `--port`.
|
|
1114
|
+
const preServiceAllow = reclaimKillAllowlist();
|
|
1115
|
+
const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow));
|
|
1116
|
+
let skipServiceInstall = false;
|
|
1117
|
+
// This skip existed because the refresh ran `ocx service install`, whose Windows
|
|
1118
|
+
// scheduler path always reaches `schtasks /create` — elevation the GUI update worker
|
|
1119
|
+
// (OCX_SERVICE=1) never has. `service repair` rewrites the wrapper assets and
|
|
1120
|
+
// restarts the EXISTING task with no `/create`, so the reason no longer applies and
|
|
1121
|
+
// skipping would leave the dashboard-triggered update — the most common Windows
|
|
1122
|
+
// path — with a stale service it could have refreshed.
|
|
1123
|
+
//
|
|
1124
|
+
// Only a caller that still passes install argv keeps the old behavior.
|
|
1125
|
+
const refreshRegisters = (svcArgs ?? []).includes("install");
|
|
1126
|
+
if ((io.platform ?? process.platform) === "win32" && process.env.OCX_SERVICE === "1" && refreshRegisters) {
|
|
1127
|
+
updateJob(job, {}, "Skipping service re-registration from the non-elevated update worker; falling back to a direct proxy start.");
|
|
1128
|
+
skipServiceInstall = true;
|
|
1129
|
+
}
|
|
1130
|
+
if (!freed && !skipServiceInstall) {
|
|
1131
|
+
updateJob(
|
|
1132
|
+
job,
|
|
1133
|
+
{},
|
|
1134
|
+
`Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s; refusing to hop — reinstall may fail until the port is free.`
|
|
1135
|
+
+ ` ${formatPortHolders(port, listPids, verifyOcx, preServiceAllow)}`,
|
|
1136
|
+
);
|
|
1137
|
+
const liveScan: ListenPidScan = io.scanListenPidsFn
|
|
1138
|
+
? io.scanListenPidsFn(port)
|
|
1139
|
+
: io.listListenPidsFn
|
|
1140
|
+
// Test seam: an injected list represents a successful scan.
|
|
1141
|
+
? { ok: true, pids: io.listListenPidsFn(port) }
|
|
1142
|
+
: scanListenPids(port);
|
|
1143
|
+
const liveAfter = liveScan.ok
|
|
1144
|
+
? liveScan.pids.filter(pid => pid !== process.pid && aliveFn(pid))
|
|
1145
|
+
: null;
|
|
1146
|
+
if (liveAfter !== null && liveAfter.length === 0) {
|
|
1147
|
+
// Non-elevated `service install` will UAC-fail anyway; skip straight to
|
|
1148
|
+
// the direct-start fallthrough instead of burning another minute on it.
|
|
1149
|
+
updateJob(job, {}, "Skipping service reinstall after reclaim timeout with no live holders; falling back to a direct proxy start.");
|
|
1150
|
+
skipServiceInstall = true;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (!skipServiceInstall) {
|
|
1154
|
+
const prevBake = process.env.OCX_BAKE_PORT;
|
|
1155
|
+
process.env.OCX_BAKE_PORT = String(Math.trunc(port));
|
|
1156
|
+
let serviceOk = false;
|
|
1157
|
+
try {
|
|
1158
|
+
const run = io.runService ?? ((j, bin, args) => runLoggedCommand(j, bin, args, RESTART_TIMEOUT_MS));
|
|
1159
|
+
const result = run(job, cmd.bin, cmd.args);
|
|
1160
|
+
serviceOk = result.status === 0;
|
|
1161
|
+
if (!serviceOk) {
|
|
1162
|
+
// The refresh that just failed was `ocx service repair` (serviceReinstallArgs),
|
|
1163
|
+
// which needs no elevation because it never calls `schtasks /create`. Advising
|
|
1164
|
+
// `install` here would send the user to re-registration — a UAC prompt on
|
|
1165
|
+
// Windows and a possible WinSW-to-scheduler backend switch — to fix a service
|
|
1166
|
+
// that is already registered. Point at the same command that failed so its
|
|
1167
|
+
// output explains why, on every platform.
|
|
1168
|
+
updateJob(
|
|
1169
|
+
job,
|
|
1170
|
+
{},
|
|
1171
|
+
`Service refresh failed (exit ${result.status ?? "?"}); falling back to a direct proxy start.`
|
|
1172
|
+
+ " Run 'ocx service repair' by hand to see the reason, then 'ocx service status'.",
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
} finally {
|
|
1176
|
+
if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
|
|
1177
|
+
else process.env.OCX_BAKE_PORT = prevBake;
|
|
1178
|
+
}
|
|
1179
|
+
if (serviceOk) {
|
|
1180
|
+
// Exit 0 is not enough, and neither is `viable`. Registration state cannot
|
|
1181
|
+
// distinguish a serving supervisor from one that registered and bound nothing:
|
|
1182
|
+
// `launchctl list` reports both, and `schtasks` reports a task whose child
|
|
1183
|
+
// exited immediately. Since WP2 the child asserts the port itself on
|
|
1184
|
+
// macOS/Linux, but Windows still reports success from registration alone, a
|
|
1185
|
+
// flapping supervisor can satisfy one probe, and the child may be an older CLI.
|
|
1186
|
+
// Ask the port before skipping the fallback this branch exists to protect.
|
|
1187
|
+
const viable = (io.serviceViableFn ?? isServiceViable)();
|
|
1188
|
+
if (viable) {
|
|
1189
|
+
if (await serviceRestartServed(job, port, hostname, io)) return;
|
|
1190
|
+
updateJob(
|
|
1191
|
+
job,
|
|
1192
|
+
{},
|
|
1193
|
+
`Service reinstall exited 0 and reported viable, but nothing answered on ${hostname}:${port} `
|
|
1194
|
+
+ `within ${Math.trunc((io.serviceHealthTimeoutMs ?? SERVICE_RECOVERY_HEALTH_MS) / 1000)}s; `
|
|
1195
|
+
+ "falling back to a direct proxy start.",
|
|
1196
|
+
);
|
|
1197
|
+
} else {
|
|
1198
|
+
updateJob(
|
|
1199
|
+
job,
|
|
1200
|
+
{},
|
|
1201
|
+
"Service reinstall exited 0 but the background service is not viable (stale or missing assets, disabled, or conflicting); falling back to a direct proxy start.",
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
// Fall through to the direct proxy start below so the update never leaves the
|
|
1207
|
+
// proxy stopped when the service reinstall could not run or did not leave a
|
|
1208
|
+
// viable supervisor.
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const pid = readPid();
|
|
1212
|
+
if (pid) {
|
|
1213
|
+
updateJob(job, {}, `Stopping current proxy PID ${pid}.`);
|
|
1214
|
+
try {
|
|
1215
|
+
killProxy(pid);
|
|
1216
|
+
} catch {
|
|
1217
|
+
// A PID that resists taskkill must not abort recovery: reclaim + pinned start
|
|
1218
|
+
// below are the path that repairs stuck Windows listeners.
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
if (serviceInstalled) stopWindowsServiceWrappersBestEffort();
|
|
1222
|
+
// Reclaim the captured port before the pinned start. Spawning `--port` while the old
|
|
1223
|
+
// socket is still busy is how Windows updates used to fail health checks (or hop).
|
|
1224
|
+
// killAllOcxOnPort covers wrapper-respawned bun PIDs minted during the wait.
|
|
1225
|
+
const directAllow = reclaimKillAllowlist();
|
|
1226
|
+
const freed = await waitFn(port, hostname, reclaimOptsFor(directAllow));
|
|
1227
|
+
if (!freed) {
|
|
1228
|
+
const liveHolders = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
|
|
1229
|
+
updateJob(
|
|
1230
|
+
job,
|
|
1231
|
+
{},
|
|
1232
|
+
`Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s (reclaim could not free the socket).`
|
|
1233
|
+
+ ` ${formatPortHolders(port, listPids, verifyOcx, directAllow)}`,
|
|
1234
|
+
);
|
|
1235
|
+
if (liveHolders.length > 0) {
|
|
1236
|
+
updateJob(job, {}, `Live holder(s) remain on port ${port}; not starting on another port. Retry 'ocx start --port ${port}'.`);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
// Dead PIDs can still own LISTEN rows. SetTcpEntry needs elevation (rc 317 on a
|
|
1240
|
+
// normal update worker), so poll until netstat is empty and the start runtime can bind.
|
|
1241
|
+
updateJob(
|
|
1242
|
+
job,
|
|
1243
|
+
{},
|
|
1244
|
+
`No live holders on port ${port}; waiting for ghost LISTEN rows to clear before pinned start.`,
|
|
1245
|
+
);
|
|
1246
|
+
// Injected spawnStart is the unit-test seam — skip the long OS wait.
|
|
1247
|
+
if (!io.spawnStart) {
|
|
1248
|
+
const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
|
1249
|
+
const cleared = await waitForGhostListenClear(port, hostname, listPids, 90_000, sleep);
|
|
1250
|
+
if (cleared.accessDenied) {
|
|
1251
|
+
updateJob(job, {}, "SetTcpEntry is non-elevated (access denied); relying on OS ghost-LISTEN expiry.");
|
|
1252
|
+
}
|
|
1253
|
+
if (!cleared.ok) {
|
|
1254
|
+
updateJob(
|
|
1255
|
+
job,
|
|
1256
|
+
{},
|
|
1257
|
+
`Ghost LISTEN rows on port ${port} did not clear in time. `
|
|
1258
|
+
+ `${formatPortHolders(port, listPids, verifyOcx, directAllow)} `
|
|
1259
|
+
+ `Retry 'ocx start --port ${port}'.`,
|
|
1260
|
+
);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
// Injected spawnStart keeps unit tests deterministic (one call). Production path
|
|
1266
|
+
// retries on missing /healthz after prepare + ghost-LISTEN clear.
|
|
1267
|
+
if (io.spawnStart) {
|
|
1268
|
+
io.spawnStart(job, job.installer, port);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
|
1272
|
+
const probe = io.probeProxy ?? (async (p: number, host?: string) => (
|
|
1273
|
+
!!(await proxyIdentityAt(p, { hostname: host }))
|
|
1274
|
+
));
|
|
1275
|
+
const probeIdentity = io.probeProxyIdentity ?? defaultProbeProxyIdentity;
|
|
1276
|
+
const expectedVersion = typeof job.latestVersion === "string" && job.latestVersion.length > 0
|
|
1277
|
+
? job.latestVersion
|
|
1278
|
+
: null;
|
|
1279
|
+
// Service wrappers can respawn a listener during reclaim; if it already reports the
|
|
1280
|
+
// update target version, do not spawn a second start that exits "already running".
|
|
1281
|
+
{
|
|
1282
|
+
const identity = await probeIdentity(port, hostname);
|
|
1283
|
+
if (identity && expectedVersion && identity.version === expectedVersion) {
|
|
1284
|
+
updateJob(
|
|
1285
|
+
job,
|
|
1286
|
+
{},
|
|
1287
|
+
`Proxy already healthy on ${hostname}:${port} at ${expectedVersion}; skipping pinned start.`,
|
|
1288
|
+
);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
const attempts = 3;
|
|
1293
|
+
// Longer than published hard-pin reclaim (30s) so a slow start can still report healthy.
|
|
1294
|
+
const perAttemptHealthMs = 70_000;
|
|
1295
|
+
let lastChild: ChildProcess | null = null;
|
|
1296
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
1297
|
+
if (attempt > 1) {
|
|
1298
|
+
updateJob(
|
|
1299
|
+
job,
|
|
1300
|
+
{},
|
|
1301
|
+
`Pinned start attempt ${attempt - 1} did not become healthy on port ${port}; `
|
|
1302
|
+
+ `retrying (${attempt}/${attempts}).`,
|
|
1303
|
+
);
|
|
1304
|
+
if (lastChild?.pid && aliveFn(lastChild.pid)) {
|
|
1305
|
+
try { killProxy(lastChild.pid); } catch { /* best-effort */ }
|
|
1306
|
+
}
|
|
1307
|
+
lastChild = null;
|
|
1308
|
+
}
|
|
1309
|
+
preparePortForPinnedStart(job, port, listPids, aliveFn, verifyOcx);
|
|
1310
|
+
const ready = await waitForGhostListenClear(
|
|
1311
|
+
port,
|
|
1312
|
+
hostname,
|
|
1313
|
+
listPids,
|
|
1314
|
+
attempt === 1 ? (freed ? 15_000 : 5_000) : 30_000,
|
|
1315
|
+
sleep,
|
|
1316
|
+
);
|
|
1317
|
+
if (!ready.ok) {
|
|
1318
|
+
updateJob(
|
|
1319
|
+
job,
|
|
1320
|
+
{},
|
|
1321
|
+
`Port ${port} not bindable before pinned start attempt ${attempt}; `
|
|
1322
|
+
+ `${formatPortHolders(port, listPids, verifyOcx, directAllow)}`,
|
|
1323
|
+
);
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
lastChild = spawnDetachedStart(job, job.installer, port);
|
|
1327
|
+
const healthDeadline = Date.now() + perAttemptHealthMs;
|
|
1328
|
+
while (Date.now() < healthDeadline) {
|
|
1329
|
+
if (await probe(port, hostname)) return;
|
|
1330
|
+
await sleep(500);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
// Exhausted retries: do not leave a hung pinned-start child owning the port.
|
|
1334
|
+
if (lastChild?.pid && aliveFn(lastChild.pid)) {
|
|
1335
|
+
try { killProxy(lastChild.pid); } catch { /* best-effort */ }
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** Compact listen-holder summary for update-job logs when reclaim fails. */
|
|
1340
|
+
function formatPortHolders(
|
|
1341
|
+
port: number,
|
|
1342
|
+
listPids: (port: number) => number[],
|
|
1343
|
+
verifyOcx: (pid: number) => number | null,
|
|
1344
|
+
allow: number[],
|
|
1345
|
+
): string {
|
|
1346
|
+
const allowSet = new Set(allow);
|
|
1347
|
+
const holders = listPids(port).map(pid => {
|
|
1348
|
+
const tags = [
|
|
1349
|
+
verifyOcx(pid) === pid ? "ocx" : "foreign",
|
|
1350
|
+
allowSet.has(pid) ? "allow" : "deny",
|
|
1351
|
+
isProcessAlive(pid) ? "live" : "dead",
|
|
1352
|
+
];
|
|
1353
|
+
return `${pid}(${tags.join(",")})`;
|
|
1354
|
+
});
|
|
1355
|
+
return `holders=[${holders.join(", ") || "none"}] allow=[${allow.join(", ") || "none"}]`;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** Stop the installed Windows backend and best-effort kill surviving :loop wrappers. */
|
|
1359
|
+
function stopWindowsServiceWrappersBestEffort(): void {
|
|
1360
|
+
if (process.platform !== "win32") return;
|
|
1361
|
+
try {
|
|
1362
|
+
if (readServiceBackend() === "native") {
|
|
1363
|
+
stopWinswService();
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
stopWindows();
|
|
1367
|
+
} catch { /* already stopped */ }
|
|
1368
|
+
killWindowsServiceWrapperProcesses();
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
|
|
1373
|
+
* `schtasks /end` ends the task instance but often leaves wscript/cmd running the
|
|
1374
|
+
* `:loop` batch, which brings the proxy back during post-update reclaim.
|
|
1375
|
+
*
|
|
1376
|
+
* This used to match the bare filenames with -like '*name*', which could stop a
|
|
1377
|
+
* wrapper belonging to a DIFFERENT OpenCodex home under the same account. The
|
|
1378
|
+
* shared killer scopes to this home's canonical paths as complete tokens.
|
|
1379
|
+
*/
|
|
1380
|
+
function killWindowsServiceWrapperProcesses(): void {
|
|
1381
|
+
killWindowsSchedulerWrappers({
|
|
1382
|
+
scriptPath: join(getConfigDir(), "opencodex-service.cmd"),
|
|
1383
|
+
launcherPath: join(getConfigDir(), "opencodex-service-launcher.vbs"),
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/** Exposed for tests: drives the non-service restart path with injected io. */
|
|
1388
|
+
export function restartAfterUpdateForTests(
|
|
1389
|
+
job: UpdateJobState,
|
|
1390
|
+
captured: { port: number; hostname: string; oldPid?: number },
|
|
1391
|
+
io: RestartIo,
|
|
1392
|
+
): Promise<void> {
|
|
1393
|
+
return restartAfterUpdate(job, captured, io);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function restartFailureHint(port: number): string {
|
|
1397
|
+
return `Update installed, but the restarted proxy did not stay healthy on port ${port}. `
|
|
1398
|
+
+ `Try 'ocx start --port ${port}'. `
|
|
1399
|
+
+ "If the update log shows bun postinstall or EPERM warnings, "
|
|
1400
|
+
+ `reinstall with 'npm install -g --allow-scripts=bun ${PKG}'.`;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
type AwaitHealthyResult =
|
|
1404
|
+
| { ok: true }
|
|
1405
|
+
| { ok: false; reason: "timeout" | "flapped" };
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Wait for an identity-checked /healthz on the captured listen target, then require a short
|
|
1409
|
+
* stability window. Soft: never marks the job failed (callers decide whether to fail or retry).
|
|
1410
|
+
*/
|
|
1411
|
+
async function awaitRestartedProxyHealthy(
|
|
1412
|
+
job: UpdateJobState,
|
|
1413
|
+
captured: { port: number; hostname: string },
|
|
1414
|
+
io: RestartIo = {},
|
|
1415
|
+
): Promise<AwaitHealthyResult> {
|
|
1416
|
+
// Fresh post-update starts are busy with catalog sync / OAuth; a single 750ms
|
|
1417
|
+
// /healthz miss must not fail the job. Use a longer probe and tolerate brief blips.
|
|
1418
|
+
const probe = io.probeProxy ?? (async (port: number, hostname?: string) => (
|
|
1419
|
+
!!(await proxyIdentityAt(port, { hostname }, { timeoutMs: 2_000, attempts: 3 }))
|
|
1420
|
+
));
|
|
1421
|
+
const sleep = io.sleepMs ?? (async (ms: number) => {
|
|
1422
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
1423
|
+
});
|
|
1424
|
+
const now = io.now ?? (() => Date.now());
|
|
1425
|
+
const port = captured.port;
|
|
1426
|
+
const hostname = captured.hostname;
|
|
1427
|
+
const startDeadline = now() + (io.healthTimeoutMs ?? RESTART_HEALTH_TIMEOUT_MS);
|
|
1428
|
+
/** Consecutive failed probes before the stability window counts as a flap. */
|
|
1429
|
+
const stabilityMissLimit = 3;
|
|
1430
|
+
|
|
1431
|
+
while (true) {
|
|
1432
|
+
// Always make one identity-aware probe at or after the boundary. A replacement
|
|
1433
|
+
// becoming healthy on the final tick must not be mistaken for a timeout.
|
|
1434
|
+
const finalProbe = now() >= startDeadline;
|
|
1435
|
+
if (await probe(port, hostname)) {
|
|
1436
|
+
updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`);
|
|
1437
|
+
const stableUntil = now() + RESTART_STABILITY_WINDOW_MS;
|
|
1438
|
+
let misses = 0;
|
|
1439
|
+
while (now() < stableUntil) {
|
|
1440
|
+
if (await probe(port, hostname)) {
|
|
1441
|
+
misses = 0;
|
|
1442
|
+
} else {
|
|
1443
|
+
misses += 1;
|
|
1444
|
+
if (misses >= stabilityMissLimit) {
|
|
1445
|
+
updateJob(job, {}, `Proxy became unhealthy on ${hostname}:${port} during the stability window.`);
|
|
1446
|
+
return { ok: false, reason: "flapped" };
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
await sleep(500);
|
|
1450
|
+
}
|
|
1451
|
+
updateJob(job, {}, `Proxy stayed healthy for ${Math.trunc(RESTART_STABILITY_WINDOW_MS / 1000)}s after restart.`);
|
|
1452
|
+
return { ok: true };
|
|
1453
|
+
}
|
|
1454
|
+
if (finalProbe) break;
|
|
1455
|
+
await sleep(Math.min(250, Math.max(0, startDeadline - now())));
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
return { ok: false, reason: "timeout" };
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* Confirm that the detached/service restart really came back and stayed up. The GUI worker
|
|
1463
|
+
* used to mark success immediately after spawning the new process, which hid Windows cases
|
|
1464
|
+
* where npm left the bundled Bun runtime half-updated and the restarted proxy died seconds
|
|
1465
|
+
* later. A healthy /healthz must appear, then remain healthy for one short stability window.
|
|
1466
|
+
*/
|
|
1467
|
+
async function confirmRestartedProxy(
|
|
1468
|
+
job: UpdateJobState,
|
|
1469
|
+
captured: { port: number; hostname: string },
|
|
1470
|
+
io: RestartIo = {},
|
|
1471
|
+
): Promise<boolean> {
|
|
1472
|
+
/* [Decision Log]
|
|
1473
|
+
- 목적과 의도: GUI update job이 detached restart 요청만 보고 성공 처리하지 않도록, 실제 프록시 복귀 여부를 확인한다.
|
|
1474
|
+
- 기존 구현 및 제약 조건: update-job.json은 spawn/service reinstall 직후 `succeeded`로 끝났고, Windows npm/Bun 교체 실패처럼 몇 초 후 죽는 재시작을 잡지 못했다.
|
|
1475
|
+
- 검토한 주요 대안: (1) 포트 점유만 확인 — 외부 프로세스/죽기 직전 프로세스를 성공으로 오인할 수 있다. (2) 무기한 /healthz 폴링 — UX가 느려지고 worker 종료 시점이 불명확하다. (3) 짧은 healthy 등장 + 안정성 창 확인 — 실제 복귀를 확인하면서도 대기 시간을 제한할 수 있다.
|
|
1476
|
+
- 선택한 방식: identity-aware /healthz probe가 일정 시간 안에 나타나고, 추가 안정성 창 동안 유지되는지 확인한다.
|
|
1477
|
+
- 다른 대안 대신 이 방식을 선택한 이유: GUI는 "업데이트가 설치됐지만 재시작은 실패"를 분리해 알려줘야 하며, 이 방식이 가장 적은 오탐으로 그 경계를 만든다.
|
|
1478
|
+
- 장점, 단점 및 영향: 장점은 silent restart failure가 update-job 상태로 드러난다는 점이다. 단점은 설정상 성공 판정 창이 30초 도착 + 15초 안정성으로 늘어나고 경계 probe 지연이 추가될 수 있다는 점이며, 대신 실제 복귀를 더 정확히 반영한다.
|
|
1479
|
+
*/
|
|
1480
|
+
const result = await awaitRestartedProxyHealthy(job, captured, io);
|
|
1481
|
+
if (result.ok) return true;
|
|
1482
|
+
const port = captured.port;
|
|
1483
|
+
const hostname = captured.hostname;
|
|
1484
|
+
const error = result.reason === "flapped"
|
|
1485
|
+
? `proxy restart became unhealthy on ${hostname}:${port}`
|
|
1486
|
+
: `proxy restart never became healthy on ${hostname}:${port}`;
|
|
1487
|
+
updateJob(job, {
|
|
1488
|
+
status: "failed",
|
|
1489
|
+
restarted: false,
|
|
1490
|
+
error,
|
|
1491
|
+
}, restartFailureHint(port));
|
|
1492
|
+
return false;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
export function confirmRestartAfterUpdateForTests(
|
|
1496
|
+
job: UpdateJobState,
|
|
1497
|
+
captured: { port: number; hostname: string },
|
|
1498
|
+
io: RestartIo,
|
|
1499
|
+
): Promise<boolean> {
|
|
1500
|
+
return confirmRestartedProxy(job, captured, io);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
async function defaultProbeProxyIdentity(
|
|
1504
|
+
port: number,
|
|
1505
|
+
hostname?: string,
|
|
1506
|
+
): Promise<RestartProxyIdentity | null> {
|
|
1507
|
+
try {
|
|
1508
|
+
const res = await fetch(`http://${probeHostname(hostname)}:${port}/healthz`, {
|
|
1509
|
+
signal: AbortSignal.timeout(750),
|
|
1510
|
+
});
|
|
1511
|
+
if (!res.ok) return null;
|
|
1512
|
+
const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
|
|
1513
|
+
if (!isOpencodexHealthz(body)) return null;
|
|
1514
|
+
return {
|
|
1515
|
+
pid: typeof body?.pid === "number" ? body.pid : null,
|
|
1516
|
+
// Validate the shape at the boundary where the value ENTERS, not where it is logged.
|
|
1517
|
+
// `/healthz` is answered by whatever is listening on that port, so a hostile or confused
|
|
1518
|
+
// responder can return any string here — and the restart-evidence reasons below
|
|
1519
|
+
// interpolate it into a persisted field. A version is a version or it is nothing.
|
|
1520
|
+
...(isVersionLike(body?.version) ? { version: body.version } : {}),
|
|
1521
|
+
};
|
|
1522
|
+
} catch {
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Health alone is not enough to skip the GUI worker restart: a surviving pre-update
|
|
1529
|
+
* process is still identity-healthy. Require update-correlated evidence — a new PID
|
|
1530
|
+
* when the pre-update PID was captured, and/or /healthz reporting the job's target
|
|
1531
|
+
* version when PID evidence is unavailable.
|
|
1532
|
+
*/
|
|
1533
|
+
export function npmSelfUpdateRestartEvidence(
|
|
1534
|
+
job: Pick<UpdateJobState, "latestVersion">,
|
|
1535
|
+
captured: { oldPid?: number },
|
|
1536
|
+
identity: RestartProxyIdentity | null,
|
|
1537
|
+
): { ok: true; detail: string } | { ok: false; reason: string } {
|
|
1538
|
+
if (!identity) return { ok: false, reason: "could not read proxy identity" };
|
|
1539
|
+
|
|
1540
|
+
const oldPid = typeof captured.oldPid === "number" && captured.oldPid > 0
|
|
1541
|
+
? captured.oldPid
|
|
1542
|
+
: undefined;
|
|
1543
|
+
const livePid = typeof identity.pid === "number" && identity.pid > 0 ? identity.pid : null;
|
|
1544
|
+
const expected = typeof job.latestVersion === "string" && job.latestVersion.length > 0
|
|
1545
|
+
? job.latestVersion
|
|
1546
|
+
: null;
|
|
1547
|
+
const versionMatches = expected !== null && identity.version === expected;
|
|
1548
|
+
|
|
1549
|
+
if (oldPid !== undefined) {
|
|
1550
|
+
if (livePid === oldPid) {
|
|
1551
|
+
return { ok: false, reason: "still the pre-update PID" };
|
|
1552
|
+
}
|
|
1553
|
+
if (livePid !== null) {
|
|
1554
|
+
if (expected !== null && identity.version && identity.version !== expected) {
|
|
1555
|
+
// Never echo the REPORTED version: `/healthz` is answered by whatever holds the port,
|
|
1556
|
+
// and `2.7.41-JaneDoe` is valid semver. Say that it mismatched, and name only the
|
|
1557
|
+
// version we expected — which is ours.
|
|
1558
|
+
return { ok: false, reason: `new pid but reported version did not match expected ${expected}` };
|
|
1559
|
+
}
|
|
1560
|
+
return { ok: true, detail: `pid changed ${oldPid}→${livePid}` };
|
|
1561
|
+
}
|
|
1562
|
+
// Pre-update PID known but healthz omitted pid — only accept matching target version.
|
|
1563
|
+
// On a match the reported value equals `expected`, so render the trusted one.
|
|
1564
|
+
if (versionMatches) return { ok: true, detail: `version ${expected}` };
|
|
1565
|
+
return { ok: false, reason: "no PID in healthz and version did not match the update target" };
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
if (versionMatches) return { ok: true, detail: `version ${expected}` };
|
|
1569
|
+
if (expected !== null && identity.version && identity.version !== expected) {
|
|
1570
|
+
return { ok: false, reason: `reported version did not match expected ${expected}` };
|
|
1571
|
+
}
|
|
1572
|
+
return { ok: false, reason: "no pre-update PID capture and no expected-version match" };
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Post-install restart for the GUI worker.
|
|
1577
|
+
*
|
|
1578
|
+
* npm installs run `node ocx.mjs update`, which already stops the proxy and reinstalls /
|
|
1579
|
+
* starts the service (or falls back to a direct start). A second `service install` here
|
|
1580
|
+
* calls `stopWindows()` on that healthy listener, then often fails elevation from the
|
|
1581
|
+
* non-interactive worker — leaving the captured port (default 10100) dead until a manual
|
|
1582
|
+
* restart. Prefer confirming the npm self-update's own restart first; only re-run restart
|
|
1583
|
+
* when that probe fails. Bun/source installs still always take the explicit restart path.
|
|
1584
|
+
*
|
|
1585
|
+
* Probe-first applies only to service-managed npm installs: without a service, `ocx.mjs`
|
|
1586
|
+
* only prints `ocx start` and never brings the proxy back, so waiting would always burn
|
|
1587
|
+
* the full health timeout. Skipping also requires update-correlated evidence (PID change
|
|
1588
|
+
* and/or target version) so a surviving pre-update process cannot look like success.
|
|
1589
|
+
* After an explicit npm restart the same evidence is required again — health alone is
|
|
1590
|
+
* not enough when a no-op restart or failed port reclaim leaves the old proxy up.
|
|
1591
|
+
*
|
|
1592
|
+
* Browser-dashboard update recovery must not require a viable Background Service: when
|
|
1593
|
+
* no service is installed (or reinstall leaves a non-viable/stale manager), the explicit
|
|
1594
|
+
* path always falls through to a direct `ocx start --port` so /healthz can recover.
|
|
1595
|
+
*/
|
|
1596
|
+
export async function finishGuiUpdateRestart(
|
|
1597
|
+
job: UpdateJobState,
|
|
1598
|
+
captured: { port: number; hostname: string; oldPid?: number },
|
|
1599
|
+
installer: Installer,
|
|
1600
|
+
io: RestartIo = {},
|
|
1601
|
+
): Promise<boolean> {
|
|
1602
|
+
if (installer === "npm") {
|
|
1603
|
+
const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
|
|
1604
|
+
if (serviceInstalled) {
|
|
1605
|
+
// Stop-first npm update leaves a dead PID's LISTEN row. Polling /healthz for the
|
|
1606
|
+
// full 30s against that zombie keeps ESTABLISHED TCBs alive and blocks bind.
|
|
1607
|
+
// If nothing live owns the port, skip straight to explicit restart. A failed
|
|
1608
|
+
// listener scan must not look like "no listeners" — fall back to /healthz.
|
|
1609
|
+
const aliveFn = io.isAliveFn ?? isProcessAlive;
|
|
1610
|
+
const scan: ListenPidScan = io.scanListenPidsFn
|
|
1611
|
+
? io.scanListenPidsFn(captured.port)
|
|
1612
|
+
: io.listListenPidsFn
|
|
1613
|
+
// Test seam: injected list is always a successful scan.
|
|
1614
|
+
? { ok: true, pids: io.listListenPidsFn(captured.port) }
|
|
1615
|
+
: scanListenPids(captured.port);
|
|
1616
|
+
const liveListeners = scan.ok
|
|
1617
|
+
? scan.pids.filter(pid => pid !== process.pid && aliveFn(pid))
|
|
1618
|
+
: null;
|
|
1619
|
+
if (liveListeners !== null && liveListeners.length === 0) {
|
|
1620
|
+
updateJob(job, {}, "npm self-update did not leave a live listener; performing explicit restart...");
|
|
1621
|
+
} else {
|
|
1622
|
+
if (!scan.ok) {
|
|
1623
|
+
updateJob(
|
|
1624
|
+
job,
|
|
1625
|
+
{},
|
|
1626
|
+
"Listener scan inconclusive after npm self-update; probing /healthz before deciding on explicit restart...",
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
const already = await awaitRestartedProxyHealthy(job, captured, io);
|
|
1630
|
+
if (already.ok) {
|
|
1631
|
+
const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)(
|
|
1632
|
+
captured.port,
|
|
1633
|
+
captured.hostname,
|
|
1634
|
+
);
|
|
1635
|
+
const evidence = npmSelfUpdateRestartEvidence(job, captured, identity);
|
|
1636
|
+
if (evidence.ok) {
|
|
1637
|
+
updateJob(
|
|
1638
|
+
job,
|
|
1639
|
+
{},
|
|
1640
|
+
`Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`,
|
|
1641
|
+
);
|
|
1642
|
+
return true;
|
|
1643
|
+
}
|
|
1644
|
+
updateJob(
|
|
1645
|
+
job,
|
|
1646
|
+
{},
|
|
1647
|
+
`npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`,
|
|
1648
|
+
);
|
|
1649
|
+
} else {
|
|
1650
|
+
updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart...");
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
const restartFn = io.restartAfterUpdateFn ?? restartAfterUpdate;
|
|
1656
|
+
await restartFn(job, captured, io);
|
|
1657
|
+
if (installer !== "npm") {
|
|
1658
|
+
// Bun/source: health alone remains enough unless a richer identity probe is supplied.
|
|
1659
|
+
if (!io.probeProxyIdentity) return confirmRestartedProxy(job, captured, io);
|
|
1660
|
+
}
|
|
1661
|
+
return confirmNpmExplicitRestart(job, captured, io);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* After an explicit npm (or identity-aware) restart, require update-correlated
|
|
1666
|
+
* evidence — not merely a healthy OpenCodex listener. A no-op restart or a
|
|
1667
|
+
* failed port reclaim can leave the pre-update process on the captured port;
|
|
1668
|
+
* `confirmRestartedProxy` alone would treat that as success.
|
|
1669
|
+
*/
|
|
1670
|
+
async function confirmNpmExplicitRestart(
|
|
1671
|
+
job: UpdateJobState,
|
|
1672
|
+
captured: { port: number; hostname: string; oldPid?: number },
|
|
1673
|
+
io: RestartIo = {},
|
|
1674
|
+
): Promise<boolean> {
|
|
1675
|
+
const healthy = await awaitRestartedProxyHealthy(job, captured, io);
|
|
1676
|
+
if (!healthy.ok) {
|
|
1677
|
+
const port = captured.port;
|
|
1678
|
+
const hostname = captured.hostname;
|
|
1679
|
+
const error = healthy.reason === "flapped"
|
|
1680
|
+
? `proxy restart became unhealthy on ${hostname}:${port}`
|
|
1681
|
+
: `proxy restart never became healthy on ${hostname}:${port}`;
|
|
1682
|
+
updateJob(job, {
|
|
1683
|
+
status: "failed",
|
|
1684
|
+
restarted: false,
|
|
1685
|
+
error,
|
|
1686
|
+
}, restartFailureHint(port));
|
|
1687
|
+
return false;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)(
|
|
1691
|
+
captured.port,
|
|
1692
|
+
captured.hostname,
|
|
1693
|
+
);
|
|
1694
|
+
const evidence = npmSelfUpdateRestartEvidence(job, captured, identity);
|
|
1695
|
+
if (!evidence.ok) {
|
|
1696
|
+
updateJob(job, {
|
|
1697
|
+
status: "failed",
|
|
1698
|
+
restarted: false,
|
|
1699
|
+
error: `proxy restart did not show update-correlated identity (${evidence.reason})`,
|
|
1700
|
+
}, restartFailureHint(captured.port));
|
|
1701
|
+
return false;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
updateJob(
|
|
1705
|
+
job,
|
|
1706
|
+
{},
|
|
1707
|
+
`Proxy restart confirmed on ${captured.hostname}:${captured.port} (${evidence.detail}).`,
|
|
1708
|
+
);
|
|
1709
|
+
return true;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* Test seams for the GUI update worker.
|
|
1714
|
+
*
|
|
1715
|
+
* The cache pre-flight and the install/stop step were previously reached only through module
|
|
1716
|
+
* globals, so "the gate runs before the stop" could only be asserted by comparing source-string
|
|
1717
|
+
* positions — a test that stays green even if the call is unreachable. These make the ordering
|
|
1718
|
+
* observable: a failed pre-flight must leave `runCommand` untouched.
|
|
1719
|
+
*/
|
|
1720
|
+
export interface GuiUpdateWorkerIo {
|
|
1721
|
+
cachePreflightFn?: () => { ok: boolean; reason: string };
|
|
1722
|
+
/** Force the resolved update target. A source checkout otherwise aborts before the npm branch. */
|
|
1723
|
+
checkForUpdateFn?: (channel: Channel) => ReturnType<typeof checkForUpdate>;
|
|
1724
|
+
/** Bypass the registry integrity probe, which runs before the cache gate and needs network. */
|
|
1725
|
+
integrityFn?: (version: string | null) => ReturnType<typeof checkUpdatePackageIntegrity>;
|
|
1726
|
+
runCommandFn?: (
|
|
1727
|
+
job: UpdateJobState,
|
|
1728
|
+
bin: string,
|
|
1729
|
+
args: string[],
|
|
1730
|
+
timeout: number,
|
|
1731
|
+
) => { status: number | null; signal: NodeJS.Signals | null };
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
export async function runGuiUpdateWorker(
|
|
1735
|
+
jobId: string,
|
|
1736
|
+
channel: Channel,
|
|
1737
|
+
restart: boolean,
|
|
1738
|
+
io: GuiUpdateWorkerIo = {},
|
|
1739
|
+
): Promise<void> {
|
|
1740
|
+
let job = readUpdateJob(jobId);
|
|
1741
|
+
const check = (io.checkForUpdateFn ?? checkForUpdate)(channel);
|
|
1742
|
+
const now = new Date().toISOString();
|
|
1743
|
+
// Capture the live listen target BEFORE the update command runs: the stop-first update
|
|
1744
|
+
// flow clears pid/runtime state, so this is the last moment the real port is knowable.
|
|
1745
|
+
// Only trust runtime-port.json when its pid matches the live pidfile process.
|
|
1746
|
+
const rt = readRuntimePort();
|
|
1747
|
+
const livePid = readPid();
|
|
1748
|
+
const preUpdateConfig = loadConfig();
|
|
1749
|
+
const runtimeTrusted = !!(rt && livePid && rt.pid === livePid);
|
|
1750
|
+
const configPort = typeof preUpdateConfig.port === "number" && preUpdateConfig.port > 0
|
|
1751
|
+
? preUpdateConfig.port
|
|
1752
|
+
: 10100;
|
|
1753
|
+
const captured = {
|
|
1754
|
+
port: runtimeTrusted ? rt.port : configPort,
|
|
1755
|
+
hostname: (runtimeTrusted ? rt.hostname : undefined) ?? preUpdateConfig.hostname ?? "127.0.0.1",
|
|
1756
|
+
...(runtimeTrusted && livePid ? { oldPid: livePid } : {}),
|
|
1757
|
+
};
|
|
1758
|
+
let trayWasInstalled = false;
|
|
1759
|
+
let trayWasRunning = false;
|
|
1760
|
+
if (!job) {
|
|
1761
|
+
job = {
|
|
1762
|
+
id: jobId,
|
|
1763
|
+
status: "running",
|
|
1764
|
+
startedAt: now,
|
|
1765
|
+
updatedAt: now,
|
|
1766
|
+
currentVersion: check.currentVersion,
|
|
1767
|
+
latestVersion: check.latestVersion,
|
|
1768
|
+
channel: check.channel,
|
|
1769
|
+
installer: check.installer,
|
|
1770
|
+
restart,
|
|
1771
|
+
command: check.command,
|
|
1772
|
+
releaseNotesUrl: check.releaseNotesUrl,
|
|
1773
|
+
log: [],
|
|
1774
|
+
};
|
|
1775
|
+
writeJob(job);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
try {
|
|
1779
|
+
if (!check.canUpdate) {
|
|
1780
|
+
throw new Error(check.reason ?? "No update is available");
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// Pre-flight integrity metadata check (same lanes as the CLI): anomalous registry
|
|
1784
|
+
// metadata for a resolved version fails the job BEFORE anything is spawned or the
|
|
1785
|
+
// proxy is stopped; transient registry failure degrades to a logged skip.
|
|
1786
|
+
const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion);
|
|
1787
|
+
if (integrity.ok === false) {
|
|
1788
|
+
updateJob(job, { status: "failed", error: integrity.reason });
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
const integrityLine = integrity.ok === "skipped"
|
|
1792
|
+
? `Integrity pre-flight skipped: ${integrity.reason}. Proceeding best-effort.`
|
|
1793
|
+
: `Verified ${PKG}@${check.latestVersion} integrity metadata ${integrity.integrity.slice(0, 24)}…`;
|
|
1794
|
+
|
|
1795
|
+
const cmd = updateExecutionCommand(check.installer, channel, undefined, check.latestVersion);
|
|
1796
|
+
job = updateJob(job, {
|
|
1797
|
+
currentVersion: check.currentVersion,
|
|
1798
|
+
latestVersion: check.latestVersion,
|
|
1799
|
+
installer: check.installer,
|
|
1800
|
+
command: cmd.display,
|
|
1801
|
+
}, integrityLine);
|
|
1802
|
+
|
|
1803
|
+
if (check.installer === "npm") {
|
|
1804
|
+
const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)();
|
|
1805
|
+
if (!cachePreflight.ok) {
|
|
1806
|
+
updateJob(job, {
|
|
1807
|
+
status: "failed",
|
|
1808
|
+
error: npmCachePreflightFailureMessage(cachePreflight.reason as NpmCachePreflightReason),
|
|
1809
|
+
}, "Update aborted before stopping the proxy because the npm cache pre-flight failed.");
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
if (process.platform === "win32") {
|
|
1815
|
+
try {
|
|
1816
|
+
const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows");
|
|
1817
|
+
const tray = getWindowsTrayStatus();
|
|
1818
|
+
const trayPlan = handoffWindowsTrayForUpdate(tray, {
|
|
1819
|
+
stop: () => {
|
|
1820
|
+
const stopped = stopWindowsTray();
|
|
1821
|
+
return { exitStatus: 0, running: stopped.running };
|
|
1822
|
+
},
|
|
1823
|
+
start: () => startWindowsTray(),
|
|
1824
|
+
});
|
|
1825
|
+
trayWasInstalled = trayPlan.refreshAfterReplacement;
|
|
1826
|
+
trayWasRunning = trayPlan.restoreOnFailure;
|
|
1827
|
+
} catch (error) {
|
|
1828
|
+
updateJob(job, {
|
|
1829
|
+
status: "failed",
|
|
1830
|
+
error: `Could not stop the Windows tray; aborting before package replacement: ${withheldSummary(error)}`,
|
|
1831
|
+
});
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/* [Decision Log]
|
|
1837
|
+
- 목적: GUI 요청 처리 프로세스가 자신이 실행 중인 패키지를 직접 덮어쓰지 않도록 업데이트를 별도 worker에서 수행한다.
|
|
1838
|
+
- 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능.
|
|
1839
|
+
- 선택 근거: 현재 CLI의 npm self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다.
|
|
1840
|
+
*/
|
|
1841
|
+
const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);
|
|
1842
|
+
if (result.status !== 0) {
|
|
1843
|
+
if (trayWasRunning) {
|
|
1844
|
+
try {
|
|
1845
|
+
const { startWindowsTray } = await import("../tray/windows");
|
|
1846
|
+
startWindowsTray();
|
|
1847
|
+
} catch { /* retain the primary update failure */ }
|
|
1848
|
+
}
|
|
1849
|
+
updateJob(job, {
|
|
1850
|
+
status: "failed",
|
|
1851
|
+
exitCode: result.status,
|
|
1852
|
+
signal: result.signal,
|
|
1853
|
+
error: `update command failed (${result.status ?? "?"})`,
|
|
1854
|
+
});
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
if (trayWasInstalled) {
|
|
1859
|
+
const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs);
|
|
1860
|
+
const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000);
|
|
1861
|
+
if (tray.status !== 0) {
|
|
1862
|
+
updateJob(job, {}, "Windows tray refresh failed; run 'ocx tray install'.");
|
|
1863
|
+
if (trayWasRunning) runLoggedCommand(job, process.execPath, selfLaunchArgv(["tray", "start"]), 15_000);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
if (restart) {
|
|
1868
|
+
job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy...");
|
|
1869
|
+
if (!(await finishGuiUpdateRestart(job, captured, check.installer))) return;
|
|
1870
|
+
updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy.");
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
updateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy to use the new version.");
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
if (trayWasRunning) {
|
|
1877
|
+
try {
|
|
1878
|
+
const { startWindowsTray } = await import("../tray/windows");
|
|
1879
|
+
startWindowsTray();
|
|
1880
|
+
} catch { /* retain the primary worker failure */ }
|
|
1881
|
+
}
|
|
1882
|
+
updateJob(job, {
|
|
1883
|
+
status: "failed",
|
|
1884
|
+
error: withheldSummary(err),
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
}
|