@iislee/opencodex 2.11.0 → 2.35.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/README.md +117 -22
- package/bin/ocx.mjs +245 -88
- package/bin/package-main.mjs +1 -1
- package/gui/dist/assets/index-Be1sDpWw.css +1 -0
- package/gui/dist/assets/index-D4OKgUV4.js +114 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +21 -10
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +475 -62
- package/src/adapters/base.ts +82 -7
- package/src/adapters/client-fingerprint.ts +18 -12
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +637 -0
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/checkpoint-store.ts +308 -0
- package/src/adapters/cursor/cursor-errors.ts +144 -6
- package/src/adapters/cursor/discovery.ts +122 -14
- package/src/adapters/cursor/effort-map.ts +106 -4
- package/src/adapters/cursor/envelope-echo.ts +290 -0
- package/src/adapters/cursor/framing.ts +39 -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/live-models.ts +180 -59
- package/src/adapters/cursor/live-transport.ts +623 -170
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-common.ts +23 -2
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- package/src/adapters/cursor/native-exec-fs.ts +10 -7
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +5 -3
- package/src/adapters/cursor/native-exec.ts +121 -14
- package/src/adapters/cursor/protobuf-events.ts +829 -11
- package/src/adapters/cursor/protobuf-request.ts +447 -75
- package/src/adapters/cursor/request-builder.ts +271 -35
- package/src/adapters/cursor/tool-definitions.ts +252 -12
- package/src/adapters/cursor/tool-result-normalize.ts +115 -0
- package/src/adapters/cursor/transport.ts +22 -0
- package/src/adapters/cursor/types.ts +48 -1
- package/src/adapters/cursor.ts +336 -39
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +713 -41
- package/src/adapters/google-antigravity-wire.ts +48 -10
- package/src/adapters/google-errors.ts +44 -12
- package/src/adapters/google-http.ts +38 -10
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +742 -86
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +27 -0
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +18 -2
- package/src/adapters/kiro-tools.ts +65 -17
- package/src/adapters/kiro.ts +270 -73
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1377 -344
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +1164 -64
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +105 -21
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/adapters/xai-web-search.ts +186 -0
- package/src/bridge.ts +512 -92
- package/src/chat/inbound.ts +57 -20
- package/src/chat/outbound.ts +97 -33
- package/src/claude/agents-inject.ts +40 -10
- package/src/claude/context-windows.ts +37 -14
- package/src/claude/desktop-3p.ts +243 -9
- package/src/claude/gateway-cache.ts +41 -4
- package/src/claude/inbound.ts +72 -3
- package/src/claude/model-info.ts +38 -15
- package/src/claude/outbound.ts +88 -21
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +150 -19
- package/src/cli/account-auth.ts +33 -6
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +648 -28
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +97 -19
- package/src/cli/agent.ts +53 -2
- package/src/cli/alias.ts +66 -0
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +496 -0
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +60 -15
- package/src/cli/claude.ts +129 -35
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +8 -0
- package/src/cli/config-command.ts +74 -10
- package/src/cli/dispatch.ts +821 -0
- package/src/cli/doctor.ts +440 -56
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +46 -20
- package/src/cli/help.ts +34 -274
- package/src/cli/index.ts +417 -528
- package/src/cli/init.ts +5 -17
- package/src/cli/inspect.ts +230 -0
- package/src/cli/integrations.ts +120 -2
- 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 +130 -2
- package/src/cli/models.ts +118 -16
- package/src/cli/observe.ts +123 -11
- package/src/cli/opencode.ts +4 -2
- package/src/cli/provider-runtime.ts +34 -3
- package/src/cli/provider.ts +25 -3
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +476 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +57 -10
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +51 -4
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/cli/usage-report.ts +184 -0
- package/src/cli/v2.ts +137 -18
- package/src/cli/version-skew.ts +46 -0
- package/src/cli.ts +1 -1
- package/src/clients/config-export.ts +1442 -23
- package/src/clients/effective-status.ts +30 -2
- package/src/clients/probes/cline.ts +81 -0
- package/src/codex/account-label.ts +35 -1
- package/src/codex/account-lifecycle.ts +130 -13
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +84 -0
- package/src/codex/account-store.ts +29 -2
- package/src/codex/account-usability.ts +25 -2
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/app-server-processes.ts +570 -107
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +893 -246
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/auth-context.ts +348 -32
- package/src/codex/autostart-health.ts +24 -1
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +86 -10
- package/src/codex/catalog/bundled.ts +331 -33
- package/src/codex/catalog/effort.ts +138 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +555 -45
- package/src/codex/catalog/native-models.ts +75 -0
- package/src/codex/catalog/parsing.ts +269 -30
- package/src/codex/catalog/provider-fetch.ts +1626 -144
- package/src/codex/catalog/sync.ts +1576 -187
- 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 +6 -3
- package/src/codex/codex-write-lock.ts +386 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +676 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/desktop-app-restart.ts +355 -0
- package/src/codex/features.ts +636 -39
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +436 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +30 -24
- package/src/codex/history-provider.ts +1016 -235
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +223 -0
- package/src/codex/inject-coordination.ts +373 -0
- package/src/codex/inject.ts +1114 -152
- package/src/codex/injected-marker.ts +37 -3
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +81 -0
- package/src/codex/journal.ts +66 -4
- package/src/codex/log-guard/inspect.ts +524 -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 +24 -0
- package/src/codex/main-account.ts +29 -1
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +56 -10
- package/src/codex/model-entitlements.ts +360 -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 +622 -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 +675 -0
- package/src/codex/paths.ts +80 -1
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/project-config-warnings.ts +20 -6
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +1313 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +264 -21
- package/src/codex/refresh.ts +11 -2
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +514 -94
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +1065 -31
- package/src/codex/subagent-model-fallback.ts +437 -43
- package/src/codex/sync.ts +191 -2
- package/src/codex/transition-state.ts +720 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +187 -81
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +47 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/request.ts +32 -0
- package/src/combos/types.ts +91 -9
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +121 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +309 -0
- package/src/config/provider-name.ts +24 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +1713 -709
- package/src/generated/compatibility-version.json +3324 -0
- package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
- package/src/grok/inject.ts +16 -5
- package/src/grok/inspect.ts +45 -0
- package/src/grok/sync.ts +2 -2
- package/src/images/loop.ts +157 -33
- package/src/images/plan.ts +24 -13
- 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-policy.ts +160 -0
- package/src/integrations/ownership.ts +155 -0
- package/src/integrations/registry.ts +166 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +433 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +715 -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/admin-secrets.ts +24 -0
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/bounded-body.ts +167 -11
- package/src/lib/bun-runtime.ts +125 -12
- package/src/lib/bun-stream-caps.ts +13 -9
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +26 -2
- package/src/lib/destination-policy.ts +65 -1
- package/src/lib/errors.ts +80 -5
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -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/optional-shutdown-hooks.ts +57 -0
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/lib/pinned-http.ts +145 -26
- package/src/lib/process-control.ts +6 -2
- package/src/lib/provider-outbound.ts +49 -9
- package/src/lib/redact.ts +419 -3
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/service-secrets.ts +15 -0
- package/src/lib/shadow-call.ts +35 -4
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/state-store-registrations.ts +10 -2
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/tool-argument-integers.ts +253 -0
- package/src/lib/translator-budget.ts +44 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +156 -3
- package/src/lib/windows-atomic-replace.ts +155 -0
- package/src/lib/windows-elevation.ts +70 -2
- package/src/lib/windows-secret-acl.ts +409 -69
- 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 +33 -5
- 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.ts +12 -1
- package/src/oauth/callback-server.ts +30 -4
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/cline.ts +203 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +46 -5
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/google-antigravity.ts +35 -3
- package/src/oauth/health.ts +20 -12
- package/src/oauth/index.ts +462 -71
- package/src/oauth/key-providers.ts +25 -0
- package/src/oauth/kimi.ts +25 -3
- package/src/oauth/kiro.ts +95 -6
- package/src/oauth/local-token-detect.ts +13 -2
- package/src/oauth/log.ts +3 -1
- package/src/oauth/login-cli.ts +88 -28
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +133 -26
- package/src/oauth/token-guardian.ts +9 -3
- package/src/oauth/types.ts +15 -0
- package/src/pi/models.ts +2 -2
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/antigravity-models.ts +521 -31
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +176 -0
- package/src/providers/context-cap.ts +26 -8
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +281 -6
- package/src/providers/fastwire.ts +507 -0
- package/src/providers/free-directory.ts +10 -7
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/label.ts +35 -2
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +115 -22
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/openai-sidecar.ts +72 -4
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/openai-tiers.ts +119 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +30 -0
- package/src/providers/quota.ts +1379 -40
- package/src/providers/registry.ts +1545 -112
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +94 -6
- 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 +32 -4
- package/src/reasoning-effort.ts +68 -3
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/compaction.ts +26 -1
- package/src/responses/custom-tool-compat.ts +384 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +435 -0
- package/src/responses/parser.ts +225 -38
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +504 -0
- package/src/responses/schema.ts +12 -2
- package/src/responses/spill-store.ts +75 -10
- package/src/responses/state.ts +659 -31
- package/src/responses/thought-signature-replay.ts +364 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/responses/turn-termination.ts +107 -0
- package/src/router.ts +405 -31
- package/src/routing/analytics.ts +379 -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 +155 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +2 -29
- package/src/server/auth-cors.ts +289 -48
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +154 -72
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +445 -0
- package/src/server/claude-messages.ts +160 -44
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +18 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +94 -12
- package/src/server/index.ts +1021 -190
- package/src/server/lifecycle.ts +336 -14
- package/src/server/live.ts +136 -17
- 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 +433 -116
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/body.ts +6 -0
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +64 -25
- package/src/server/management/config-routes.ts +511 -51
- package/src/server/management/context.ts +89 -2
- 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 +127 -52
- package/src/server/management/model-routes.ts +461 -133
- package/src/server/management/model-rows.ts +167 -0
- package/src/server/management/native-integration-routes.ts +770 -0
- package/src/server/management/oauth-account-routes.ts +90 -8
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +836 -159
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/route-registry.ts +332 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +380 -0
- package/src/server/management/shared.ts +27 -11
- package/src/server/management/sidebar-routes.ts +47 -31
- 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 +289 -33
- package/src/server/management/system-routes.ts +77 -0
- package/src/server/management/usage-summary-cache.ts +9 -1
- 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 +126 -23
- package/src/server/management-auth.ts +220 -5
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +161 -10
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +221 -54
- package/src/server/relay.ts +479 -87
- package/src/server/request-decompress.ts +113 -6
- package/src/server/request-log-conversation.ts +51 -0
- package/src/server/request-log.ts +298 -31
- 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/codex-auth-error.ts +55 -0
- package/src/server/responses/collaboration.ts +239 -36
- package/src/server/responses/combo-stream-preflight.ts +178 -0
- package/src/server/responses/compact.ts +444 -72
- package/src/server/responses/core.ts +3579 -436
- package/src/server/responses/empty-completion-guard.ts +333 -0
- package/src/server/responses/encrypted-payload.ts +62 -39
- package/src/server/responses/fetch-helpers.ts +89 -99
- package/src/server/responses/input-admission.ts +187 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +343 -0
- package/src/server/responses/terminal-guard.ts +26 -5
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses/ws-upstream.ts +381 -0
- package/src/server/responses-custom-tool-repair.ts +363 -0
- package/src/server/responses-item-id-repair.ts +54 -6
- 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 +363 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +376 -0
- package/src/server/responses.ts +18 -2
- package/src/server/search.ts +78 -13
- package/src/server/sse-frame-buffer.ts +319 -0
- package/src/server/sse-payload-rewrite.ts +110 -22
- package/src/server/startup-action-control.ts +8 -1
- package/src/server/startup-health-cache.ts +19 -1
- package/src/server/system-env.ts +80 -9
- package/src/server/ws-bridge.ts +53 -40
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +1153 -94
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/storage/cleanup.ts +2 -2
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- package/src/storage/scanner.ts +1 -1
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +83 -9
- package/src/tray/windows.ts +43 -16
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +911 -0
- package/src/types/provider.ts +614 -0
- package/src/types/request.ts +397 -0
- package/src/types/tools.ts +236 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +107 -1237
- package/src/update/index.ts +37 -23
- package/src/update/job.ts +445 -68
- package/src/update/notify.ts +12 -6
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +266 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +287 -16
- package/src/usage/log.ts +666 -50
- package/src/usage/summary.ts +388 -17
- 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 +10 -6
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +27 -16
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +238 -24
- 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 +13 -7
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +119 -0
- package/src/web-search/executor.ts +21 -6
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +150 -15
- package/src/web-search/loop.ts +284 -54
- package/src/web-search/parse.ts +125 -30
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-DTpMHS4F.js +0 -67
- package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
- package/src/cli/internal-dispatch.ts +0 -20
package/src/service.ts
CHANGED
|
@@ -7,35 +7,47 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync, execSync, spawnSync } from "node:child_process";
|
|
9
9
|
import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
|
|
10
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { dirname, join, resolve } from "node:path";
|
|
13
|
-
import { expandUserPath, getConfigDir,
|
|
14
|
-
import {
|
|
15
|
-
import { restoreNativeCodex } from "./codex/inject";
|
|
10
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir, tmpdir } from "node:os";
|
|
12
|
+
import { dirname, join, posix, resolve, win32 } from "node:path";
|
|
13
|
+
import { expandUserPath, getConfigDir, loadConfig } from "./config";
|
|
14
|
+
import { readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config/process-state";
|
|
15
|
+
import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
|
|
16
16
|
import { stripGrokConfig } from "./grok/inject";
|
|
17
|
-
import { isWslRuntime } from "./codex/home";
|
|
18
|
-
import {
|
|
17
|
+
import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home";
|
|
18
|
+
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime";
|
|
19
|
+
import type { BunRuntimeSource } from "./lib/bun-runtime";
|
|
19
20
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
20
21
|
import { serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
22
|
+
import { tokenCollidesWithAdmin } from "./lib/admin-secrets";
|
|
23
|
+
import { PROXY_ENV_KEYS } from "./lib/proxy-env";
|
|
21
24
|
import { randomUUID } from "node:crypto";
|
|
22
25
|
import {
|
|
23
26
|
ELEVATION_REQUEST_TIMEOUT_MS,
|
|
24
27
|
OCX_ELEVATED_PROTOCOL_FAILED,
|
|
25
28
|
raceWithTimeout,
|
|
29
|
+
resolveTrustedWindowsPowerShellExe,
|
|
26
30
|
resolveTrustedWindowsSchtasksExe,
|
|
27
31
|
startElevatedSchtasksCreateAndRun,
|
|
28
32
|
runWindowsElevated,
|
|
33
|
+
runWindowsElevatedScheduledTaskRegistration,
|
|
29
34
|
toWindowsSchtasksError,
|
|
30
35
|
WindowsElevationError,
|
|
36
|
+
WindowsSchtasksError,
|
|
31
37
|
type ElevatedSchedulerOutcome,
|
|
32
38
|
type ElevatedSchtasksCreateAndRunExecution,
|
|
33
39
|
type ElevatedSchtasksCreateAndRunResult,
|
|
34
40
|
} from "./lib/windows-elevation";
|
|
35
|
-
import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw";
|
|
36
|
-
import {
|
|
41
|
+
import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION, type WinswStatus } from "./lib/winsw";
|
|
42
|
+
import {
|
|
43
|
+
forgetEphemeralSecretDir,
|
|
44
|
+
forgetEphemeralSecretPath,
|
|
45
|
+
hardenSecretDir,
|
|
46
|
+
hardenSecretPath,
|
|
47
|
+
} from "./lib/windows-secret-acl";
|
|
37
48
|
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
|
|
38
49
|
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
50
|
+
import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
|
|
39
51
|
import { maybeShowStarPrompt } from "./cli/star-prompt";
|
|
40
52
|
|
|
41
53
|
const LABEL = "com.opencodex.proxy";
|
|
@@ -43,11 +55,15 @@ const TASK = "opencodex-proxy";
|
|
|
43
55
|
|
|
44
56
|
export type ServiceBackend = "scheduler" | "native";
|
|
45
57
|
|
|
46
|
-
function cliEntry(): { bun: string; cli: string } {
|
|
58
|
+
function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
|
|
47
59
|
// Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
|
|
48
60
|
// a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
|
|
49
61
|
// standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
|
|
50
|
-
|
|
62
|
+
//
|
|
63
|
+
// Path and provenance come from ONE resolution so the marker can never describe a
|
|
64
|
+
// different binary than the one actually baked.
|
|
65
|
+
const runtime = durableBunRuntime();
|
|
66
|
+
return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "cli", "index.ts") };
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
function plistPath(): string {
|
|
@@ -82,16 +98,41 @@ function defaultOpenCodexHome(): string {
|
|
|
82
98
|
return resolve(join(homedir(), ".opencodex"));
|
|
83
99
|
}
|
|
84
100
|
|
|
85
|
-
function
|
|
86
|
-
const paths = [
|
|
101
|
+
export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] {
|
|
102
|
+
const paths = [join(opencodexHome, "service-state.json")];
|
|
87
103
|
const defaultPath = join(defaultOpenCodexHome(), "service-state.json");
|
|
88
104
|
if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath);
|
|
89
105
|
return paths;
|
|
90
106
|
}
|
|
91
107
|
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
108
|
+
function serviceStatePaths(): string[] {
|
|
109
|
+
return serviceStatePathsForOpenCodexHome(currentOpenCodexHome());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function currentCodexHome(deps: CodexHomeDeps = {}): string {
|
|
113
|
+
// Service ownership must identify the same home as the runtime. In WSL an
|
|
114
|
+
// unset CODEX_HOME can resolve to the single Windows Desktop home rather than
|
|
115
|
+
// Linux ~/.codex; recording the fallback here creates a false foreign owner.
|
|
116
|
+
return resolveCodexHomeDir(deps);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function currentCodexSqliteHomeAbsolute(target: "native" | "windows" = "native"): string | undefined {
|
|
120
|
+
const raw = process.env.CODEX_SQLITE_HOME?.trim();
|
|
121
|
+
if (!raw) return undefined;
|
|
122
|
+
const expanded = expandUserPath(raw);
|
|
123
|
+
// Service artifacts can be rendered by cross-platform tests and repair tooling, so an
|
|
124
|
+
// already-absolute path for the TARGET platform is preserved rather than re-anchored
|
|
125
|
+
// against the writing host. `resolve()` is host-relative in both directions: on a POSIX
|
|
126
|
+
// host it turns `C:\data` into `<cwd>/C:\data`, and on a Windows host it turns `/tmp/x`
|
|
127
|
+
// into `D:\tmp\x` — neither is a path the target can use. A relative value still resolves,
|
|
128
|
+
// because a service unit has no meaningful working directory.
|
|
129
|
+
//
|
|
130
|
+
// CODEX_HOME and OPENCODEX_HOME are carried through literally, so without this the same
|
|
131
|
+
// generated file disagreed with itself about two variables holding the same kind of value.
|
|
132
|
+
if (target === "windows") {
|
|
133
|
+
return win32.isAbsolute(expanded) ? win32.normalize(expanded) : resolve(expanded);
|
|
134
|
+
}
|
|
135
|
+
return posix.isAbsolute(expanded) ? posix.normalize(expanded) : resolve(expanded);
|
|
95
136
|
}
|
|
96
137
|
|
|
97
138
|
function currentOpenCodexHome(): string {
|
|
@@ -169,13 +210,80 @@ function readServiceInstallState(): ServiceInstallState | null {
|
|
|
169
210
|
return null;
|
|
170
211
|
}
|
|
171
212
|
|
|
172
|
-
/**
|
|
213
|
+
/** What ONE state path said. Absent, unreadable and invalid are different answers. */
|
|
214
|
+
export type ServiceStateEvidence =
|
|
215
|
+
| { readonly path: string; readonly kind: "absent" }
|
|
216
|
+
| { readonly path: string; readonly kind: "unreadable"; readonly reason: string }
|
|
217
|
+
| { readonly path: string; readonly kind: "invalid" }
|
|
218
|
+
| { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallState };
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Every state path, with what each one said.
|
|
222
|
+
*
|
|
223
|
+
* `readServiceInstallState` returns the FIRST path that parsed and discards the
|
|
224
|
+
* rest, so a valid mirror beside a corrupt one reads as clean. That is the right
|
|
225
|
+
* behavior for callers that just need the install state; it is the wrong input
|
|
226
|
+
* for deciding ownership, where a disagreement between mirrors is exactly the
|
|
227
|
+
* evidence that matters.
|
|
228
|
+
*/
|
|
229
|
+
export function inspectServiceStateEvidence(
|
|
230
|
+
paths: readonly string[] = serviceStatePaths(),
|
|
231
|
+
): readonly ServiceStateEvidence[] {
|
|
232
|
+
return paths.map((path): ServiceStateEvidence => {
|
|
233
|
+
let raw: string;
|
|
234
|
+
try {
|
|
235
|
+
raw = readFileSync(path, "utf8");
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const code = error && typeof error === "object" && "code" in error
|
|
238
|
+
? String((error as { code?: unknown }).code)
|
|
239
|
+
: "";
|
|
240
|
+
// ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask,
|
|
241
|
+
// and collapsing them into absence is how a locked-down state file would
|
|
242
|
+
// become permission to write.
|
|
243
|
+
if (code === "ENOENT") return { path, kind: "absent" };
|
|
244
|
+
return { path, kind: "unreadable", reason: code || String(error) };
|
|
245
|
+
}
|
|
246
|
+
let parsed: ServiceInstallState | null;
|
|
247
|
+
try {
|
|
248
|
+
parsed = parseServiceInstallState(JSON.parse(raw));
|
|
249
|
+
} catch {
|
|
250
|
+
return { path, kind: "invalid" };
|
|
251
|
+
}
|
|
252
|
+
return parsed ? { path, kind: "valid", state: parsed } : { path, kind: "invalid" };
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The homes this process is actually using, for comparison against a claim. */
|
|
257
|
+
export function currentServiceHomes(deps: CodexHomeDeps = {}): { codexHome: string; opencodexHome: string } {
|
|
258
|
+
return { codexHome: currentCodexHome(deps), opencodexHome: currentOpenCodexHome() };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function serviceHomeMatches(a: string, b: string): boolean {
|
|
262
|
+
return normalizePathForCompare(a) === normalizePathForCompare(b);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Single accessor for backend-sensitive service code — v1/legacy state maps to scheduler. */
|
|
173
266
|
export function readServiceBackend(): ServiceBackend {
|
|
174
267
|
return readServiceInstallState()?.backend === "native" ? "native" : "scheduler";
|
|
175
268
|
}
|
|
176
269
|
|
|
177
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* The `ocx` argv that refreshes an already-installed service after an update.
|
|
272
|
+
*
|
|
273
|
+
* `repair` discovers the installed backend itself and, on Windows scheduler installs,
|
|
274
|
+
* rewrites the wrapper assets and restarts the existing task WITHOUT `schtasks /create`
|
|
275
|
+
* (see repairService below). `install` always reaches `/create`, which requires
|
|
276
|
+
* elevation — so an ordinary non-elevated `ocx update` used to stop a working proxy and
|
|
277
|
+
* then fail to bring its service back.
|
|
278
|
+
*
|
|
279
|
+
* The historical export name is kept for callers outside this module.
|
|
280
|
+
*/
|
|
178
281
|
export function serviceReinstallArgs(): string[] {
|
|
282
|
+
return ["service", "repair"];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The `ocx` argv that registers a service from scratch, preserving the chosen backend. */
|
|
286
|
+
export function serviceInstallArgs(): string[] {
|
|
179
287
|
return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
|
|
180
288
|
}
|
|
181
289
|
|
|
@@ -211,11 +319,12 @@ export function serviceEnvironmentOwnedHere(): boolean {
|
|
|
211
319
|
export function assertServiceEnvironmentMatchesInstall(): void {
|
|
212
320
|
const state = readServiceInstallState();
|
|
213
321
|
if (!state) return;
|
|
322
|
+
const actualCodexHome = currentCodexHome();
|
|
214
323
|
const expected = normalizePathForCompare(state.codexHome);
|
|
215
|
-
const actual = normalizePathForCompare(
|
|
324
|
+
const actual = normalizePathForCompare(actualCodexHome);
|
|
216
325
|
if (expected !== actual) {
|
|
217
326
|
throw new ServiceOwnershipError(
|
|
218
|
-
`Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${
|
|
327
|
+
`Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` +
|
|
219
328
|
"Run the service command from the same Codex home so native Codex restore updates the correct config.",
|
|
220
329
|
);
|
|
221
330
|
}
|
|
@@ -229,6 +338,7 @@ export function assertServiceEnvironmentMatchesInstall(): void {
|
|
|
229
338
|
}
|
|
230
339
|
}
|
|
231
340
|
|
|
341
|
+
|
|
232
342
|
function plistString(value: string): string {
|
|
233
343
|
return value
|
|
234
344
|
.replace(/&/g, "&")
|
|
@@ -243,19 +353,70 @@ function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
|
243
353
|
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
|
244
354
|
}
|
|
245
355
|
|
|
356
|
+
/**
|
|
357
|
+
* The `ocx` command a user should rerun for the service state they actually have.
|
|
358
|
+
*
|
|
359
|
+
* `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW
|
|
360
|
+
* conflict outright, so recommending repair there names a command guaranteed to fail.
|
|
361
|
+
* Install IS the valid conflict recovery, because `installWindows` removes the native
|
|
362
|
+
* backend first. Exported so the guard tests the real selector rather than a copy of it.
|
|
363
|
+
*/
|
|
364
|
+
export function serviceRetryCommand(
|
|
365
|
+
diag: Pick<ServiceDiagnostic, "installed" | "conflict"> = diagnoseService(),
|
|
366
|
+
): string {
|
|
367
|
+
return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Refuse a management (admin) token as the data-plane secret.
|
|
372
|
+
*
|
|
373
|
+
* The service exports the contents of the service token file as
|
|
374
|
+
* `OPENCODEX_API_AUTH_TOKEN` before starting the proxy. When that value is the admin
|
|
375
|
+
* token, the server treats the management credential as a data-plane admission secret
|
|
376
|
+
* and fails the ENTIRE management plane closed at boot, so every `/api/*` request
|
|
377
|
+
* returns 503 — even on a loopback install that never needed a data-plane secret.
|
|
378
|
+
* Exporting the admin token in the CLI cannot recover it, because the fence is decided
|
|
379
|
+
* server-side at startup (#2696).
|
|
380
|
+
*
|
|
381
|
+
* Nothing in this codebase puts an admin token in that env var; it arrives from the
|
|
382
|
+
* installing shell. This function is the chokepoint that should refuse it rather than
|
|
383
|
+
* writing a file that produces a broken service. Comparison is the same helper doctor
|
|
384
|
+
* uses: minted `ocx_admin_…` prefix, or byte-equal to configuredAdminToken (env or file).
|
|
385
|
+
*/
|
|
386
|
+
export function assertNotAdminToken(token: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
387
|
+
if (!tokenCollidesWithAdmin(token, env)) return;
|
|
388
|
+
throw new Error(
|
|
389
|
+
"OPENCODEX_API_AUTH_TOKEN holds a management (admin) token. The service exports it "
|
|
390
|
+
+ "as the data-plane secret, which fences the whole management API closed and makes "
|
|
391
|
+
+ "every ocx management command fail with 503. Unset OPENCODEX_API_AUTH_TOKEN, or set "
|
|
392
|
+
+ "it to a distinct data-plane key, then rerun the install.",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
246
396
|
export function assertServiceAuthEnvironment(): void {
|
|
247
397
|
const config = loadConfig();
|
|
398
|
+
// Check the collision before the loopback short-circuit: a loopback install writes
|
|
399
|
+
// the token file too, so returning early here is what let the broken state through.
|
|
400
|
+
const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
401
|
+
if (present) assertNotAdminToken(present);
|
|
248
402
|
if (isLoopbackHostname(config.hostname)) return;
|
|
249
403
|
if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
|
|
404
|
+
// Reached from `service repair` as well as `install`, so name a command that can
|
|
405
|
+
// actually succeed (see serviceRetryCommand).
|
|
406
|
+
const diag = diagnoseService();
|
|
407
|
+
const retry = serviceRetryCommand(diag);
|
|
250
408
|
throw new Error(
|
|
251
|
-
|
|
252
|
-
|
|
409
|
+
`OPENCODEX_API_AUTH_TOKEN is required before ${diag.installed ? "refreshing" : "installing"} a service `
|
|
410
|
+
+ `for non-loopback hostname. Set it in the same shell, then rerun \`${retry}\`.`,
|
|
253
411
|
);
|
|
254
412
|
}
|
|
255
413
|
|
|
256
414
|
function writeServiceApiTokenFile(): string | null {
|
|
257
415
|
const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
258
416
|
if (!token) return null;
|
|
417
|
+
// Last line of defence: every install/repair path funnels through here, so a
|
|
418
|
+
// collision cannot reach disk regardless of which caller ran (#2696).
|
|
419
|
+
assertNotAdminToken(token);
|
|
259
420
|
const path = serviceApiTokenFilePath();
|
|
260
421
|
const dir = getConfigDir();
|
|
261
422
|
recordOwnedConfigPath(dir, path);
|
|
@@ -267,17 +428,23 @@ function writeServiceApiTokenFile(): string | null {
|
|
|
267
428
|
return path;
|
|
268
429
|
}
|
|
269
430
|
|
|
270
|
-
export function buildPlist(): string {
|
|
271
|
-
const { bun, cli } = cliEntry();
|
|
431
|
+
export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
|
|
432
|
+
const { bun, bunRuntimeSource, cli } = cliEntry();
|
|
272
433
|
const log = logPath();
|
|
273
434
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
274
435
|
const codexHome = process.env.CODEX_HOME?.trim();
|
|
436
|
+
const codexSqliteHome = currentCodexSqliteHomeAbsolute();
|
|
275
437
|
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
|
|
276
438
|
const envLines = [
|
|
277
439
|
` <key>OCX_SERVICE</key><string>1</string>`,
|
|
440
|
+
` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
|
|
441
|
+
` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
|
|
278
442
|
` <key>PATH</key><string>${plistString(path)}</string>`,
|
|
279
443
|
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
|
|
444
|
+
codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
|
|
280
445
|
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
|
|
446
|
+
...proxyEnv.map(({ name, value }) =>
|
|
447
|
+
` <key>${name}</key><string>${plistString(value)}</string>`),
|
|
281
448
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
282
449
|
const command = buildServiceShellCommand(bun, cli);
|
|
283
450
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -491,17 +658,14 @@ async function reportServiceServing(
|
|
|
491
658
|
}
|
|
492
659
|
|
|
493
660
|
/**
|
|
494
|
-
* The
|
|
661
|
+
* The command that repairs the CURRENTLY INSTALLED backend without re-registering it.
|
|
495
662
|
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
* backend, so the hint has to carry `--native` when that is what is installed.
|
|
663
|
+
* `ocx service repair` reads the recorded backend itself, so it cannot silently switch a
|
|
664
|
+
* WinSW install to Task Scheduler the way a plain `ocx service install` would, and on
|
|
665
|
+
* Windows it needs no elevation because it never calls `schtasks /create`.
|
|
500
666
|
*/
|
|
501
667
|
function serviceRepairCommand(): string {
|
|
502
|
-
return
|
|
503
|
-
? "ocx service install --native"
|
|
504
|
-
: "ocx service install";
|
|
668
|
+
return "ocx service repair";
|
|
505
669
|
}
|
|
506
670
|
|
|
507
671
|
function systemdQuote(value: string): string {
|
|
@@ -517,6 +681,31 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined):
|
|
|
517
681
|
return `Environment=${systemdQuote(`${name}=${value}`)}`;
|
|
518
682
|
}
|
|
519
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Outbound proxy settings the installing shell had, resolved for baking into a service
|
|
686
|
+
* definition.
|
|
687
|
+
*
|
|
688
|
+
* A service manager does not inherit the environment of the shell that installed it, and
|
|
689
|
+
* `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not
|
|
690
|
+
* `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to
|
|
691
|
+
* reach the upstream got a service that dialed direct: the socket was reset, the retry
|
|
692
|
+
* budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The
|
|
693
|
+
* same install driven through `ocx codex-shim` worked, because that path spawns with
|
|
694
|
+
* `{ ...process.env }`.
|
|
695
|
+
*
|
|
696
|
+
* Lower-case variants are honored because curl-style tooling sets them and the runtime's
|
|
697
|
+
* own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
|
|
698
|
+
* upper-case name is baked, so a definition never carries two spellings of one setting.
|
|
699
|
+
*/
|
|
700
|
+
export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
|
|
701
|
+
const resolved: { name: string; value: string }[] = [];
|
|
702
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
703
|
+
const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
|
|
704
|
+
if (value) resolved.push({ name: key, value });
|
|
705
|
+
}
|
|
706
|
+
return resolved;
|
|
707
|
+
}
|
|
708
|
+
|
|
520
709
|
function systemdOutputTarget(value: string): string {
|
|
521
710
|
// StandardOutput/StandardError use output specifiers such as append:/path.
|
|
522
711
|
// Quoting the full specifier makes systemd reject it as an invalid output target.
|
|
@@ -544,16 +733,28 @@ function sh(cmd: string): string {
|
|
|
544
733
|
export function runLaunchctl(
|
|
545
734
|
args: string[],
|
|
546
735
|
deps: { run?: typeof spawnSync } = {},
|
|
547
|
-
): { ok: boolean; stdout: string; stderr: string } {
|
|
736
|
+
): { ok: boolean; stdout: string; stderr: string; status: number | null } {
|
|
548
737
|
const run = deps.run ?? spawnSync;
|
|
549
738
|
const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
|
|
550
739
|
// `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
|
|
551
740
|
// null for a signalled child; neither may be reported as success.
|
|
552
|
-
if (result.error)
|
|
741
|
+
if (result.error) {
|
|
742
|
+
return { ok: false, stdout: "", stderr: String(result.error.message ?? ""), status: null };
|
|
743
|
+
}
|
|
553
744
|
return {
|
|
554
745
|
ok: result.status === 0,
|
|
555
746
|
stdout: String(result.stdout ?? "").trim(),
|
|
556
747
|
stderr: String(result.stderr ?? "").trim(),
|
|
748
|
+
/*
|
|
749
|
+
* The NUMBER, not just its zero-ness.
|
|
750
|
+
*
|
|
751
|
+
* `launchctl print` distinguishes "that domain does not exist" (112) from
|
|
752
|
+
* "the domain answered and has no such service" (113), and an ownership
|
|
753
|
+
* probe needs that difference: the second proves absence, the first only
|
|
754
|
+
* proves we could not look. Collapsing both into `ok: false` forced callers
|
|
755
|
+
* to parse stderr, which Apple does not treat as a stable interface.
|
|
756
|
+
*/
|
|
757
|
+
status: result.status ?? null,
|
|
557
758
|
};
|
|
558
759
|
}
|
|
559
760
|
|
|
@@ -788,6 +989,10 @@ export function windowsSchedulerTaskInstalled(taskName = TASK): boolean {
|
|
|
788
989
|
export interface WindowsSchedulerInstallVerification {
|
|
789
990
|
taskInstalled: boolean;
|
|
790
991
|
registrationHealthy: boolean;
|
|
992
|
+
/** Well-formed XML that is PUBLISHED but policy-violating — permanent, never
|
|
993
|
+
* worth a settle retry (vs an empty/unreadable view, which is publication
|
|
994
|
+
* lag and transient). */
|
|
995
|
+
registrationInvalid: boolean;
|
|
791
996
|
assetsHealthy: boolean;
|
|
792
997
|
nativeServiceAbsent: boolean;
|
|
793
998
|
/** True when SCM probe failed; not a proven WinSW presence. */
|
|
@@ -808,6 +1013,10 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
|
|
|
808
1013
|
}): WindowsSchedulerInstallVerification {
|
|
809
1014
|
const registrationHealthy = inputs.xml.length > 0
|
|
810
1015
|
&& windowsTaskRegistrationHealthy(inputs.xml, inputs.wscript, inputs.launcher);
|
|
1016
|
+
// Permanent invalidity: the XML IS published but violates the registration
|
|
1017
|
+
// contract — no amount of settling changes it. Empty/unreadable XML stays
|
|
1018
|
+
// transient (publication lag).
|
|
1019
|
+
const registrationInvalid = inputs.taskInstalled && inputs.xml.length > 0 && !registrationHealthy;
|
|
811
1020
|
const assetsHealthy = inputs.assetsExist;
|
|
812
1021
|
const nativeServiceAbsent = inputs.nativeStatus === "nonexistent";
|
|
813
1022
|
const nativeStatusUnknown = inputs.nativeStatus === "unknown";
|
|
@@ -831,6 +1040,7 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
|
|
|
831
1040
|
return {
|
|
832
1041
|
taskInstalled: inputs.taskInstalled,
|
|
833
1042
|
registrationHealthy,
|
|
1043
|
+
registrationInvalid,
|
|
834
1044
|
assetsHealthy,
|
|
835
1045
|
nativeServiceAbsent,
|
|
836
1046
|
nativeStatusUnknown,
|
|
@@ -870,6 +1080,48 @@ async function elevateSchtasks(args: string[]): Promise<void> {
|
|
|
870
1080
|
}
|
|
871
1081
|
}
|
|
872
1082
|
|
|
1083
|
+
export interface WindowsSchedulerRollbackDeps {
|
|
1084
|
+
queryXml?: () => string;
|
|
1085
|
+
deleteTask?: () => Promise<void>;
|
|
1086
|
+
probe?: () => WindowsSchedulerTaskProbe;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
export async function rollbackWindowsSchedulerTaskOwnedByAttempt(
|
|
1090
|
+
attemptNonce: string,
|
|
1091
|
+
taskName = TASK,
|
|
1092
|
+
deps: WindowsSchedulerRollbackDeps = {},
|
|
1093
|
+
): Promise<string | null> {
|
|
1094
|
+
let registeredXml = "";
|
|
1095
|
+
try {
|
|
1096
|
+
registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", taskName, "/xml"])))();
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1099
|
+
return `Task Scheduler task ${taskName} ownership could not be proven: ${detail}. Residual scheduler state: task ${taskName} presence is unknown; no rollback deletion was attempted.`;
|
|
1100
|
+
}
|
|
1101
|
+
if (!registeredXml.trim()) {
|
|
1102
|
+
return `Task Scheduler task ${taskName} ownership could not be proven because its live XML was empty. Residual scheduler state: task ${taskName} presence is unknown; no rollback deletion was attempted.`;
|
|
1103
|
+
}
|
|
1104
|
+
if (!windowsTaskRegistrationOwnedByAttempt(registeredXml, attemptNonce)) {
|
|
1105
|
+
return `Task Scheduler task ${taskName} ownership could not be proven because its attempt nonce does not match. Residual scheduler state: task ${taskName} remains registered; no rollback deletion was attempted.`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
try {
|
|
1109
|
+
await (deps.deleteTask ?? (() => elevateSchtasks(["/delete", "/tn", taskName, "/f"])))();
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1112
|
+
return `Rollback deletion failed: ${detail}. Residual scheduler state: task ${taskName} may remain registered.`;
|
|
1113
|
+
}
|
|
1114
|
+
const probe = (deps.probe ?? (() => resolveWindowsSchedulerTaskProbe(taskName)))();
|
|
1115
|
+
if (probe.status === "absent") return null;
|
|
1116
|
+
if (probe.status === "unknown") {
|
|
1117
|
+
return `Task Scheduler task ${taskName} presence could not be verified after rollback: ${probe.detail}. Residual scheduler state: task presence is unknown.`;
|
|
1118
|
+
}
|
|
1119
|
+
return `Residual scheduler state: task ${taskName} is still present after rollback.`;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Legacy dashboard finalization creates and runs in one elevated child, whose protocol
|
|
1123
|
+
// performs its own rollback before returning. This fallback remains for indeterminate
|
|
1124
|
+
// protocol outcomes that predate the staged CLI transaction.
|
|
873
1125
|
async function rollbackElevatedSchedulerTask(taskName = TASK): Promise<string | null> {
|
|
874
1126
|
try {
|
|
875
1127
|
await elevateSchtasks(["/delete", "/tn", taskName, "/f"]);
|
|
@@ -909,6 +1161,8 @@ type FinalizeHooks = {
|
|
|
909
1161
|
/** Defense-in-depth: late reconciliation must still own this attempt. */
|
|
910
1162
|
stillOwnsAttempt?: (attemptId: string) => boolean;
|
|
911
1163
|
requestTimeoutMs?: number;
|
|
1164
|
+
/** Test-only seam for the post-create settle backoff; real installs use a timer. */
|
|
1165
|
+
settleDelay?: (ms: number) => Promise<void>;
|
|
912
1166
|
};
|
|
913
1167
|
|
|
914
1168
|
let finalizeHooks: FinalizeHooks | null = null;
|
|
@@ -973,6 +1227,68 @@ function attemptStillOwned(options: ApplyElevatedOptions): boolean {
|
|
|
973
1227
|
return !check || check(options.attemptId);
|
|
974
1228
|
}
|
|
975
1229
|
|
|
1230
|
+
/**
|
|
1231
|
+
* Bounded post-create backoff, 1.1s total. Task Scheduler's non-elevated view can
|
|
1232
|
+
* lag an elevated `/create` by a few hundred milliseconds, so a single verification
|
|
1233
|
+
* would roll back a task that is merely not visible yet.
|
|
1234
|
+
*/
|
|
1235
|
+
const SCHEDULER_SETTLE_DELAYS_MS = [50, 150, 300, 600] as const;
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Whether a failed verification is still worth re-checking after a short delay.
|
|
1239
|
+
*
|
|
1240
|
+
* Retrying is confined to states that a lagging scheduler view actually produces:
|
|
1241
|
+
* the task is not visible yet, or it is visible but its registration has not been
|
|
1242
|
+
* published in full. Everything else keeps its existing fail-closed meaning and is
|
|
1243
|
+
* rejected here so no delay can turn it into a pass:
|
|
1244
|
+
*
|
|
1245
|
+
* - a proven conflict (both backends present) is a real dual-backend install;
|
|
1246
|
+
* - missing assets are missing on disk, which no amount of waiting creates;
|
|
1247
|
+
* - a WinSW service that is proven present (`started`/`stopped`) is never absent
|
|
1248
|
+
* later. This is checked independently of `conflict`, which only becomes true
|
|
1249
|
+
* once the task itself is visible — while the task is still invisible the pair
|
|
1250
|
+
* is `conflict: false` with `nativeServiceAbsent: false`, and that must not retry;
|
|
1251
|
+
* - unknown SCM status is unproven rather than transient, and has its own
|
|
1252
|
+
* task-preserving branch below.
|
|
1253
|
+
*/
|
|
1254
|
+
/** Exported for tests: the transient-vs-permanent settle decision. */
|
|
1255
|
+
export function schedulerVerificationMaySettle(v: WindowsSchedulerInstallVerification): boolean {
|
|
1256
|
+
if (v.ok) return false;
|
|
1257
|
+
if (v.conflict) return false;
|
|
1258
|
+
if (!v.assetsHealthy) return false;
|
|
1259
|
+
if (!v.nativeServiceAbsent) return false;
|
|
1260
|
+
// A published-but-invalid registration is permanent: no delay repairs it.
|
|
1261
|
+
if (v.registrationInvalid) return false;
|
|
1262
|
+
return !v.taskInstalled || !v.registrationHealthy;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function settleDelay(ms: number): Promise<void> {
|
|
1266
|
+
const hook = finalizeHooks?.settleDelay;
|
|
1267
|
+
if (hook) return hook(ms);
|
|
1268
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Verify the elevated install, re-checking only while the failure looks like a
|
|
1273
|
+
* scheduler view that has not caught up yet. Returns `null` when this attempt lost
|
|
1274
|
+
* ownership mid-settle: a newer attempt owns the task, so this one must neither
|
|
1275
|
+
* write install state nor roll anything back.
|
|
1276
|
+
*/
|
|
1277
|
+
async function verifyWindowsSchedulerInstallAfterSettle(
|
|
1278
|
+
options: ApplyElevatedOptions,
|
|
1279
|
+
): Promise<WindowsSchedulerInstallVerification | null> {
|
|
1280
|
+
const verify = finalizeHooks?.verify ?? verifyWindowsSchedulerInstall;
|
|
1281
|
+
let verification = verify();
|
|
1282
|
+
for (const delayMs of SCHEDULER_SETTLE_DELAYS_MS) {
|
|
1283
|
+
if (!schedulerVerificationMaySettle(verification)) break;
|
|
1284
|
+
if (!attemptStillOwned(options)) return null;
|
|
1285
|
+
await settleDelay(delayMs);
|
|
1286
|
+
if (!attemptStillOwned(options)) return null;
|
|
1287
|
+
verification = verify();
|
|
1288
|
+
}
|
|
1289
|
+
return verification;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
976
1292
|
async function applyElevatedSchedulerResult(
|
|
977
1293
|
result: ElevatedSchtasksCreateAndRunResult,
|
|
978
1294
|
options: ApplyElevatedOptions,
|
|
@@ -1002,7 +1318,9 @@ async function applyElevatedSchedulerResult(
|
|
|
1002
1318
|
await reconcileUnknownElevatedOutcome(result.exitCode);
|
|
1003
1319
|
}
|
|
1004
1320
|
|
|
1005
|
-
const verification =
|
|
1321
|
+
const verification = await verifyWindowsSchedulerInstallAfterSettle(options);
|
|
1322
|
+
// Ownership moved to a newer attempt while settling; that attempt owns the outcome.
|
|
1323
|
+
if (!verification) return;
|
|
1006
1324
|
if (!verification.ok) {
|
|
1007
1325
|
// Preserve a healthy elevated task when WinSW absence cannot be proven (unknown SCM status).
|
|
1008
1326
|
// Unknown is not a confirmed dual-backend conflict; install state is still withheld.
|
|
@@ -1019,6 +1337,9 @@ async function applyElevatedSchedulerResult(
|
|
|
1019
1337
|
"Installation state was not written.",
|
|
1020
1338
|
]);
|
|
1021
1339
|
}
|
|
1340
|
+
// Rollback deletes a real task, so it needs the same ownership fence as the
|
|
1341
|
+
// state write below: a stale attempt must never delete a newer attempt's task.
|
|
1342
|
+
if (!attemptStillOwned(options)) return;
|
|
1022
1343
|
const rollbackError = await rollbackElevatedSchedulerTask();
|
|
1023
1344
|
const parts = [
|
|
1024
1345
|
"Elevated Task Scheduler registration did not produce a conflict-free install.",
|
|
@@ -1258,9 +1579,14 @@ function taskXmlRunLevelAcceptable(principal: string): boolean {
|
|
|
1258
1579
|
return value === "leastprivilege" || value === "highestavailable";
|
|
1259
1580
|
}
|
|
1260
1581
|
|
|
1261
|
-
export function buildWindowsServiceScript(
|
|
1262
|
-
|
|
1263
|
-
|
|
1582
|
+
export function buildWindowsServiceScript(
|
|
1583
|
+
entry = cliEntry(),
|
|
1584
|
+
port = resolveServiceListenPort(),
|
|
1585
|
+
proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
|
|
1586
|
+
): string {
|
|
1587
|
+
// Provenance rides along with the entry: a second durableBunRuntime() call here could
|
|
1588
|
+
// resolve differently from the binary the caller actually baked.
|
|
1589
|
+
const { bun, bunRuntimeSource, cli } = entry;
|
|
1264
1590
|
const path = process.env.PATH ?? "";
|
|
1265
1591
|
const lines = [
|
|
1266
1592
|
"@echo off",
|
|
@@ -1269,24 +1595,45 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
|
|
|
1269
1595
|
// it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
|
|
1270
1596
|
"chcp 65001 >nul",
|
|
1271
1597
|
windowsBatchSet("OCX_SERVICE", "1"),
|
|
1598
|
+
windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
|
|
1599
|
+
windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"),
|
|
1272
1600
|
windowsBatchSet("PATH", path, "pathList"),
|
|
1273
1601
|
windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
|
|
1602
|
+
windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
|
|
1274
1603
|
windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
|
|
1604
|
+
...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
|
|
1275
1605
|
windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
|
|
1276
1606
|
windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
|
|
1277
1607
|
windowsBatchSet("OCX_BUN", bun, "path"),
|
|
1278
1608
|
windowsBatchSet("OCX_CLI", cli, "path"),
|
|
1609
|
+
// Package root for the transactional-update restore path (#1942): cli is
|
|
1610
|
+
// <pkg>\src\cli\index.ts, so the package dir is three levels up.
|
|
1611
|
+
'for %%I in ("%OCX_CLI%\\..\\..\\..") do set "OCX_PKG_DIR=%%~fI"',
|
|
1279
1612
|
'if exist "%OCX_API_TOKEN_FILE%" (',
|
|
1280
1613
|
' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
|
|
1281
1614
|
")",
|
|
1282
1615
|
":loop",
|
|
1283
1616
|
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
|
|
1284
1617
|
'>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
|
|
1285
|
-
`>>"%OCX_SERVICE_LOG%" echo bun_source="${
|
|
1618
|
+
`>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntimeSource}"`,
|
|
1286
1619
|
'>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
|
|
1287
1620
|
'>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
|
|
1288
1621
|
'>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
|
|
1289
1622
|
'>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
|
|
1623
|
+
'if not exist "%OCX_BUN%" (',
|
|
1624
|
+
" call :restore_backup",
|
|
1625
|
+
")",
|
|
1626
|
+
'if not exist "%OCX_BUN%" (',
|
|
1627
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair',
|
|
1628
|
+
" exit /b 3",
|
|
1629
|
+
")",
|
|
1630
|
+
'if not exist "%OCX_CLI%" (',
|
|
1631
|
+
" call :restore_backup",
|
|
1632
|
+
")",
|
|
1633
|
+
'if not exist "%OCX_CLI%" (',
|
|
1634
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair',
|
|
1635
|
+
" exit /b 3",
|
|
1636
|
+
")",
|
|
1290
1637
|
`"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
|
|
1291
1638
|
"if %ERRORLEVEL% NEQ 0 (",
|
|
1292
1639
|
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
|
|
@@ -1296,6 +1643,26 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
|
|
|
1296
1643
|
" goto loop",
|
|
1297
1644
|
")",
|
|
1298
1645
|
"endlocal",
|
|
1646
|
+
"goto :eof",
|
|
1647
|
+
"",
|
|
1648
|
+
// #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and
|
|
1649
|
+
// a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE
|
|
1650
|
+
// the package tree, so it can restore when the launcher itself is gone — the exact
|
|
1651
|
+
// window the in-launcher boot probe cannot reach.
|
|
1652
|
+
":restore_backup",
|
|
1653
|
+
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] install incomplete - looking for a transactional-update backup to restore',
|
|
1654
|
+
'for /f "delims=" %%B in (\'dir /b /ad /o-n "%OCX_PKG_DIR%\\..\\.ocx-backup-*" 2^>nul\') do (',
|
|
1655
|
+
' if exist "%OCX_PKG_DIR%\\..\\%%B\\opencodex\\package.json" (',
|
|
1656
|
+
' if exist "%OCX_PKG_DIR%" rmdir /s /q "%OCX_PKG_DIR%" 2>nul',
|
|
1657
|
+
' move "%OCX_PKG_DIR%\\..\\%%B\\opencodex" "%OCX_PKG_DIR%" >nul 2>&1',
|
|
1658
|
+
' if exist "%OCX_PKG_DIR%\\package.json" (',
|
|
1659
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %%B',
|
|
1660
|
+
" goto :eof",
|
|
1661
|
+
" )",
|
|
1662
|
+
" )",
|
|
1663
|
+
")",
|
|
1664
|
+
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] no restorable backup found',
|
|
1665
|
+
"goto :eof",
|
|
1299
1666
|
].filter((line): line is string => Boolean(line));
|
|
1300
1667
|
return `${lines.join("\r\n")}\r\n`;
|
|
1301
1668
|
}
|
|
@@ -1305,6 +1672,11 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath
|
|
|
1305
1672
|
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
|
|
1306
1673
|
}
|
|
1307
1674
|
|
|
1675
|
+
/** Build the fixed scheduler-create command from an explicit staged XML document. */
|
|
1676
|
+
export function buildWindowsSchtasksCreateArgsForXml(xml: string): string[] {
|
|
1677
|
+
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1308
1680
|
/**
|
|
1309
1681
|
* VBS launcher that starts the batch wrapper with a hidden window (style 0).
|
|
1310
1682
|
* bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
|
|
@@ -1325,7 +1697,17 @@ export function buildWindowsLauncherVbs(script = windowsServiceScriptPath()): st
|
|
|
1325
1697
|
return `${lines.join("\r\n")}\r\n`;
|
|
1326
1698
|
}
|
|
1327
1699
|
|
|
1328
|
-
|
|
1700
|
+
function windowsTaskDescription(attemptNonce?: string): string {
|
|
1701
|
+
return attemptNonce
|
|
1702
|
+
? `OpenCodex proxy service wrapper; install-attempt=${attemptNonce}`
|
|
1703
|
+
: "OpenCodex proxy service wrapper";
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
export function buildWindowsTaskXml(
|
|
1707
|
+
script = windowsServiceScriptPath(),
|
|
1708
|
+
launcher = windowsLauncherVbsPath(),
|
|
1709
|
+
attemptNonce?: string,
|
|
1710
|
+
): string {
|
|
1329
1711
|
const escapedWscript = taskXmlString(windowsWscript());
|
|
1330
1712
|
// Escape the launcher path independently for the <Arguments> element; quoting it
|
|
1331
1713
|
// keeps spaces intact, and /b (batch mode) suppresses script error popups.
|
|
@@ -1333,7 +1715,7 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath(), launche
|
|
|
1333
1715
|
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
1334
1716
|
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
1335
1717
|
<RegistrationInfo>
|
|
1336
|
-
<Description
|
|
1718
|
+
<Description>${taskXmlString(windowsTaskDescription(attemptNonce))}</Description>
|
|
1337
1719
|
</RegistrationInfo>
|
|
1338
1720
|
<Triggers>
|
|
1339
1721
|
<LogonTrigger>
|
|
@@ -1453,6 +1835,21 @@ function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string):
|
|
|
1453
1835
|
return value?.trim().toLowerCase() === expected.toLowerCase();
|
|
1454
1836
|
}
|
|
1455
1837
|
|
|
1838
|
+
/** True only when the exported live task carries this install attempt's nonce. */
|
|
1839
|
+
export function windowsTaskRegistrationOwnedByAttempt(xml: string, attemptNonce: string): boolean {
|
|
1840
|
+
if (!attemptNonce) return false;
|
|
1841
|
+
const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
|
|
1842
|
+
if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
|
|
1843
|
+
if (taskXmlHasPrefixedTag(scrubbed, "RegistrationInfo")) return false;
|
|
1844
|
+
if (taskXmlElementCount(scrubbed, "RegistrationInfo") !== 1) return false;
|
|
1845
|
+
const registrationInfo = taskXmlSection(scrubbed, "RegistrationInfo");
|
|
1846
|
+
return taskXmlDecodedValueEquals(
|
|
1847
|
+
registrationInfo,
|
|
1848
|
+
"Description",
|
|
1849
|
+
windowsTaskDescription(attemptNonce),
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1456
1853
|
/** Validate the security/lifecycle-critical fields of the registered scheduler task. */
|
|
1457
1854
|
export function windowsTaskRegistrationHealthy(
|
|
1458
1855
|
xml: string,
|
|
@@ -1523,7 +1920,10 @@ function installLaunchd(): void {
|
|
|
1523
1920
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
1524
1921
|
writeServiceApiTokenFile();
|
|
1525
1922
|
const p = plistPath();
|
|
1526
|
-
|
|
1923
|
+
// Capture this BEFORE writing: the write below makes the plist exist unconditionally,
|
|
1924
|
+
// so a post-write existsSync would call every fresh install an "installed" service.
|
|
1925
|
+
const wasInstalled = existsSync(p);
|
|
1926
|
+
writeServiceDefinitionFile(p, buildPlist(), "utf8");
|
|
1527
1927
|
// Best-effort: an absent job is fine here, and a failed unload is caught by the
|
|
1528
1928
|
// load verification below with a better message than a raw unload error.
|
|
1529
1929
|
runLaunchctl(["unload", p]);
|
|
@@ -1535,7 +1935,9 @@ function installLaunchd(): void {
|
|
|
1535
1935
|
`launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
|
|
1536
1936
|
+ "A previous job may still be bootstrapped. Try:\n"
|
|
1537
1937
|
+ ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n`
|
|
1538
|
-
|
|
1938
|
+
// macOS `service repair` delegates straight to installLaunchd, so this fires for
|
|
1939
|
+
// an already-installed service too; repair reloads it without re-registering.
|
|
1940
|
+
+ `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
|
|
1539
1941
|
);
|
|
1540
1942
|
}
|
|
1541
1943
|
writeServiceInstallState();
|
|
@@ -1572,8 +1974,8 @@ export function startLaunchd(deps: {
|
|
|
1572
1974
|
throw new Error(
|
|
1573
1975
|
`launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
|
|
1574
1976
|
+ (live.loaded
|
|
1575
|
-
? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service
|
|
1576
|
-
: "The job is not loaded. Run 'ocx service
|
|
1977
|
+
? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service repair`
|
|
1978
|
+
: "The job is not loaded. Run 'ocx service repair' to reload it."),
|
|
1577
1979
|
);
|
|
1578
1980
|
}
|
|
1579
1981
|
function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
|
|
@@ -1584,6 +1986,52 @@ function uninstallLaunchd(): void {
|
|
|
1584
1986
|
if (existsSync(p)) unlinkSync(p);
|
|
1585
1987
|
}
|
|
1586
1988
|
|
|
1989
|
+
/**
|
|
1990
|
+
* Write a service definition with owner-only permissions.
|
|
1991
|
+
*
|
|
1992
|
+
* These files carry the outbound proxy environment (#2107), and a proxy URL routinely
|
|
1993
|
+
* carries `user:password`. `writeFileSync` without a mode lands at 0644 under the default
|
|
1994
|
+
* umask, so the credential would be world-readable on a shared host. Every other
|
|
1995
|
+
* secret-bearing write in this file already uses 0600 — the service API token and the
|
|
1996
|
+
* install state — and a service definition holding a proxy credential belongs in the same
|
|
1997
|
+
* class.
|
|
1998
|
+
*
|
|
1999
|
+
* The explicit `chmodSync` is not redundant: `mode` only applies when the file is
|
|
2000
|
+
* created, so an install over a definition left at 0644 by an earlier version would keep
|
|
2001
|
+
* the loose mode.
|
|
2002
|
+
*
|
|
2003
|
+
* On Windows the POSIX bits are advisory, so the ACL is the real boundary — and whether it
|
|
2004
|
+
* may soft-fail depends on what the definition actually contains. A definition carrying a
|
|
2005
|
+
* proxy credential is a secret publication and fails closed like the API token and the
|
|
2006
|
+
* install state do; one carrying only paths and a port is not worth refusing an install
|
|
2007
|
+
* over, since before #2107 these files had no hardening at all and a failure here would
|
|
2008
|
+
* regress a user who has no credential to protect.
|
|
2009
|
+
*/
|
|
2010
|
+
export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void {
|
|
2011
|
+
writeFileSync(path, content, { encoding, mode: 0o600 });
|
|
2012
|
+
try { chmodSync(path, 0o600); } catch { /* superseded by the Windows ACL below */ }
|
|
2013
|
+
if (process.platform === "win32") {
|
|
2014
|
+
hardenSecretPath(path, { required: definitionCarriesCredential(content) });
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/**
|
|
2019
|
+
* Does this service definition embed a credential-bearing proxy URL?
|
|
2020
|
+
*
|
|
2021
|
+
* Only the userinfo form leaks something: `http://user:pass@host` in any of the four proxy
|
|
2022
|
+
* variables. A bare `http://127.0.0.1:7890` is not a secret, and treating it as one would
|
|
2023
|
+
* make an icacls stall fail an install that had nothing to protect.
|
|
2024
|
+
*
|
|
2025
|
+
* The scan is over any URL in the rendered definition rather than over a `KEY=value` shape,
|
|
2026
|
+
* because the three formats render differently — systemd writes `Environment="K=V"`, the
|
|
2027
|
+
* plist writes `<key>K</key><string>V</string>`, and the Windows wrapper writes
|
|
2028
|
+
* `set "K=V"`. Keying on the assignment syntax silently missed the plist.
|
|
2029
|
+
*/
|
|
2030
|
+
export function definitionCarriesCredential(content: string): boolean {
|
|
2031
|
+
// A userinfo authority: scheme, then anything that is not a delimiter, then '@'.
|
|
2032
|
+
return /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>/@]+@/i.test(content);
|
|
2033
|
+
}
|
|
2034
|
+
|
|
1587
2035
|
// ── Windows (Task Scheduler) ──
|
|
1588
2036
|
/**
|
|
1589
2037
|
* In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
|
|
@@ -1592,7 +2040,7 @@ function uninstallLaunchd(): void {
|
|
|
1592
2040
|
function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
|
|
1593
2041
|
for (let attempt = 0; ; attempt++) {
|
|
1594
2042
|
try {
|
|
1595
|
-
|
|
2043
|
+
writeServiceDefinitionFile(path, content, encoding);
|
|
1596
2044
|
return;
|
|
1597
2045
|
} catch (err) {
|
|
1598
2046
|
const code = (err as NodeJS.ErrnoException).code;
|
|
@@ -1617,21 +2065,236 @@ function writeWindowsSchedulerAssets(): void {
|
|
|
1617
2065
|
writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
|
|
1618
2066
|
}
|
|
1619
2067
|
|
|
1620
|
-
|
|
1621
|
-
|
|
2068
|
+
const WINDOWS_SCHEDULER_STAGE_PREFIX = "opencodex-service-stage-";
|
|
2069
|
+
const ownedWindowsSchedulerStages = new Set<string>();
|
|
2070
|
+
|
|
2071
|
+
export interface WindowsSchedulerRegistrationStageDeps {
|
|
2072
|
+
createStageDir?: () => string;
|
|
2073
|
+
hardenDir?: (path: string) => void;
|
|
2074
|
+
writeXml?: (path: string, contents: string) => void;
|
|
2075
|
+
hardenPath?: (path: string) => void;
|
|
2076
|
+
removeStageDir?: (path: string) => void;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
function cleanupWindowsSchedulerStage(
|
|
2080
|
+
stageDir: string,
|
|
2081
|
+
xmlPath: string,
|
|
2082
|
+
removeStageDir: (path: string) => void,
|
|
2083
|
+
): void {
|
|
2084
|
+
let cleanupError: unknown;
|
|
2085
|
+
try {
|
|
2086
|
+
unlinkSync(xmlPath);
|
|
2087
|
+
forgetEphemeralSecretPath(xmlPath);
|
|
2088
|
+
} catch (error) {
|
|
2089
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
|
2090
|
+
forgetEphemeralSecretPath(xmlPath);
|
|
2091
|
+
} else {
|
|
2092
|
+
cleanupError = error;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
try {
|
|
2096
|
+
removeStageDir(stageDir);
|
|
2097
|
+
forgetEphemeralSecretDir(stageDir);
|
|
2098
|
+
} catch (error) {
|
|
2099
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
|
2100
|
+
forgetEphemeralSecretDir(stageDir);
|
|
2101
|
+
} else if (cleanupError) {
|
|
2102
|
+
throw new AggregateError([cleanupError, error], "Task Scheduler staging cleanup failed.");
|
|
2103
|
+
} else {
|
|
2104
|
+
cleanupError = error;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
if (cleanupError) throw cleanupError;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
export function stageWindowsSchedulerRegistrationXml(
|
|
2111
|
+
attemptNonce: string,
|
|
2112
|
+
deps: WindowsSchedulerRegistrationStageDeps = {},
|
|
2113
|
+
): string {
|
|
2114
|
+
const createStageDir = deps.createStageDir
|
|
2115
|
+
?? (() => mkdtempSync(join(tmpdir(), WINDOWS_SCHEDULER_STAGE_PREFIX)));
|
|
2116
|
+
const hardenDir = deps.hardenDir
|
|
2117
|
+
?? ((path: string) => { hardenSecretDir(path, { required: true }); });
|
|
2118
|
+
const writeXml = deps.writeXml ?? ((path: string, contents: string) => {
|
|
2119
|
+
writeFileSync(path, contents, { encoding: "utf16le", flag: "wx", mode: 0o600 });
|
|
2120
|
+
});
|
|
2121
|
+
const hardenPath = deps.hardenPath
|
|
2122
|
+
?? ((path: string) => { hardenSecretPath(path, { required: true }); });
|
|
2123
|
+
const removeStageDir = deps.removeStageDir
|
|
2124
|
+
?? ((path: string) => { rmdirSync(path); });
|
|
2125
|
+
|
|
2126
|
+
let stageDir: string | null = null;
|
|
2127
|
+
let xmlPath: string | null = null;
|
|
2128
|
+
try {
|
|
2129
|
+
stageDir = createStageDir();
|
|
2130
|
+
try { chmodSync(stageDir, 0o700); } catch { /* required Windows ACL is authoritative */ }
|
|
2131
|
+
hardenDir(stageDir);
|
|
2132
|
+
xmlPath = join(stageDir, "task.xml");
|
|
2133
|
+
// This document points at the canonical launcher but does not publish or rewrite it.
|
|
2134
|
+
// The hardened private directory prevents another local account from replacing the
|
|
2135
|
+
// document while UAC is pending; the file harden independently proves its identity.
|
|
2136
|
+
writeXml(
|
|
2137
|
+
xmlPath,
|
|
2138
|
+
`\uFEFF${buildWindowsTaskXml(windowsServiceScriptPath(), windowsLauncherVbsPath(), attemptNonce)}`,
|
|
2139
|
+
);
|
|
2140
|
+
hardenPath(xmlPath);
|
|
2141
|
+
ownedWindowsSchedulerStages.add(xmlPath);
|
|
2142
|
+
return xmlPath;
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
if (stageDir) {
|
|
2145
|
+
try {
|
|
2146
|
+
cleanupWindowsSchedulerStage(stageDir, xmlPath ?? join(stageDir, "task.xml"), removeStageDir);
|
|
2147
|
+
} catch (cleanupError) {
|
|
2148
|
+
throw new AggregateError(
|
|
2149
|
+
[error, cleanupError],
|
|
2150
|
+
"Task Scheduler staging failed and its private temporary directory could not be removed.",
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
throw error;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
function removeWindowsSchedulerRegistrationStage(xmlPath: string): void {
|
|
2159
|
+
if (!ownedWindowsSchedulerStages.has(xmlPath)) {
|
|
2160
|
+
throw new Error("Refusing to remove an unrecognized Task Scheduler staging path.");
|
|
2161
|
+
}
|
|
2162
|
+
const stageDir = dirname(xmlPath);
|
|
2163
|
+
cleanupWindowsSchedulerStage(
|
|
2164
|
+
stageDir,
|
|
2165
|
+
xmlPath,
|
|
2166
|
+
path => { rmdirSync(path); },
|
|
2167
|
+
);
|
|
2168
|
+
if (existsSync(stageDir)) {
|
|
2169
|
+
throw new Error("The private Task Scheduler staging directory still exists after cleanup.");
|
|
2170
|
+
}
|
|
2171
|
+
ownedWindowsSchedulerStages.delete(xmlPath);
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
export interface FreshWindowsSchedulerRegistrationDeps {
|
|
2175
|
+
create?: (args: string[]) => void;
|
|
2176
|
+
elevate?: (taskName: string, xml: string) => Promise<void>;
|
|
2177
|
+
probe?: () => WindowsSchedulerTaskProbe;
|
|
2178
|
+
queryXml?: () => string;
|
|
2179
|
+
rollback?: () => Promise<string | null>;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
export async function registerFreshWindowsSchedulerTask(
|
|
2183
|
+
xmlPath: string,
|
|
2184
|
+
attemptNonce: string,
|
|
2185
|
+
deps: FreshWindowsSchedulerRegistrationDeps = {},
|
|
2186
|
+
): Promise<void> {
|
|
2187
|
+
const args = buildWindowsSchtasksCreateArgsForXml(xmlPath);
|
|
2188
|
+
// Capture and validate the exact definition before an access-denied attempt can
|
|
2189
|
+
// cross the UAC boundary. The elevated fallback receives these immutable bytes,
|
|
2190
|
+
// never the caller-writable staging pathname.
|
|
2191
|
+
const expectedXml = decodeSchtasksOutput(readFileSync(xmlPath));
|
|
2192
|
+
if (
|
|
2193
|
+
!windowsTaskRegistrationHealthy(expectedXml)
|
|
2194
|
+
|| !windowsTaskRegistrationOwnedByAttempt(expectedXml, attemptNonce)
|
|
2195
|
+
) {
|
|
2196
|
+
throw new Error("The staged Task Scheduler registration failed OpenCodex ownership or shape validation.");
|
|
2197
|
+
}
|
|
2198
|
+
try {
|
|
2199
|
+
(deps.create ?? schtasks)(args);
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
if (
|
|
2202
|
+
!(error instanceof WindowsSchtasksError)
|
|
2203
|
+
|| error.operation !== "create"
|
|
2204
|
+
|| error.reason !== "access-denied"
|
|
2205
|
+
) {
|
|
2206
|
+
throw error;
|
|
2207
|
+
}
|
|
2208
|
+
// Register from the captured XML string inside the elevated process. Another
|
|
2209
|
+
// same-user process can mutate its own temp files, but cannot change this command.
|
|
2210
|
+
const elevate = deps.elevate ?? (async (taskName: string, xml: string) => {
|
|
2211
|
+
const exitCode = await runWindowsElevatedScheduledTaskRegistration(taskName, xml);
|
|
2212
|
+
if (exitCode !== 0) throw new Error(`Background service install failed with exit code ${exitCode}.`);
|
|
2213
|
+
});
|
|
2214
|
+
await elevate(TASK, expectedXml);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
const rollbackTask = deps.rollback ?? (() => rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK));
|
|
2218
|
+
const probe = (deps.probe ?? (() => probeWindowsSchedulerTask(TASK)))();
|
|
2219
|
+
if (probe.status === "absent") {
|
|
2220
|
+
throw new Error("Task Scheduler reported success, but the new registration is absent; no service cleanup was started.");
|
|
2221
|
+
}
|
|
2222
|
+
if (probe.status === "unknown") {
|
|
2223
|
+
const rollback = await rollbackTask();
|
|
2224
|
+
throw new Error(
|
|
2225
|
+
`Task Scheduler registration was not verifiably present after create (${probe.detail}).`
|
|
2226
|
+
+ (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
let registeredXml = "";
|
|
2231
|
+
let queryDetail: string | null = null;
|
|
2232
|
+
try {
|
|
2233
|
+
registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", TASK, "/xml"])))();
|
|
2234
|
+
} catch (error) {
|
|
2235
|
+
queryDetail = error instanceof Error ? error.message : String(error);
|
|
2236
|
+
}
|
|
2237
|
+
if (!registeredXml.trim()) {
|
|
2238
|
+
const rollback = await rollbackTask();
|
|
2239
|
+
throw new Error(
|
|
2240
|
+
"Task Scheduler registration was created, but its live XML could not be verified."
|
|
2241
|
+
+ (queryDetail ? ` Query failed: ${queryDetail}` : " The query returned an empty document.")
|
|
2242
|
+
+ (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
if (
|
|
2246
|
+
!windowsTaskRegistrationHealthy(registeredXml)
|
|
2247
|
+
|| !windowsTaskRegistrationOwnedByAttempt(registeredXml, attemptNonce)
|
|
2248
|
+
) {
|
|
2249
|
+
const rollback = await rollbackTask();
|
|
2250
|
+
throw new Error(
|
|
2251
|
+
"Task Scheduler registration was created but failed the OpenCodex action/trigger or attempt-ownership verification."
|
|
2252
|
+
+ (rollback ? ` Cleanup also failed: ${rollback}` : " The invalid registration was rolled back."),
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function recordWindowsSchedulerOwnership(): boolean {
|
|
2258
|
+
// Ownership claiming is deliberately conservative: a legacy non-empty config root
|
|
2259
|
+
// without metadata stays unclaimed, but that must not turn a service reinstall into
|
|
2260
|
+
// an outage after prepareServiceInstall has stopped the previous manager.
|
|
2261
|
+
return recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
export interface RemoveNativeWindowsServiceDeps {
|
|
2265
|
+
status?: () => WinswStatus;
|
|
2266
|
+
uninstall?: () => void;
|
|
2267
|
+
sleep?: (ms: number) => void;
|
|
2268
|
+
settleChecks?: number;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
export function removeNativeWindowsServiceForScheduler(
|
|
2272
|
+
deps: RemoveNativeWindowsServiceDeps = {},
|
|
2273
|
+
): void {
|
|
2274
|
+
const status = deps.status ?? statusWinswRaw;
|
|
2275
|
+
const uninstall = deps.uninstall ?? uninstallWinswService;
|
|
2276
|
+
const sleep = deps.sleep ?? Bun.sleepSync;
|
|
2277
|
+
const settleChecks = Math.max(1, deps.settleChecks ?? 20);
|
|
1622
2278
|
// Transactional backend switch: installing the scheduler backend removes a native
|
|
1623
2279
|
// service first — two live managers would both respawn the proxy (conflict).
|
|
1624
|
-
if (
|
|
2280
|
+
if (status() !== "nonexistent") {
|
|
1625
2281
|
console.log("🔁 Removing the native (WinSW) service before installing the Task Scheduler backend...");
|
|
1626
2282
|
try {
|
|
1627
|
-
|
|
2283
|
+
uninstall();
|
|
1628
2284
|
} catch (err) {
|
|
1629
2285
|
throw new Error(`Cannot remove the native service before switching to Task Scheduler: ${err instanceof Error ? err.message : String(err)}. Remove it manually with 'sc delete ${WINSW_SERVICE_ID}' or retry.`);
|
|
1630
2286
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
2287
|
+
for (let check = 0; check < settleChecks; check++) {
|
|
2288
|
+
if (status() === "nonexistent") return;
|
|
2289
|
+
if (check + 1 < settleChecks) sleep(250);
|
|
1633
2290
|
}
|
|
2291
|
+
throw new Error(`Native service registration could not be re-verified after the removal attempt — aborting switch. Check 'sc.exe query ${WINSW_SERVICE_ID}' and remove it manually if present.`);
|
|
1634
2292
|
}
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function installWindows(): void {
|
|
2296
|
+
recordWindowsSchedulerOwnership();
|
|
2297
|
+
removeNativeWindowsServiceForScheduler();
|
|
1635
2298
|
// End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
|
|
1636
2299
|
// script mid-rewrite runs a torn batch file, and its open handle can fail the write.
|
|
1637
2300
|
try { stopWindows(); } catch { /* not running */ }
|
|
@@ -1803,6 +2466,22 @@ export function stopWindows(): void {
|
|
|
1803
2466
|
}
|
|
1804
2467
|
function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
|
|
1805
2468
|
function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
|
|
2469
|
+
|
|
2470
|
+
/**
|
|
2471
|
+
* Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
|
|
2472
|
+
* `schtasks /end` ends the task instance but often leaves wscript/cmd running the
|
|
2473
|
+
* `:loop` batch, which brings the proxy back during a stop or restart.
|
|
2474
|
+
*
|
|
2475
|
+
* The matching rule — canonical paths of THIS installation, as complete
|
|
2476
|
+
* command-line tokens — lives in lib/windows-service-wrappers so the update job
|
|
2477
|
+
* cannot drift away from it again.
|
|
2478
|
+
*/
|
|
2479
|
+
function killWindowsServiceWrapperProcesses(): void {
|
|
2480
|
+
killWindowsSchedulerWrappers({
|
|
2481
|
+
scriptPath: windowsServiceScriptPath(),
|
|
2482
|
+
launcherPath: windowsLauncherVbsPath(),
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
1806
2485
|
function uninstallWindows(): void {
|
|
1807
2486
|
const probe = probeWindowsSchedulerTask(TASK);
|
|
1808
2487
|
if (probe.status === "present") {
|
|
@@ -1836,7 +2515,7 @@ export function bakedServicePathsDiagnostic(): string | null {
|
|
|
1836
2515
|
if (!state?.bunPath || !state?.cliPath) return null;
|
|
1837
2516
|
const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
|
|
1838
2517
|
if (missing.length === 0) return null;
|
|
1839
|
-
return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service
|
|
2518
|
+
return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service repair' to re-bake`;
|
|
1840
2519
|
}
|
|
1841
2520
|
|
|
1842
2521
|
function serviceDiagnosticsSummary(): string {
|
|
@@ -1853,17 +2532,22 @@ function unitPath(): string {
|
|
|
1853
2532
|
return join(unitDir(), `${TASK}.service`);
|
|
1854
2533
|
}
|
|
1855
2534
|
|
|
1856
|
-
export function buildUnit(): string {
|
|
1857
|
-
const { bun, cli } = cliEntry();
|
|
2535
|
+
export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
|
|
2536
|
+
const { bun, bunRuntimeSource, cli } = cliEntry();
|
|
1858
2537
|
const log = logPath();
|
|
1859
2538
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
1860
2539
|
const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
|
|
2540
|
+
const codexSqliteHome = systemdEnvironmentAssignment("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute());
|
|
1861
2541
|
const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
|
|
1862
2542
|
const envLines = [
|
|
1863
2543
|
systemdEnvironmentAssignment("OCX_SERVICE", "1"),
|
|
2544
|
+
systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
|
|
2545
|
+
systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
|
|
1864
2546
|
systemdEnvironmentAssignment("PATH", path),
|
|
1865
2547
|
codexHome,
|
|
2548
|
+
codexSqliteHome,
|
|
1866
2549
|
opencodexHome,
|
|
2550
|
+
...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
|
|
1867
2551
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
1868
2552
|
return `[Unit]
|
|
1869
2553
|
Description=OpenCodex Proxy Server
|
|
@@ -1924,7 +2608,7 @@ function installSystemd(): void {
|
|
|
1924
2608
|
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
1925
2609
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
1926
2610
|
writeServiceApiTokenFile();
|
|
1927
|
-
|
|
2611
|
+
writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8");
|
|
1928
2612
|
sh("systemctl --user daemon-reload");
|
|
1929
2613
|
sh(`systemctl --user enable ${TASK}`);
|
|
1930
2614
|
sh(`systemctl --user restart ${TASK}`);
|
|
@@ -1987,6 +2671,11 @@ type ServiceOps = {
|
|
|
1987
2671
|
status: () => string; uninstall: () => void;
|
|
1988
2672
|
};
|
|
1989
2673
|
|
|
2674
|
+
type ServiceInstallCleanupOps = {
|
|
2675
|
+
status: () => string | null;
|
|
2676
|
+
stop: () => void;
|
|
2677
|
+
};
|
|
2678
|
+
|
|
1990
2679
|
function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
|
|
1991
2680
|
if (process.platform === "darwin")
|
|
1992
2681
|
return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
|
|
@@ -2012,6 +2701,67 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
|
|
|
2012
2701
|
return null;
|
|
2013
2702
|
}
|
|
2014
2703
|
|
|
2704
|
+
/**
|
|
2705
|
+
* Install-only manager operations. Unlike the ordinary status/stop helpers, these
|
|
2706
|
+
* distinguish confirmed absence from a failed manager query and propagate every
|
|
2707
|
+
* non-benign stop failure. Installing new assets is unsafe while either answer is
|
|
2708
|
+
* unknown because an old manager may still respawn a listener on the target port.
|
|
2709
|
+
*/
|
|
2710
|
+
function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInstallCleanupOps | null {
|
|
2711
|
+
if (process.platform === "darwin") {
|
|
2712
|
+
return {
|
|
2713
|
+
status: () => {
|
|
2714
|
+
const listing = sh("launchctl list");
|
|
2715
|
+
return listing.split("\n").some(line => line.includes(LABEL)) ? listing : null;
|
|
2716
|
+
},
|
|
2717
|
+
stop: () => { sh(`launchctl unload "${plistPath()}"`); },
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
if (process.platform === "win32") {
|
|
2721
|
+
if (backend === "native") {
|
|
2722
|
+
return {
|
|
2723
|
+
status: () => {
|
|
2724
|
+
const status = statusWinswRaw();
|
|
2725
|
+
if (status === "unknown") throw new Error("Native service status could not be verified.");
|
|
2726
|
+
return status === "nonexistent" ? null : status;
|
|
2727
|
+
},
|
|
2728
|
+
stop: stopWinswService,
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
return {
|
|
2732
|
+
status: () => {
|
|
2733
|
+
const probe = probeWindowsSchedulerTask(TASK);
|
|
2734
|
+
if (probe.status === "unknown") throw new Error(`Task Scheduler status could not be verified: ${probe.detail}`);
|
|
2735
|
+
return probe.status === "present" ? "present" : null;
|
|
2736
|
+
},
|
|
2737
|
+
stop: () => {
|
|
2738
|
+
try {
|
|
2739
|
+
schtasks(["/end", "/tn", TASK]);
|
|
2740
|
+
} catch (error) {
|
|
2741
|
+
if (!isWindowsSchedulerEndBenign(error)) throw error;
|
|
2742
|
+
}
|
|
2743
|
+
},
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
if (process.platform === "linux") {
|
|
2747
|
+
return {
|
|
2748
|
+
status: () => {
|
|
2749
|
+
// `list-unit-files <name>` exits non-zero when the unit has never been
|
|
2750
|
+
// installed, which made a clean first install look like an unknown manager
|
|
2751
|
+
// failure. `show LoadState` gives us the tri-state we actually need: a
|
|
2752
|
+
// healthy user manager returns `not-found` for a missing unit, while an
|
|
2753
|
+
// unreachable/permission-denied manager still makes `sh()` throw and the
|
|
2754
|
+
// caller therefore fails closed.
|
|
2755
|
+
const loadState = sh(`systemctl --user show ${TASK} --property=LoadState --value`).trim().toLowerCase();
|
|
2756
|
+
if (!loadState) throw new Error("systemd service status could not be verified.");
|
|
2757
|
+
return loadState === "not-found" ? null : loadState;
|
|
2758
|
+
},
|
|
2759
|
+
stop: () => { sh(`systemctl --user stop ${TASK}`); },
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
return null;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2015
2765
|
type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
|
|
2016
2766
|
|
|
2017
2767
|
function verifiedKillTarget(pid: number | null | undefined): number | null {
|
|
@@ -2107,6 +2857,159 @@ async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupR
|
|
|
2107
2857
|
}
|
|
2108
2858
|
}
|
|
2109
2859
|
|
|
2860
|
+
export interface ServiceInstallPreparationDeps {
|
|
2861
|
+
diagnose?: () => ServiceDiagnostic;
|
|
2862
|
+
managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null;
|
|
2863
|
+
stopTrackedProxy?: () => Promise<unknown>;
|
|
2864
|
+
platform?: NodeJS.Platform;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
/**
|
|
2868
|
+
* Stop every manager that could own the install port, then stop the tracked
|
|
2869
|
+
* standalone listener. Any unknown status or cleanup failure rejects, so callers
|
|
2870
|
+
* cannot write assets or report success over a surviving old listener.
|
|
2871
|
+
*/
|
|
2872
|
+
export async function prepareServiceInstall(
|
|
2873
|
+
requestedBackend: ServiceBackend,
|
|
2874
|
+
deps: ServiceInstallPreparationDeps = {},
|
|
2875
|
+
): Promise<void> {
|
|
2876
|
+
const diagnostic = (deps.diagnose ?? diagnoseService)();
|
|
2877
|
+
const platform = deps.platform ?? process.platform;
|
|
2878
|
+
const resolveOps = deps.managerOps ?? platformServiceInstallCleanupOps;
|
|
2879
|
+
const backends: ServiceBackend[] = [];
|
|
2880
|
+
const addBackend = (backend: ServiceBackend) => {
|
|
2881
|
+
if (!backends.includes(backend)) backends.push(backend);
|
|
2882
|
+
};
|
|
2883
|
+
|
|
2884
|
+
if (platform === "win32") {
|
|
2885
|
+
// The recorded backend owns the old installation and must be stopped first.
|
|
2886
|
+
// A conflicting diagnostic means both managers exist, so stop both even when
|
|
2887
|
+
// the requested backend happens to match the recorded one.
|
|
2888
|
+
if (diagnostic.backend === "scheduler" || diagnostic.backend === "native") {
|
|
2889
|
+
addBackend(diagnostic.backend);
|
|
2890
|
+
if (diagnostic.conflict) addBackend(diagnostic.backend === "scheduler" ? "native" : "scheduler");
|
|
2891
|
+
}
|
|
2892
|
+
addBackend(requestedBackend);
|
|
2893
|
+
} else {
|
|
2894
|
+
addBackend(requestedBackend);
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
for (const backend of backends) {
|
|
2898
|
+
const manager = resolveOps(backend);
|
|
2899
|
+
if (!manager) throw new Error(`Background service manager is unavailable for ${backend}.`);
|
|
2900
|
+
if (manager.status() !== null) manager.stop();
|
|
2901
|
+
}
|
|
2902
|
+
await (deps.stopTrackedProxy ?? stopTrackedProxyIfRunning)();
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
export async function installServiceSafely(
|
|
2906
|
+
requestedBackend: ServiceBackend,
|
|
2907
|
+
install: () => void | Promise<void>,
|
|
2908
|
+
deps: ServiceInstallPreparationDeps = {},
|
|
2909
|
+
): Promise<void> {
|
|
2910
|
+
await prepareServiceInstall(requestedBackend, deps);
|
|
2911
|
+
await install();
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
export interface FreshWindowsSchedulerInstallDeps {
|
|
2915
|
+
stageRegistrationXml?: (attemptNonce: string) => string;
|
|
2916
|
+
register?: (xmlPath: string, attemptNonce: string) => Promise<void>;
|
|
2917
|
+
recordOwnership?: () => boolean;
|
|
2918
|
+
prepare?: () => Promise<void>;
|
|
2919
|
+
removeNativeService?: () => void;
|
|
2920
|
+
publishAssets?: () => void;
|
|
2921
|
+
runTask?: () => void;
|
|
2922
|
+
writeState?: () => void;
|
|
2923
|
+
rollbackTask?: (attemptNonce: string) => Promise<string | null>;
|
|
2924
|
+
removeStagedXml?: (xmlPath: string) => void;
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
/**
|
|
2928
|
+
* Fresh Windows scheduler install with UAC before the destructive commit.
|
|
2929
|
+
*
|
|
2930
|
+
* The registration is created but never run before `prepare`: UAC cancellation and
|
|
2931
|
+
* create failure therefore cannot stop the existing proxy or trigger its native-routing
|
|
2932
|
+
* cleanup. Rollback proves ownership from the live registration's attempt nonce before
|
|
2933
|
+
* deleting, because the fixed task name can be replaced by another process at any time.
|
|
2934
|
+
*/
|
|
2935
|
+
export async function installFreshWindowsSchedulerSafely(
|
|
2936
|
+
deps: FreshWindowsSchedulerInstallDeps = {},
|
|
2937
|
+
): Promise<void> {
|
|
2938
|
+
const stage = deps.stageRegistrationXml ?? stageWindowsSchedulerRegistrationXml;
|
|
2939
|
+
const register = deps.register ?? registerFreshWindowsSchedulerTask;
|
|
2940
|
+
const recordOwnership = deps.recordOwnership ?? recordWindowsSchedulerOwnership;
|
|
2941
|
+
const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler"));
|
|
2942
|
+
const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler;
|
|
2943
|
+
const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets;
|
|
2944
|
+
const runTask = deps.runTask ?? startWindows;
|
|
2945
|
+
const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler"));
|
|
2946
|
+
const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => (
|
|
2947
|
+
rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK)
|
|
2948
|
+
));
|
|
2949
|
+
const removeStagedXml = deps.removeStagedXml ?? ((path: string) => {
|
|
2950
|
+
removeWindowsSchedulerRegistrationStage(path);
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
let stagedXml: string | null = null;
|
|
2954
|
+
const attemptNonce = randomUUID();
|
|
2955
|
+
const configRootWasAbsent = !existsSync(getConfigDir());
|
|
2956
|
+
let registered = false;
|
|
2957
|
+
let started = false;
|
|
2958
|
+
try {
|
|
2959
|
+
stagedXml = stage(attemptNonce);
|
|
2960
|
+
await register(stagedXml, attemptNonce);
|
|
2961
|
+
registered = true;
|
|
2962
|
+
|
|
2963
|
+
// The destructive boundary begins only after Task Scheduler accepted the definition.
|
|
2964
|
+
// The registration has consumed its temporary XML. Remove it before claiming a newly
|
|
2965
|
+
// created config root, because ownership initialization intentionally requires emptiness.
|
|
2966
|
+
removeStagedXml(stagedXml);
|
|
2967
|
+
stagedXml = null;
|
|
2968
|
+
const ownershipRecorded = recordOwnership();
|
|
2969
|
+
if (!ownershipRecorded && configRootWasAbsent) {
|
|
2970
|
+
throw new Error(
|
|
2971
|
+
"The fresh OpenCodex config root could not be claimed for safe uninstall; "
|
|
2972
|
+
+ "aborting before service-manager cleanup or asset publication.",
|
|
2973
|
+
);
|
|
2974
|
+
}
|
|
2975
|
+
await prepare();
|
|
2976
|
+
removeNativeService();
|
|
2977
|
+
publishAssets();
|
|
2978
|
+
runTask();
|
|
2979
|
+
started = true;
|
|
2980
|
+
writeState();
|
|
2981
|
+
} catch (error) {
|
|
2982
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2983
|
+
if (registered && !started) {
|
|
2984
|
+
const rollback = await rollbackTask(attemptNonce);
|
|
2985
|
+
throw new Error(
|
|
2986
|
+
`${detail}\n`
|
|
2987
|
+
+ (rollback
|
|
2988
|
+
? `The new Task Scheduler registration may remain: ${rollback}`
|
|
2989
|
+
: "The new Task Scheduler registration was rolled back. The previous proxy/routing state was not assumed restored."),
|
|
2990
|
+
);
|
|
2991
|
+
}
|
|
2992
|
+
if (started) {
|
|
2993
|
+
throw new Error(
|
|
2994
|
+
`${detail}\nThe scheduler task started, but install state was not published. `
|
|
2995
|
+
+ "The task was left in place; inspect `ocx service status` before retrying.",
|
|
2996
|
+
);
|
|
2997
|
+
}
|
|
2998
|
+
throw error;
|
|
2999
|
+
} finally {
|
|
3000
|
+
if (stagedXml) {
|
|
3001
|
+
try { removeStagedXml(stagedXml); } catch (error) {
|
|
3002
|
+
const code = error && typeof error === "object" && "code" in error
|
|
3003
|
+
? String((error as NodeJS.ErrnoException).code)
|
|
3004
|
+
: "";
|
|
3005
|
+
console.error(
|
|
3006
|
+
`⚠️ Failed to remove the private Task Scheduler staging directory${code ? ` (${code})` : ""}.`,
|
|
3007
|
+
);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
|
|
2110
3013
|
/**
|
|
2111
3014
|
* If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
|
|
2112
3015
|
* Returns true if a service was found and stopped.
|
|
@@ -2128,6 +3031,10 @@ export function stopServiceIfInstalled(): boolean {
|
|
|
2128
3031
|
if (statusWinswRaw() !== "nonexistent") {
|
|
2129
3032
|
try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
|
|
2130
3033
|
}
|
|
3034
|
+
// `schtasks /end` ends the task instance but the cmd `:loop` wrapper survives and
|
|
3035
|
+
// respawns its child seconds later (issue #764), resurrecting the proxy during a
|
|
3036
|
+
// stop or a tray restart. Kill the launcher/wrapper processes outright.
|
|
3037
|
+
killWindowsServiceWrapperProcesses();
|
|
2131
3038
|
if (stopped) return true;
|
|
2132
3039
|
} else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
|
|
2133
3040
|
try { stopSystemd(); return true; } catch { return false; }
|
|
@@ -2142,33 +3049,53 @@ function removeServiceInstallState(): void {
|
|
|
2142
3049
|
}
|
|
2143
3050
|
}
|
|
2144
3051
|
|
|
3052
|
+
type UninstallServiceHooksForTests = {
|
|
3053
|
+
platform: typeof process.platform;
|
|
3054
|
+
assertEnvironment: () => void;
|
|
3055
|
+
probeWindowsTask: () => WindowsSchedulerTaskProbe;
|
|
3056
|
+
uninstallWindowsTask: () => void;
|
|
3057
|
+
nativeStatus: () => WinswStatus;
|
|
3058
|
+
uninstallNative: () => void;
|
|
3059
|
+
removeInstallState: () => void;
|
|
3060
|
+
};
|
|
3061
|
+
|
|
3062
|
+
let uninstallServiceHooksForTests: UninstallServiceHooksForTests | null = null;
|
|
3063
|
+
|
|
3064
|
+
/** Test-only hooks for full-uninstall service removal. */
|
|
3065
|
+
export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksForTests | null): void {
|
|
3066
|
+
uninstallServiceHooksForTests = hooks;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
2145
3069
|
/**
|
|
2146
3070
|
* Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
|
|
2147
|
-
* when no service exists
|
|
2148
|
-
*
|
|
3071
|
+
* when no service exists or the platform has no service manager. An installed native Windows
|
|
3072
|
+
* service or scheduler task that cannot be removed throws so the caller cannot erase state and
|
|
3073
|
+
* report success.
|
|
2149
3074
|
*/
|
|
2150
3075
|
export function uninstallServiceIfInstalled(): boolean {
|
|
2151
|
-
|
|
2152
|
-
|
|
3076
|
+
const hooks = uninstallServiceHooksForTests;
|
|
3077
|
+
(hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
|
|
3078
|
+
const platform = hooks?.platform ?? process.platform;
|
|
3079
|
+
if (platform === "darwin") {
|
|
2153
3080
|
if (existsSync(plistPath())) {
|
|
2154
3081
|
try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
|
|
2155
3082
|
}
|
|
2156
|
-
} else if (
|
|
3083
|
+
} else if (platform === "win32") {
|
|
2157
3084
|
let removed = false;
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
}
|
|
2162
|
-
if (
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
removed = true;
|
|
2166
|
-
} catch (err) {
|
|
2167
|
-
console.warn(`⚠️ Failed to remove native service: ${err instanceof Error ? err.message : String(err)}. Check 'sc.exe query ${WINSW_SERVICE_ID}'.`);
|
|
2168
|
-
}
|
|
3085
|
+
const scheduler = (hooks?.probeWindowsTask ?? probeWindowsSchedulerTask)();
|
|
3086
|
+
if (scheduler.status === "unknown") {
|
|
3087
|
+
throw new Error(`Could not determine Task Scheduler state: ${scheduler.detail}`);
|
|
3088
|
+
}
|
|
3089
|
+
if (scheduler.status === "present") {
|
|
3090
|
+
(hooks?.uninstallWindowsTask ?? uninstallWindows)();
|
|
3091
|
+
removed = true;
|
|
2169
3092
|
}
|
|
2170
|
-
if (
|
|
2171
|
-
|
|
3093
|
+
if ((hooks?.nativeStatus ?? statusWinswRaw)() !== "nonexistent") {
|
|
3094
|
+
(hooks?.uninstallNative ?? uninstallWinswService)();
|
|
3095
|
+
removed = true;
|
|
3096
|
+
}
|
|
3097
|
+
if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return true; }
|
|
3098
|
+
} else if (platform === "linux" && existsSync(unitPath())) {
|
|
2172
3099
|
try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
|
|
2173
3100
|
try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
|
|
2174
3101
|
}
|
|
@@ -2251,7 +3178,7 @@ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticI
|
|
|
2251
3178
|
const detail = conflict
|
|
2252
3179
|
? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one"
|
|
2253
3180
|
: stale
|
|
2254
|
-
? "stale or missing service assets — run 'ocx service
|
|
3181
|
+
? "stale or missing service assets — run 'ocx service repair'"
|
|
2255
3182
|
: schedulerInstalled
|
|
2256
3183
|
? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
|
|
2257
3184
|
: nativeInstalled
|
|
@@ -2373,7 +3300,7 @@ export async function serviceStatusReport(
|
|
|
2373
3300
|
: null);
|
|
2374
3301
|
const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
|
|
2375
3302
|
? " launchd is running an OLDER plist than the one on disk.\n"
|
|
2376
|
-
+ ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service
|
|
3303
|
+
+ ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service repair\n`
|
|
2377
3304
|
: "";
|
|
2378
3305
|
|
|
2379
3306
|
return `⚠️ ${diag.summary}\n`
|
|
@@ -2385,6 +3312,7 @@ export async function serviceStatusReport(
|
|
|
2385
3312
|
}
|
|
2386
3313
|
|
|
2387
3314
|
export function normalizeServiceSubcommand(sub?: string): string {
|
|
3315
|
+
if (sub === "restart") return "repair";
|
|
2388
3316
|
return sub ?? "install";
|
|
2389
3317
|
}
|
|
2390
3318
|
|
|
@@ -2394,6 +3322,119 @@ export interface ParsedServiceArgs {
|
|
|
2394
3322
|
invalid: string[];
|
|
2395
3323
|
}
|
|
2396
3324
|
|
|
3325
|
+
export type ServiceInstallationState = "installed" | "absent" | "unknown";
|
|
3326
|
+
|
|
3327
|
+
export interface ServiceInstallationProbe {
|
|
3328
|
+
state: ServiceInstallationState;
|
|
3329
|
+
detail?: string;
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
export interface ServiceInstallationProbeHooks {
|
|
3333
|
+
platform?: NodeJS.Platform;
|
|
3334
|
+
exists?: (path: string) => boolean;
|
|
3335
|
+
probeWindowsTask?: () => WindowsSchedulerTaskProbe;
|
|
3336
|
+
nativeStatus?: () => WinswStatus;
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
/**
|
|
3340
|
+
* Read only enough registration state to choose between install and repair.
|
|
3341
|
+
* Windows must keep query failure distinct from proven absence: treating an
|
|
3342
|
+
* unreadable scheduler/SCM as absent would send a bare command into the
|
|
3343
|
+
* elevated registration path and recreate the original #2287 failure.
|
|
3344
|
+
*/
|
|
3345
|
+
export function probeServiceInstallation(
|
|
3346
|
+
hooks: ServiceInstallationProbeHooks = {},
|
|
3347
|
+
): ServiceInstallationProbe {
|
|
3348
|
+
const platform = hooks.platform ?? process.platform;
|
|
3349
|
+
const exists = hooks.exists ?? existsSync;
|
|
3350
|
+
if (platform === "darwin") {
|
|
3351
|
+
return { state: exists(plistPath()) ? "installed" : "absent" };
|
|
3352
|
+
}
|
|
3353
|
+
if (platform === "linux") {
|
|
3354
|
+
return { state: exists(unitPath()) ? "installed" : "absent" };
|
|
3355
|
+
}
|
|
3356
|
+
if (platform !== "win32") return { state: "absent" };
|
|
3357
|
+
|
|
3358
|
+
let scheduler: WindowsSchedulerTaskProbe;
|
|
3359
|
+
try {
|
|
3360
|
+
scheduler = (hooks.probeWindowsTask ?? probeWindowsSchedulerTask)();
|
|
3361
|
+
} catch (cause) {
|
|
3362
|
+
scheduler = { status: "unknown", detail: schtasksErrorDetail(cause) };
|
|
3363
|
+
}
|
|
3364
|
+
let native: WinswStatus;
|
|
3365
|
+
try {
|
|
3366
|
+
native = (hooks.nativeStatus ?? statusWinswRaw)();
|
|
3367
|
+
} catch {
|
|
3368
|
+
native = "unknown";
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
if (scheduler.status === "present" || native === "started" || native === "stopped") {
|
|
3372
|
+
return { state: "installed" };
|
|
3373
|
+
}
|
|
3374
|
+
if (scheduler.status === "unknown" || native === "unknown") {
|
|
3375
|
+
const parts = [
|
|
3376
|
+
scheduler.status === "unknown" ? `Task Scheduler: ${scheduler.detail}` : null,
|
|
3377
|
+
native === "unknown" ? "WinSW status could not be determined" : null,
|
|
3378
|
+
].filter((part): part is string => Boolean(part));
|
|
3379
|
+
return { state: "unknown", detail: parts.join("; ") };
|
|
3380
|
+
}
|
|
3381
|
+
return { state: "absent" };
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
/**
|
|
3385
|
+
* A bare invocation is an idempotent "make the installed service current"
|
|
3386
|
+
* operation. First-time setup still installs, but an existing registration must
|
|
3387
|
+
* use the repair path so Windows does not re-run the elevated `schtasks /create`.
|
|
3388
|
+
* Backend flags remain an explicit install request because they select which
|
|
3389
|
+
* registration mechanism to create.
|
|
3390
|
+
*/
|
|
3391
|
+
export function selectServiceSubcommand(
|
|
3392
|
+
parsed: ParsedServiceArgs,
|
|
3393
|
+
options: { hasExplicitSubcommand: boolean; installed: boolean },
|
|
3394
|
+
): string {
|
|
3395
|
+
if (!options.hasExplicitSubcommand && parsed.backend === null && options.installed) return "repair";
|
|
3396
|
+
return parsed.sub;
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
export type ServiceCommandPlan =
|
|
3400
|
+
| { ok: true; parsed: ParsedServiceArgs; command: string }
|
|
3401
|
+
| { ok: false; message: string };
|
|
3402
|
+
|
|
3403
|
+
export function planServiceCommand(
|
|
3404
|
+
args: string[],
|
|
3405
|
+
options: { platform?: NodeJS.Platform; probeInstallation?: () => ServiceInstallationProbe } = {},
|
|
3406
|
+
): ServiceCommandPlan {
|
|
3407
|
+
const parsed = parseServiceArgs(args);
|
|
3408
|
+
if (parsed.invalid.length > 0) {
|
|
3409
|
+
return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` };
|
|
3410
|
+
}
|
|
3411
|
+
if (parsed.backend && parsed.sub !== "install") {
|
|
3412
|
+
return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." };
|
|
3413
|
+
}
|
|
3414
|
+
if (parsed.backend === "native" && (options.platform ?? process.platform) !== "win32") {
|
|
3415
|
+
return { ok: false, message: "--native (WinSW) is Windows-only." };
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
const hasExplicitSubcommand = args.some(arg => !arg.startsWith("--"));
|
|
3419
|
+
let installed = false;
|
|
3420
|
+
if (!hasExplicitSubcommand && parsed.backend === null) {
|
|
3421
|
+
const probe = (options.probeInstallation ?? probeServiceInstallation)();
|
|
3422
|
+
if (probe.state === "unknown") {
|
|
3423
|
+
const suffix = probe.detail ? ` (${probe.detail})` : "";
|
|
3424
|
+
return {
|
|
3425
|
+
ok: false,
|
|
3426
|
+
message: `Could not safely determine whether the service is installed${suffix}. Run 'ocx service status' and retry; use explicit 'ocx service install' only after confirming it is absent.`,
|
|
3427
|
+
};
|
|
3428
|
+
}
|
|
3429
|
+
installed = probe.state === "installed";
|
|
3430
|
+
}
|
|
3431
|
+
return {
|
|
3432
|
+
ok: true,
|
|
3433
|
+
parsed,
|
|
3434
|
+
command: selectServiceSubcommand(parsed, { hasExplicitSubcommand, installed }),
|
|
3435
|
+
};
|
|
3436
|
+
}
|
|
3437
|
+
|
|
2397
3438
|
/**
|
|
2398
3439
|
* `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the
|
|
2399
3440
|
* subcommand; backend flags are only meaningful for `install` (validated by the caller).
|
|
@@ -2419,20 +3460,13 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
|
|
|
2419
3460
|
}
|
|
2420
3461
|
|
|
2421
3462
|
export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
|
|
2422
|
-
const
|
|
2423
|
-
const
|
|
2424
|
-
if (
|
|
2425
|
-
console.error(
|
|
2426
|
-
process.exit(1);
|
|
2427
|
-
}
|
|
2428
|
-
if (parsed.backend && command !== "install") {
|
|
2429
|
-
console.error("--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend.");
|
|
2430
|
-
process.exit(1);
|
|
2431
|
-
}
|
|
2432
|
-
if (parsed.backend === "native" && process.platform !== "win32") {
|
|
2433
|
-
console.error("--native (WinSW) is Windows-only.");
|
|
3463
|
+
const filteredArgs = args.filter((a): a is string => Boolean(a));
|
|
3464
|
+
const plan = planServiceCommand(filteredArgs);
|
|
3465
|
+
if (!plan.ok) {
|
|
3466
|
+
console.error(plan.message);
|
|
2434
3467
|
process.exit(1);
|
|
2435
3468
|
}
|
|
3469
|
+
const { parsed, command } = plan;
|
|
2436
3470
|
if (command === "repair") {
|
|
2437
3471
|
assertServiceEnvironmentMatchesInstall();
|
|
2438
3472
|
assertServiceAuthEnvironment();
|
|
@@ -2455,7 +3489,31 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
2455
3489
|
case "install":
|
|
2456
3490
|
assertServiceEnvironmentMatchesInstall();
|
|
2457
3491
|
assertServiceAuthEnvironment();
|
|
2458
|
-
|
|
3492
|
+
// A manually started proxy can still own the configured port while the service
|
|
3493
|
+
// registration is absent or unloaded. Stop both the registered manager and any
|
|
3494
|
+
// tracked standalone listener before loading the freshly written service assets.
|
|
3495
|
+
// Otherwise launchd/Task Scheduler can register successfully while its child
|
|
3496
|
+
// restart-loops on EADDRINUSE, and the old standalone process makes the install
|
|
3497
|
+
// verification report a false success.
|
|
3498
|
+
try {
|
|
3499
|
+
if (process.platform === "win32" && backend === "scheduler") {
|
|
3500
|
+
const scheduler = probeWindowsSchedulerTask(TASK);
|
|
3501
|
+
if (scheduler.status === "unknown") {
|
|
3502
|
+
throw new Error(`Task Scheduler state could not be verified before install: ${scheduler.detail}`);
|
|
3503
|
+
}
|
|
3504
|
+
if (scheduler.status === "absent") {
|
|
3505
|
+
await installFreshWindowsSchedulerSafely();
|
|
3506
|
+
} else {
|
|
3507
|
+
await installServiceSafely(backend, ops.install);
|
|
3508
|
+
}
|
|
3509
|
+
} else {
|
|
3510
|
+
await installServiceSafely(backend, ops.install);
|
|
3511
|
+
}
|
|
3512
|
+
} catch (error) {
|
|
3513
|
+
console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3514
|
+
process.exitCode = 1;
|
|
3515
|
+
break;
|
|
3516
|
+
}
|
|
2459
3517
|
// The wrapper was written moments ago in this process, so the configured port
|
|
2460
3518
|
// and the baked one cannot have diverged yet — unlike `start`, which reads the
|
|
2461
3519
|
// installed artifact instead.
|
|
@@ -2494,7 +3552,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
2494
3552
|
process.exitCode = 1;
|
|
2495
3553
|
break;
|
|
2496
3554
|
}
|
|
2497
|
-
const restore =
|
|
3555
|
+
const restore = await restoreNativeCodexAsync();
|
|
2498
3556
|
if (restore.success) console.log("✅ service stopped + native Codex restored.");
|
|
2499
3557
|
else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
|
|
2500
3558
|
// The Grok fence is the other managed config this command owns. Leaving it behind
|
|
@@ -2532,7 +3590,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
2532
3590
|
process.exit(1);
|
|
2533
3591
|
}
|
|
2534
3592
|
{
|
|
2535
|
-
const restore =
|
|
3593
|
+
const restore = await restoreNativeCodexAsync();
|
|
2536
3594
|
if (!restore.success) {
|
|
2537
3595
|
console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
|
|
2538
3596
|
}
|
|
@@ -2545,9 +3603,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
2545
3603
|
console.log("✅ service uninstalled.");
|
|
2546
3604
|
break;
|
|
2547
3605
|
default:
|
|
2548
|
-
console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]");
|
|
2549
|
-
console.error(" With no subcommand, installs
|
|
3606
|
+
console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
|
|
3607
|
+
console.error(" With no subcommand, installs when absent or repairs/restarts an existing service.");
|
|
2550
3608
|
console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
|
|
3609
|
+
console.error(" restart: alias of repair.");
|
|
2551
3610
|
console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
|
|
2552
3611
|
process.exit(1);
|
|
2553
3612
|
}
|