@iislee/opencodex 2.11.0 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +109 -0
- package/README.md +114 -19
- package/bin/ocx.mjs +164 -36
- package/bin/package-main.mjs +1 -1
- package/gui/dist/assets/index-BF38heuV.js +104 -0
- package/gui/dist/assets/index-DMiI18Kv.css +1 -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 +19 -10
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +376 -52
- package/src/adapters/base.ts +54 -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 +601 -0
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/cursor-errors.ts +129 -6
- package/src/adapters/cursor/discovery.ts +61 -4
- package/src/adapters/cursor/effort-map.ts +27 -3
- 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 +174 -58
- package/src/adapters/cursor/live-transport.ts +609 -170
- package/src/adapters/cursor/native-exec-common.ts +23 -2
- 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 +0 -1
- package/src/adapters/cursor/native-exec.ts +101 -14
- package/src/adapters/cursor/protobuf-events.ts +829 -11
- package/src/adapters/cursor/protobuf-request.ts +383 -65
- package/src/adapters/cursor/request-builder.ts +237 -31
- package/src/adapters/cursor/tool-definitions.ts +242 -12
- package/src/adapters/cursor/tool-result-normalize.ts +92 -0
- package/src/adapters/cursor/transport.ts +22 -0
- package/src/adapters/cursor/types.ts +28 -1
- package/src/adapters/cursor.ts +133 -7
- package/src/adapters/google-antigravity-replay.ts +558 -34
- package/src/adapters/google-antigravity-wire.ts +43 -10
- 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 +618 -74
- package/src/adapters/identity.ts +39 -6
- 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 +10 -1
- package/src/adapters/kiro.ts +92 -54
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1311 -336
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +830 -56
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +104 -21
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/bridge.ts +428 -61
- package/src/chat/inbound.ts +43 -19
- package/src/chat/outbound.ts +82 -26
- package/src/claude/agents-inject.ts +32 -9
- package/src/claude/context-windows.ts +21 -5
- 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 +70 -16
- package/src/cli/account-api.ts +35 -1
- package/src/cli/account-auth.ts +33 -6
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +389 -2
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +30 -6
- package/src/cli/agent.ts +45 -1
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +29 -4
- package/src/cli/claude.ts +103 -34
- 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 +593 -0
- package/src/cli/doctor.ts +315 -43
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +46 -20
- package/src/cli/help.ts +20 -271
- package/src/cli/index.ts +378 -519
- package/src/cli/init.ts +4 -17
- 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 +35 -2
- package/src/cli/models.ts +100 -14
- package/src/cli/observe.ts +92 -3
- package/src/cli/opencode.ts +4 -2
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/provider.ts +24 -3
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +437 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +10 -3
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/cli/v2.ts +105 -10
- package/src/cli.ts +1 -1
- package/src/clients/config-export.ts +1358 -21
- package/src/codex/account-label.ts +14 -1
- package/src/codex/account-lifecycle.ts +130 -13
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -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 +493 -106
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +849 -242
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/auth-context.ts +345 -32
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +68 -10
- package/src/codex/catalog/bundled.ts +331 -33
- package/src/codex/catalog/effort.ts +121 -30
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +529 -45
- package/src/codex/catalog/native-models.ts +72 -0
- package/src/codex/catalog/parsing.ts +224 -30
- package/src/codex/catalog/provider-fetch.ts +1460 -134
- package/src/codex/catalog/sync.ts +1449 -186
- 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 +384 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +651 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/features.ts +636 -39
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +407 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +26 -20
- package/src/codex/history-provider.ts +231 -28
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +220 -0
- package/src/codex/inject-coordination.ts +290 -0
- package/src/codex/inject.ts +1073 -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 +80 -0
- package/src/codex/journal.ts +66 -4
- package/src/codex/log-guard/inspect.ts +506 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +88 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/main-account-cache.ts +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 +353 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +178 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +492 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +682 -0
- package/src/codex/paths.ts +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 +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +175 -13
- package/src/codex/refresh.ts +11 -2
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +505 -94
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +1009 -28
- package/src/codex/subagent-model-fallback.ts +350 -35
- package/src/codex/sync.ts +191 -2
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +187 -81
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +20 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/request.ts +32 -0
- package/src/combos/types.ts +81 -9
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +1762 -140
- package/src/generated/compatibility-version.json +3164 -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 +152 -29
- package/src/images/plan.ts +23 -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.ts +111 -0
- package/src/integrations/registry.ts +159 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +361 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +691 -0
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +499 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +64 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +164 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +68 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +214 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +229 -0
- package/src/lab/events/validate.ts +781 -0
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +489 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +54 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +310 -0
- package/src/lab/ledger/store.ts +532 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +336 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +118 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +301 -0
- package/src/lab/observe/from-live.ts +117 -0
- package/src/lab/paths.ts +153 -0
- package/src/lab/projection/rebuild.ts +495 -0
- package/src/lab/projection/schema.ts +135 -0
- package/src/lab/projection/verdicts.ts +474 -0
- package/src/lab/projection/verification.ts +412 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +243 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lab/query/catalog.ts +101 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +45 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +112 -0
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +74 -0
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/bounded-body.ts +153 -9
- 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 +6 -2
- package/src/lib/destination-policy.ts +65 -1
- package/src/lib/errors.ts +44 -2
- 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/pinned-http.ts +145 -26
- package/src/lib/process-control.ts +4 -1
- 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/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 +202 -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 +8 -2
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +46 -5
- package/src/oauth/google-antigravity.ts +35 -3
- package/src/oauth/health.ts +20 -12
- package/src/oauth/index.ts +398 -66
- package/src/oauth/key-providers.ts +16 -0
- package/src/oauth/kimi.ts +16 -2
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/local-token-detect.ts +11 -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/store.ts +119 -21
- package/src/oauth/token-guardian.ts +9 -3
- 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/base-url-choices.ts +10 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +144 -0
- package/src/providers/context-cap.ts +22 -5
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +253 -6
- package/src/providers/fastwire.ts +501 -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 +1 -1
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +115 -22
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -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 +29 -0
- package/src/providers/quota.ts +1319 -38
- package/src/providers/registry.ts +1429 -111
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +42 -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 +11 -4
- package/src/reasoning-effort.ts +49 -1
- package/src/responses/compaction.ts +26 -1
- package/src/responses/custom-tool-compat.ts +266 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +355 -0
- package/src/responses/parser.ts +220 -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 +426 -0
- package/src/responses/schema.ts +7 -1
- package/src/responses/spill-store.ts +75 -10
- package/src/responses/state.ts +565 -27
- package/src/responses/thought-signature-replay.ts +347 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/router.ts +366 -30
- package/src/routing/analytics.ts +378 -0
- package/src/routing/capability.ts +244 -0
- package/src/routing/compatibility/assemble.ts +73 -0
- package/src/routing/compatibility/behavior.ts +278 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +495 -0
- package/src/routing/health.ts +412 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +547 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +2 -29
- package/src/server/auth-cors.ts +267 -46
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +130 -56
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +426 -0
- package/src/server/claude-messages.ts +159 -43
- 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 +865 -181
- package/src/server/lifecycle.ts +284 -13
- 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 +398 -103
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/body.ts +6 -0
- package/src/server/management/combo-routes.ts +62 -24
- package/src/server/management/config-routes.ts +464 -51
- package/src/server/management/context.ts +80 -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 +101 -32
- package/src/server/management/model-routes.ts +189 -131
- package/src/server/management/model-rows.ts +163 -0
- package/src/server/management/native-integration-routes.ts +769 -0
- package/src/server/management/oauth-account-routes.ts +80 -4
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +764 -157
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +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 +288 -32
- 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 +115 -14
- package/src/server/management-auth.ts +220 -5
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +132 -5
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +236 -76
- package/src/server/request-decompress.ts +113 -6
- package/src/server/request-log.ts +235 -22
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +465 -0
- package/src/server/responses/collaboration.ts +204 -35
- package/src/server/responses/compact.ts +442 -55
- package/src/server/responses/core.ts +2872 -331
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/encrypted-payload.ts +62 -39
- package/src/server/responses/fetch-helpers.ts +79 -4
- package/src/server/responses/input-admission.ts +185 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +251 -0
- package/src/server/responses/terminal-guard.ts +26 -5
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses/ws-upstream.ts +308 -0
- package/src/server/responses-custom-tool-repair.ts +282 -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 +342 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/responses.ts +18 -2
- package/src/server/search.ts +78 -13
- package/src/server/sse-frame-buffer.ts +292 -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 +39 -38
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +1111 -90
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/storage/cleanup.ts +2 -2
- 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 +845 -0
- package/src/types/provider.ts +545 -0
- package/src/types/request.ts +384 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +104 -1236
- package/src/update/index.ts +32 -19
- package/src/update/job.ts +442 -67
- 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 +259 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +268 -16
- package/src/usage/log.ts +606 -41
- package/src/usage/summary.ts +177 -9
- 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 +9 -3
- 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 +88 -0
- package/src/web-search/executor.ts +11 -3
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +150 -15
- package/src/web-search/loop.ts +279 -50
- 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,46 @@
|
|
|
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";
|
|
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
13
|
import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config";
|
|
14
14
|
import { loadConfig } from "./config";
|
|
15
|
-
import { restoreNativeCodex } from "./codex/inject";
|
|
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 { PROXY_ENV_KEYS } from "./lib/proxy-env";
|
|
21
23
|
import { randomUUID } from "node:crypto";
|
|
22
24
|
import {
|
|
23
25
|
ELEVATION_REQUEST_TIMEOUT_MS,
|
|
24
26
|
OCX_ELEVATED_PROTOCOL_FAILED,
|
|
25
27
|
raceWithTimeout,
|
|
28
|
+
resolveTrustedWindowsPowerShellExe,
|
|
26
29
|
resolveTrustedWindowsSchtasksExe,
|
|
27
30
|
startElevatedSchtasksCreateAndRun,
|
|
28
31
|
runWindowsElevated,
|
|
32
|
+
runWindowsElevatedScheduledTaskRegistration,
|
|
29
33
|
toWindowsSchtasksError,
|
|
30
34
|
WindowsElevationError,
|
|
35
|
+
WindowsSchtasksError,
|
|
31
36
|
type ElevatedSchedulerOutcome,
|
|
32
37
|
type ElevatedSchtasksCreateAndRunExecution,
|
|
33
38
|
type ElevatedSchtasksCreateAndRunResult,
|
|
34
39
|
} 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 {
|
|
40
|
+
import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION, type WinswStatus } from "./lib/winsw";
|
|
41
|
+
import {
|
|
42
|
+
forgetEphemeralSecretDir,
|
|
43
|
+
forgetEphemeralSecretPath,
|
|
44
|
+
hardenSecretDir,
|
|
45
|
+
hardenSecretPath,
|
|
46
|
+
} from "./lib/windows-secret-acl";
|
|
37
47
|
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
|
|
38
48
|
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
49
|
+
import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
|
|
39
50
|
import { maybeShowStarPrompt } from "./cli/star-prompt";
|
|
40
51
|
|
|
41
52
|
const LABEL = "com.opencodex.proxy";
|
|
@@ -43,11 +54,15 @@ const TASK = "opencodex-proxy";
|
|
|
43
54
|
|
|
44
55
|
export type ServiceBackend = "scheduler" | "native";
|
|
45
56
|
|
|
46
|
-
function cliEntry(): { bun: string; cli: string } {
|
|
57
|
+
function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
|
|
47
58
|
// Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
|
|
48
59
|
// a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
|
|
49
60
|
// standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
|
|
50
|
-
|
|
61
|
+
//
|
|
62
|
+
// Path and provenance come from ONE resolution so the marker can never describe a
|
|
63
|
+
// different binary than the one actually baked.
|
|
64
|
+
const runtime = durableBunRuntime();
|
|
65
|
+
return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "cli", "index.ts") };
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
function plistPath(): string {
|
|
@@ -89,9 +104,30 @@ function serviceStatePaths(): string[] {
|
|
|
89
104
|
return paths;
|
|
90
105
|
}
|
|
91
106
|
|
|
92
|
-
function currentCodexHome(): string {
|
|
93
|
-
|
|
94
|
-
|
|
107
|
+
function currentCodexHome(deps: CodexHomeDeps = {}): string {
|
|
108
|
+
// Service ownership must identify the same home as the runtime. In WSL an
|
|
109
|
+
// unset CODEX_HOME can resolve to the single Windows Desktop home rather than
|
|
110
|
+
// Linux ~/.codex; recording the fallback here creates a false foreign owner.
|
|
111
|
+
return resolveCodexHomeDir(deps);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function currentCodexSqliteHomeAbsolute(target: "native" | "windows" = "native"): string | undefined {
|
|
115
|
+
const raw = process.env.CODEX_SQLITE_HOME?.trim();
|
|
116
|
+
if (!raw) return undefined;
|
|
117
|
+
const expanded = expandUserPath(raw);
|
|
118
|
+
// Service artifacts can be rendered by cross-platform tests and repair tooling, so an
|
|
119
|
+
// already-absolute path for the TARGET platform is preserved rather than re-anchored
|
|
120
|
+
// against the writing host. `resolve()` is host-relative in both directions: on a POSIX
|
|
121
|
+
// host it turns `C:\data` into `<cwd>/C:\data`, and on a Windows host it turns `/tmp/x`
|
|
122
|
+
// into `D:\tmp\x` — neither is a path the target can use. A relative value still resolves,
|
|
123
|
+
// because a service unit has no meaningful working directory.
|
|
124
|
+
//
|
|
125
|
+
// CODEX_HOME and OPENCODEX_HOME are carried through literally, so without this the same
|
|
126
|
+
// generated file disagreed with itself about two variables holding the same kind of value.
|
|
127
|
+
if (target === "windows") {
|
|
128
|
+
return win32.isAbsolute(expanded) ? win32.normalize(expanded) : resolve(expanded);
|
|
129
|
+
}
|
|
130
|
+
return posix.isAbsolute(expanded) ? posix.normalize(expanded) : resolve(expanded);
|
|
95
131
|
}
|
|
96
132
|
|
|
97
133
|
function currentOpenCodexHome(): string {
|
|
@@ -169,13 +205,80 @@ function readServiceInstallState(): ServiceInstallState | null {
|
|
|
169
205
|
return null;
|
|
170
206
|
}
|
|
171
207
|
|
|
172
|
-
/**
|
|
208
|
+
/** What ONE state path said. Absent, unreadable and invalid are different answers. */
|
|
209
|
+
export type ServiceStateEvidence =
|
|
210
|
+
| { readonly path: string; readonly kind: "absent" }
|
|
211
|
+
| { readonly path: string; readonly kind: "unreadable"; readonly reason: string }
|
|
212
|
+
| { readonly path: string; readonly kind: "invalid" }
|
|
213
|
+
| { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallState };
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Every state path, with what each one said.
|
|
217
|
+
*
|
|
218
|
+
* `readServiceInstallState` returns the FIRST path that parsed and discards the
|
|
219
|
+
* rest, so a valid mirror beside a corrupt one reads as clean. That is the right
|
|
220
|
+
* behavior for callers that just need the install state; it is the wrong input
|
|
221
|
+
* for deciding ownership, where a disagreement between mirrors is exactly the
|
|
222
|
+
* evidence that matters.
|
|
223
|
+
*/
|
|
224
|
+
export function inspectServiceStateEvidence(
|
|
225
|
+
paths: readonly string[] = serviceStatePaths(),
|
|
226
|
+
): readonly ServiceStateEvidence[] {
|
|
227
|
+
return paths.map((path): ServiceStateEvidence => {
|
|
228
|
+
let raw: string;
|
|
229
|
+
try {
|
|
230
|
+
raw = readFileSync(path, "utf8");
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const code = error && typeof error === "object" && "code" in error
|
|
233
|
+
? String((error as { code?: unknown }).code)
|
|
234
|
+
: "";
|
|
235
|
+
// ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask,
|
|
236
|
+
// and collapsing them into absence is how a locked-down state file would
|
|
237
|
+
// become permission to write.
|
|
238
|
+
if (code === "ENOENT") return { path, kind: "absent" };
|
|
239
|
+
return { path, kind: "unreadable", reason: code || String(error) };
|
|
240
|
+
}
|
|
241
|
+
let parsed: ServiceInstallState | null;
|
|
242
|
+
try {
|
|
243
|
+
parsed = parseServiceInstallState(JSON.parse(raw));
|
|
244
|
+
} catch {
|
|
245
|
+
return { path, kind: "invalid" };
|
|
246
|
+
}
|
|
247
|
+
return parsed ? { path, kind: "valid", state: parsed } : { path, kind: "invalid" };
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The homes this process is actually using, for comparison against a claim. */
|
|
252
|
+
export function currentServiceHomes(deps: CodexHomeDeps = {}): { codexHome: string; opencodexHome: string } {
|
|
253
|
+
return { codexHome: currentCodexHome(deps), opencodexHome: currentOpenCodexHome() };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function serviceHomeMatches(a: string, b: string): boolean {
|
|
257
|
+
return normalizePathForCompare(a) === normalizePathForCompare(b);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Single accessor for backend-sensitive service code — v1/legacy state maps to scheduler. */
|
|
173
261
|
export function readServiceBackend(): ServiceBackend {
|
|
174
262
|
return readServiceInstallState()?.backend === "native" ? "native" : "scheduler";
|
|
175
263
|
}
|
|
176
264
|
|
|
177
|
-
/**
|
|
265
|
+
/**
|
|
266
|
+
* The `ocx` argv that refreshes an already-installed service after an update.
|
|
267
|
+
*
|
|
268
|
+
* `repair` discovers the installed backend itself and, on Windows scheduler installs,
|
|
269
|
+
* rewrites the wrapper assets and restarts the existing task WITHOUT `schtasks /create`
|
|
270
|
+
* (see repairService below). `install` always reaches `/create`, which requires
|
|
271
|
+
* elevation — so an ordinary non-elevated `ocx update` used to stop a working proxy and
|
|
272
|
+
* then fail to bring its service back.
|
|
273
|
+
*
|
|
274
|
+
* The historical export name is kept for callers outside this module.
|
|
275
|
+
*/
|
|
178
276
|
export function serviceReinstallArgs(): string[] {
|
|
277
|
+
return ["service", "repair"];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The `ocx` argv that registers a service from scratch, preserving the chosen backend. */
|
|
281
|
+
export function serviceInstallArgs(): string[] {
|
|
179
282
|
return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
|
|
180
283
|
}
|
|
181
284
|
|
|
@@ -211,11 +314,12 @@ export function serviceEnvironmentOwnedHere(): boolean {
|
|
|
211
314
|
export function assertServiceEnvironmentMatchesInstall(): void {
|
|
212
315
|
const state = readServiceInstallState();
|
|
213
316
|
if (!state) return;
|
|
317
|
+
const actualCodexHome = currentCodexHome();
|
|
214
318
|
const expected = normalizePathForCompare(state.codexHome);
|
|
215
|
-
const actual = normalizePathForCompare(
|
|
319
|
+
const actual = normalizePathForCompare(actualCodexHome);
|
|
216
320
|
if (expected !== actual) {
|
|
217
321
|
throw new ServiceOwnershipError(
|
|
218
|
-
`Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${
|
|
322
|
+
`Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` +
|
|
219
323
|
"Run the service command from the same Codex home so native Codex restore updates the correct config.",
|
|
220
324
|
);
|
|
221
325
|
}
|
|
@@ -229,6 +333,7 @@ export function assertServiceEnvironmentMatchesInstall(): void {
|
|
|
229
333
|
}
|
|
230
334
|
}
|
|
231
335
|
|
|
336
|
+
|
|
232
337
|
function plistString(value: string): string {
|
|
233
338
|
return value
|
|
234
339
|
.replace(/&/g, "&")
|
|
@@ -243,13 +348,31 @@ function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
|
243
348
|
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
|
244
349
|
}
|
|
245
350
|
|
|
351
|
+
/**
|
|
352
|
+
* The `ocx` command a user should rerun for the service state they actually have.
|
|
353
|
+
*
|
|
354
|
+
* `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW
|
|
355
|
+
* conflict outright, so recommending repair there names a command guaranteed to fail.
|
|
356
|
+
* Install IS the valid conflict recovery, because `installWindows` removes the native
|
|
357
|
+
* backend first. Exported so the guard tests the real selector rather than a copy of it.
|
|
358
|
+
*/
|
|
359
|
+
export function serviceRetryCommand(
|
|
360
|
+
diag: Pick<ServiceDiagnostic, "installed" | "conflict"> = diagnoseService(),
|
|
361
|
+
): string {
|
|
362
|
+
return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install";
|
|
363
|
+
}
|
|
364
|
+
|
|
246
365
|
export function assertServiceAuthEnvironment(): void {
|
|
247
366
|
const config = loadConfig();
|
|
248
367
|
if (isLoopbackHostname(config.hostname)) return;
|
|
249
368
|
if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
|
|
369
|
+
// Reached from `service repair` as well as `install`, so name a command that can
|
|
370
|
+
// actually succeed (see serviceRetryCommand).
|
|
371
|
+
const diag = diagnoseService();
|
|
372
|
+
const retry = serviceRetryCommand(diag);
|
|
250
373
|
throw new Error(
|
|
251
|
-
|
|
252
|
-
|
|
374
|
+
`OPENCODEX_API_AUTH_TOKEN is required before ${diag.installed ? "refreshing" : "installing"} a service `
|
|
375
|
+
+ `for non-loopback hostname. Set it in the same shell, then rerun \`${retry}\`.`,
|
|
253
376
|
);
|
|
254
377
|
}
|
|
255
378
|
|
|
@@ -267,17 +390,23 @@ function writeServiceApiTokenFile(): string | null {
|
|
|
267
390
|
return path;
|
|
268
391
|
}
|
|
269
392
|
|
|
270
|
-
export function buildPlist(): string {
|
|
271
|
-
const { bun, cli } = cliEntry();
|
|
393
|
+
export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
|
|
394
|
+
const { bun, bunRuntimeSource, cli } = cliEntry();
|
|
272
395
|
const log = logPath();
|
|
273
396
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
274
397
|
const codexHome = process.env.CODEX_HOME?.trim();
|
|
398
|
+
const codexSqliteHome = currentCodexSqliteHomeAbsolute();
|
|
275
399
|
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
|
|
276
400
|
const envLines = [
|
|
277
401
|
` <key>OCX_SERVICE</key><string>1</string>`,
|
|
402
|
+
` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
|
|
403
|
+
` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
|
|
278
404
|
` <key>PATH</key><string>${plistString(path)}</string>`,
|
|
279
405
|
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
|
|
406
|
+
codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
|
|
280
407
|
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
|
|
408
|
+
...proxyEnv.map(({ name, value }) =>
|
|
409
|
+
` <key>${name}</key><string>${plistString(value)}</string>`),
|
|
281
410
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
282
411
|
const command = buildServiceShellCommand(bun, cli);
|
|
283
412
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -491,17 +620,14 @@ async function reportServiceServing(
|
|
|
491
620
|
}
|
|
492
621
|
|
|
493
622
|
/**
|
|
494
|
-
* The
|
|
623
|
+
* The command that repairs the CURRENTLY INSTALLED backend without re-registering it.
|
|
495
624
|
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
* backend, so the hint has to carry `--native` when that is what is installed.
|
|
625
|
+
* `ocx service repair` reads the recorded backend itself, so it cannot silently switch a
|
|
626
|
+
* WinSW install to Task Scheduler the way a plain `ocx service install` would, and on
|
|
627
|
+
* Windows it needs no elevation because it never calls `schtasks /create`.
|
|
500
628
|
*/
|
|
501
629
|
function serviceRepairCommand(): string {
|
|
502
|
-
return
|
|
503
|
-
? "ocx service install --native"
|
|
504
|
-
: "ocx service install";
|
|
630
|
+
return "ocx service repair";
|
|
505
631
|
}
|
|
506
632
|
|
|
507
633
|
function systemdQuote(value: string): string {
|
|
@@ -517,6 +643,31 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined):
|
|
|
517
643
|
return `Environment=${systemdQuote(`${name}=${value}`)}`;
|
|
518
644
|
}
|
|
519
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Outbound proxy settings the installing shell had, resolved for baking into a service
|
|
648
|
+
* definition.
|
|
649
|
+
*
|
|
650
|
+
* A service manager does not inherit the environment of the shell that installed it, and
|
|
651
|
+
* `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not
|
|
652
|
+
* `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to
|
|
653
|
+
* reach the upstream got a service that dialed direct: the socket was reset, the retry
|
|
654
|
+
* budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The
|
|
655
|
+
* same install driven through `ocx codex-shim` worked, because that path spawns with
|
|
656
|
+
* `{ ...process.env }`.
|
|
657
|
+
*
|
|
658
|
+
* Lower-case variants are honored because curl-style tooling sets them and the runtime's
|
|
659
|
+
* own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
|
|
660
|
+
* upper-case name is baked, so a definition never carries two spellings of one setting.
|
|
661
|
+
*/
|
|
662
|
+
export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
|
|
663
|
+
const resolved: { name: string; value: string }[] = [];
|
|
664
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
665
|
+
const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
|
|
666
|
+
if (value) resolved.push({ name: key, value });
|
|
667
|
+
}
|
|
668
|
+
return resolved;
|
|
669
|
+
}
|
|
670
|
+
|
|
520
671
|
function systemdOutputTarget(value: string): string {
|
|
521
672
|
// StandardOutput/StandardError use output specifiers such as append:/path.
|
|
522
673
|
// Quoting the full specifier makes systemd reject it as an invalid output target.
|
|
@@ -544,16 +695,28 @@ function sh(cmd: string): string {
|
|
|
544
695
|
export function runLaunchctl(
|
|
545
696
|
args: string[],
|
|
546
697
|
deps: { run?: typeof spawnSync } = {},
|
|
547
|
-
): { ok: boolean; stdout: string; stderr: string } {
|
|
698
|
+
): { ok: boolean; stdout: string; stderr: string; status: number | null } {
|
|
548
699
|
const run = deps.run ?? spawnSync;
|
|
549
700
|
const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
|
|
550
701
|
// `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
|
|
551
702
|
// null for a signalled child; neither may be reported as success.
|
|
552
|
-
if (result.error)
|
|
703
|
+
if (result.error) {
|
|
704
|
+
return { ok: false, stdout: "", stderr: String(result.error.message ?? ""), status: null };
|
|
705
|
+
}
|
|
553
706
|
return {
|
|
554
707
|
ok: result.status === 0,
|
|
555
708
|
stdout: String(result.stdout ?? "").trim(),
|
|
556
709
|
stderr: String(result.stderr ?? "").trim(),
|
|
710
|
+
/*
|
|
711
|
+
* The NUMBER, not just its zero-ness.
|
|
712
|
+
*
|
|
713
|
+
* `launchctl print` distinguishes "that domain does not exist" (112) from
|
|
714
|
+
* "the domain answered and has no such service" (113), and an ownership
|
|
715
|
+
* probe needs that difference: the second proves absence, the first only
|
|
716
|
+
* proves we could not look. Collapsing both into `ok: false` forced callers
|
|
717
|
+
* to parse stderr, which Apple does not treat as a stable interface.
|
|
718
|
+
*/
|
|
719
|
+
status: result.status ?? null,
|
|
557
720
|
};
|
|
558
721
|
}
|
|
559
722
|
|
|
@@ -788,6 +951,10 @@ export function windowsSchedulerTaskInstalled(taskName = TASK): boolean {
|
|
|
788
951
|
export interface WindowsSchedulerInstallVerification {
|
|
789
952
|
taskInstalled: boolean;
|
|
790
953
|
registrationHealthy: boolean;
|
|
954
|
+
/** Well-formed XML that is PUBLISHED but policy-violating — permanent, never
|
|
955
|
+
* worth a settle retry (vs an empty/unreadable view, which is publication
|
|
956
|
+
* lag and transient). */
|
|
957
|
+
registrationInvalid: boolean;
|
|
791
958
|
assetsHealthy: boolean;
|
|
792
959
|
nativeServiceAbsent: boolean;
|
|
793
960
|
/** True when SCM probe failed; not a proven WinSW presence. */
|
|
@@ -808,6 +975,10 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
|
|
|
808
975
|
}): WindowsSchedulerInstallVerification {
|
|
809
976
|
const registrationHealthy = inputs.xml.length > 0
|
|
810
977
|
&& windowsTaskRegistrationHealthy(inputs.xml, inputs.wscript, inputs.launcher);
|
|
978
|
+
// Permanent invalidity: the XML IS published but violates the registration
|
|
979
|
+
// contract — no amount of settling changes it. Empty/unreadable XML stays
|
|
980
|
+
// transient (publication lag).
|
|
981
|
+
const registrationInvalid = inputs.taskInstalled && inputs.xml.length > 0 && !registrationHealthy;
|
|
811
982
|
const assetsHealthy = inputs.assetsExist;
|
|
812
983
|
const nativeServiceAbsent = inputs.nativeStatus === "nonexistent";
|
|
813
984
|
const nativeStatusUnknown = inputs.nativeStatus === "unknown";
|
|
@@ -831,6 +1002,7 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
|
|
|
831
1002
|
return {
|
|
832
1003
|
taskInstalled: inputs.taskInstalled,
|
|
833
1004
|
registrationHealthy,
|
|
1005
|
+
registrationInvalid,
|
|
834
1006
|
assetsHealthy,
|
|
835
1007
|
nativeServiceAbsent,
|
|
836
1008
|
nativeStatusUnknown,
|
|
@@ -870,6 +1042,48 @@ async function elevateSchtasks(args: string[]): Promise<void> {
|
|
|
870
1042
|
}
|
|
871
1043
|
}
|
|
872
1044
|
|
|
1045
|
+
export interface WindowsSchedulerRollbackDeps {
|
|
1046
|
+
queryXml?: () => string;
|
|
1047
|
+
deleteTask?: () => Promise<void>;
|
|
1048
|
+
probe?: () => WindowsSchedulerTaskProbe;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export async function rollbackWindowsSchedulerTaskOwnedByAttempt(
|
|
1052
|
+
attemptNonce: string,
|
|
1053
|
+
taskName = TASK,
|
|
1054
|
+
deps: WindowsSchedulerRollbackDeps = {},
|
|
1055
|
+
): Promise<string | null> {
|
|
1056
|
+
let registeredXml = "";
|
|
1057
|
+
try {
|
|
1058
|
+
registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", taskName, "/xml"])))();
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1061
|
+
return `Task Scheduler task ${taskName} ownership could not be proven: ${detail}. Residual scheduler state: task ${taskName} presence is unknown; no rollback deletion was attempted.`;
|
|
1062
|
+
}
|
|
1063
|
+
if (!registeredXml.trim()) {
|
|
1064
|
+
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.`;
|
|
1065
|
+
}
|
|
1066
|
+
if (!windowsTaskRegistrationOwnedByAttempt(registeredXml, attemptNonce)) {
|
|
1067
|
+
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.`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
try {
|
|
1071
|
+
await (deps.deleteTask ?? (() => elevateSchtasks(["/delete", "/tn", taskName, "/f"])))();
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1074
|
+
return `Rollback deletion failed: ${detail}. Residual scheduler state: task ${taskName} may remain registered.`;
|
|
1075
|
+
}
|
|
1076
|
+
const probe = (deps.probe ?? (() => resolveWindowsSchedulerTaskProbe(taskName)))();
|
|
1077
|
+
if (probe.status === "absent") return null;
|
|
1078
|
+
if (probe.status === "unknown") {
|
|
1079
|
+
return `Task Scheduler task ${taskName} presence could not be verified after rollback: ${probe.detail}. Residual scheduler state: task presence is unknown.`;
|
|
1080
|
+
}
|
|
1081
|
+
return `Residual scheduler state: task ${taskName} is still present after rollback.`;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Legacy dashboard finalization creates and runs in one elevated child, whose protocol
|
|
1085
|
+
// performs its own rollback before returning. This fallback remains for indeterminate
|
|
1086
|
+
// protocol outcomes that predate the staged CLI transaction.
|
|
873
1087
|
async function rollbackElevatedSchedulerTask(taskName = TASK): Promise<string | null> {
|
|
874
1088
|
try {
|
|
875
1089
|
await elevateSchtasks(["/delete", "/tn", taskName, "/f"]);
|
|
@@ -909,6 +1123,8 @@ type FinalizeHooks = {
|
|
|
909
1123
|
/** Defense-in-depth: late reconciliation must still own this attempt. */
|
|
910
1124
|
stillOwnsAttempt?: (attemptId: string) => boolean;
|
|
911
1125
|
requestTimeoutMs?: number;
|
|
1126
|
+
/** Test-only seam for the post-create settle backoff; real installs use a timer. */
|
|
1127
|
+
settleDelay?: (ms: number) => Promise<void>;
|
|
912
1128
|
};
|
|
913
1129
|
|
|
914
1130
|
let finalizeHooks: FinalizeHooks | null = null;
|
|
@@ -973,6 +1189,68 @@ function attemptStillOwned(options: ApplyElevatedOptions): boolean {
|
|
|
973
1189
|
return !check || check(options.attemptId);
|
|
974
1190
|
}
|
|
975
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* Bounded post-create backoff, 1.1s total. Task Scheduler's non-elevated view can
|
|
1194
|
+
* lag an elevated `/create` by a few hundred milliseconds, so a single verification
|
|
1195
|
+
* would roll back a task that is merely not visible yet.
|
|
1196
|
+
*/
|
|
1197
|
+
const SCHEDULER_SETTLE_DELAYS_MS = [50, 150, 300, 600] as const;
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Whether a failed verification is still worth re-checking after a short delay.
|
|
1201
|
+
*
|
|
1202
|
+
* Retrying is confined to states that a lagging scheduler view actually produces:
|
|
1203
|
+
* the task is not visible yet, or it is visible but its registration has not been
|
|
1204
|
+
* published in full. Everything else keeps its existing fail-closed meaning and is
|
|
1205
|
+
* rejected here so no delay can turn it into a pass:
|
|
1206
|
+
*
|
|
1207
|
+
* - a proven conflict (both backends present) is a real dual-backend install;
|
|
1208
|
+
* - missing assets are missing on disk, which no amount of waiting creates;
|
|
1209
|
+
* - a WinSW service that is proven present (`started`/`stopped`) is never absent
|
|
1210
|
+
* later. This is checked independently of `conflict`, which only becomes true
|
|
1211
|
+
* once the task itself is visible — while the task is still invisible the pair
|
|
1212
|
+
* is `conflict: false` with `nativeServiceAbsent: false`, and that must not retry;
|
|
1213
|
+
* - unknown SCM status is unproven rather than transient, and has its own
|
|
1214
|
+
* task-preserving branch below.
|
|
1215
|
+
*/
|
|
1216
|
+
/** Exported for tests: the transient-vs-permanent settle decision. */
|
|
1217
|
+
export function schedulerVerificationMaySettle(v: WindowsSchedulerInstallVerification): boolean {
|
|
1218
|
+
if (v.ok) return false;
|
|
1219
|
+
if (v.conflict) return false;
|
|
1220
|
+
if (!v.assetsHealthy) return false;
|
|
1221
|
+
if (!v.nativeServiceAbsent) return false;
|
|
1222
|
+
// A published-but-invalid registration is permanent: no delay repairs it.
|
|
1223
|
+
if (v.registrationInvalid) return false;
|
|
1224
|
+
return !v.taskInstalled || !v.registrationHealthy;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function settleDelay(ms: number): Promise<void> {
|
|
1228
|
+
const hook = finalizeHooks?.settleDelay;
|
|
1229
|
+
if (hook) return hook(ms);
|
|
1230
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Verify the elevated install, re-checking only while the failure looks like a
|
|
1235
|
+
* scheduler view that has not caught up yet. Returns `null` when this attempt lost
|
|
1236
|
+
* ownership mid-settle: a newer attempt owns the task, so this one must neither
|
|
1237
|
+
* write install state nor roll anything back.
|
|
1238
|
+
*/
|
|
1239
|
+
async function verifyWindowsSchedulerInstallAfterSettle(
|
|
1240
|
+
options: ApplyElevatedOptions,
|
|
1241
|
+
): Promise<WindowsSchedulerInstallVerification | null> {
|
|
1242
|
+
const verify = finalizeHooks?.verify ?? verifyWindowsSchedulerInstall;
|
|
1243
|
+
let verification = verify();
|
|
1244
|
+
for (const delayMs of SCHEDULER_SETTLE_DELAYS_MS) {
|
|
1245
|
+
if (!schedulerVerificationMaySettle(verification)) break;
|
|
1246
|
+
if (!attemptStillOwned(options)) return null;
|
|
1247
|
+
await settleDelay(delayMs);
|
|
1248
|
+
if (!attemptStillOwned(options)) return null;
|
|
1249
|
+
verification = verify();
|
|
1250
|
+
}
|
|
1251
|
+
return verification;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
976
1254
|
async function applyElevatedSchedulerResult(
|
|
977
1255
|
result: ElevatedSchtasksCreateAndRunResult,
|
|
978
1256
|
options: ApplyElevatedOptions,
|
|
@@ -1002,7 +1280,9 @@ async function applyElevatedSchedulerResult(
|
|
|
1002
1280
|
await reconcileUnknownElevatedOutcome(result.exitCode);
|
|
1003
1281
|
}
|
|
1004
1282
|
|
|
1005
|
-
const verification =
|
|
1283
|
+
const verification = await verifyWindowsSchedulerInstallAfterSettle(options);
|
|
1284
|
+
// Ownership moved to a newer attempt while settling; that attempt owns the outcome.
|
|
1285
|
+
if (!verification) return;
|
|
1006
1286
|
if (!verification.ok) {
|
|
1007
1287
|
// Preserve a healthy elevated task when WinSW absence cannot be proven (unknown SCM status).
|
|
1008
1288
|
// Unknown is not a confirmed dual-backend conflict; install state is still withheld.
|
|
@@ -1019,6 +1299,9 @@ async function applyElevatedSchedulerResult(
|
|
|
1019
1299
|
"Installation state was not written.",
|
|
1020
1300
|
]);
|
|
1021
1301
|
}
|
|
1302
|
+
// Rollback deletes a real task, so it needs the same ownership fence as the
|
|
1303
|
+
// state write below: a stale attempt must never delete a newer attempt's task.
|
|
1304
|
+
if (!attemptStillOwned(options)) return;
|
|
1022
1305
|
const rollbackError = await rollbackElevatedSchedulerTask();
|
|
1023
1306
|
const parts = [
|
|
1024
1307
|
"Elevated Task Scheduler registration did not produce a conflict-free install.",
|
|
@@ -1258,9 +1541,14 @@ function taskXmlRunLevelAcceptable(principal: string): boolean {
|
|
|
1258
1541
|
return value === "leastprivilege" || value === "highestavailable";
|
|
1259
1542
|
}
|
|
1260
1543
|
|
|
1261
|
-
export function buildWindowsServiceScript(
|
|
1262
|
-
|
|
1263
|
-
|
|
1544
|
+
export function buildWindowsServiceScript(
|
|
1545
|
+
entry = cliEntry(),
|
|
1546
|
+
port = resolveServiceListenPort(),
|
|
1547
|
+
proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
|
|
1548
|
+
): string {
|
|
1549
|
+
// Provenance rides along with the entry: a second durableBunRuntime() call here could
|
|
1550
|
+
// resolve differently from the binary the caller actually baked.
|
|
1551
|
+
const { bun, bunRuntimeSource, cli } = entry;
|
|
1264
1552
|
const path = process.env.PATH ?? "";
|
|
1265
1553
|
const lines = [
|
|
1266
1554
|
"@echo off",
|
|
@@ -1269,24 +1557,45 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
|
|
|
1269
1557
|
// it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
|
|
1270
1558
|
"chcp 65001 >nul",
|
|
1271
1559
|
windowsBatchSet("OCX_SERVICE", "1"),
|
|
1560
|
+
windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
|
|
1561
|
+
windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"),
|
|
1272
1562
|
windowsBatchSet("PATH", path, "pathList"),
|
|
1273
1563
|
windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
|
|
1564
|
+
windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
|
|
1274
1565
|
windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
|
|
1566
|
+
...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
|
|
1275
1567
|
windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
|
|
1276
1568
|
windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
|
|
1277
1569
|
windowsBatchSet("OCX_BUN", bun, "path"),
|
|
1278
1570
|
windowsBatchSet("OCX_CLI", cli, "path"),
|
|
1571
|
+
// Package root for the transactional-update restore path (#1942): cli is
|
|
1572
|
+
// <pkg>\src\cli\index.ts, so the package dir is three levels up.
|
|
1573
|
+
'for %%I in ("%OCX_CLI%\\..\\..\\..") do set "OCX_PKG_DIR=%%~fI"',
|
|
1279
1574
|
'if exist "%OCX_API_TOKEN_FILE%" (',
|
|
1280
1575
|
' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
|
|
1281
1576
|
")",
|
|
1282
1577
|
":loop",
|
|
1283
1578
|
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
|
|
1284
1579
|
'>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
|
|
1285
|
-
`>>"%OCX_SERVICE_LOG%" echo bun_source="${
|
|
1580
|
+
`>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntimeSource}"`,
|
|
1286
1581
|
'>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
|
|
1287
1582
|
'>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
|
|
1288
1583
|
'>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
|
|
1289
1584
|
'>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
|
|
1585
|
+
'if not exist "%OCX_BUN%" (',
|
|
1586
|
+
" call :restore_backup",
|
|
1587
|
+
")",
|
|
1588
|
+
'if not exist "%OCX_BUN%" (',
|
|
1589
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair',
|
|
1590
|
+
" exit /b 3",
|
|
1591
|
+
")",
|
|
1592
|
+
'if not exist "%OCX_CLI%" (',
|
|
1593
|
+
" call :restore_backup",
|
|
1594
|
+
")",
|
|
1595
|
+
'if not exist "%OCX_CLI%" (',
|
|
1596
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair',
|
|
1597
|
+
" exit /b 3",
|
|
1598
|
+
")",
|
|
1290
1599
|
`"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
|
|
1291
1600
|
"if %ERRORLEVEL% NEQ 0 (",
|
|
1292
1601
|
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
|
|
@@ -1296,6 +1605,26 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
|
|
|
1296
1605
|
" goto loop",
|
|
1297
1606
|
")",
|
|
1298
1607
|
"endlocal",
|
|
1608
|
+
"goto :eof",
|
|
1609
|
+
"",
|
|
1610
|
+
// #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and
|
|
1611
|
+
// a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE
|
|
1612
|
+
// the package tree, so it can restore when the launcher itself is gone — the exact
|
|
1613
|
+
// window the in-launcher boot probe cannot reach.
|
|
1614
|
+
":restore_backup",
|
|
1615
|
+
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] install incomplete - looking for a transactional-update backup to restore',
|
|
1616
|
+
'for /f "delims=" %%B in (\'dir /b /ad /o-n "%OCX_PKG_DIR%\\..\\.ocx-backup-*" 2^>nul\') do (',
|
|
1617
|
+
' if exist "%OCX_PKG_DIR%\\..\\%%B\\opencodex\\package.json" (',
|
|
1618
|
+
' if exist "%OCX_PKG_DIR%" rmdir /s /q "%OCX_PKG_DIR%" 2>nul',
|
|
1619
|
+
' move "%OCX_PKG_DIR%\\..\\%%B\\opencodex" "%OCX_PKG_DIR%" >nul 2>&1',
|
|
1620
|
+
' if exist "%OCX_PKG_DIR%\\package.json" (',
|
|
1621
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %%B',
|
|
1622
|
+
" goto :eof",
|
|
1623
|
+
" )",
|
|
1624
|
+
" )",
|
|
1625
|
+
")",
|
|
1626
|
+
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] no restorable backup found',
|
|
1627
|
+
"goto :eof",
|
|
1299
1628
|
].filter((line): line is string => Boolean(line));
|
|
1300
1629
|
return `${lines.join("\r\n")}\r\n`;
|
|
1301
1630
|
}
|
|
@@ -1305,6 +1634,11 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath
|
|
|
1305
1634
|
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
|
|
1306
1635
|
}
|
|
1307
1636
|
|
|
1637
|
+
/** Build the fixed scheduler-create command from an explicit staged XML document. */
|
|
1638
|
+
export function buildWindowsSchtasksCreateArgsForXml(xml: string): string[] {
|
|
1639
|
+
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1308
1642
|
/**
|
|
1309
1643
|
* VBS launcher that starts the batch wrapper with a hidden window (style 0).
|
|
1310
1644
|
* bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
|
|
@@ -1325,7 +1659,17 @@ export function buildWindowsLauncherVbs(script = windowsServiceScriptPath()): st
|
|
|
1325
1659
|
return `${lines.join("\r\n")}\r\n`;
|
|
1326
1660
|
}
|
|
1327
1661
|
|
|
1328
|
-
|
|
1662
|
+
function windowsTaskDescription(attemptNonce?: string): string {
|
|
1663
|
+
return attemptNonce
|
|
1664
|
+
? `OpenCodex proxy service wrapper; install-attempt=${attemptNonce}`
|
|
1665
|
+
: "OpenCodex proxy service wrapper";
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
export function buildWindowsTaskXml(
|
|
1669
|
+
script = windowsServiceScriptPath(),
|
|
1670
|
+
launcher = windowsLauncherVbsPath(),
|
|
1671
|
+
attemptNonce?: string,
|
|
1672
|
+
): string {
|
|
1329
1673
|
const escapedWscript = taskXmlString(windowsWscript());
|
|
1330
1674
|
// Escape the launcher path independently for the <Arguments> element; quoting it
|
|
1331
1675
|
// keeps spaces intact, and /b (batch mode) suppresses script error popups.
|
|
@@ -1333,7 +1677,7 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath(), launche
|
|
|
1333
1677
|
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
1334
1678
|
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
1335
1679
|
<RegistrationInfo>
|
|
1336
|
-
<Description
|
|
1680
|
+
<Description>${taskXmlString(windowsTaskDescription(attemptNonce))}</Description>
|
|
1337
1681
|
</RegistrationInfo>
|
|
1338
1682
|
<Triggers>
|
|
1339
1683
|
<LogonTrigger>
|
|
@@ -1453,6 +1797,21 @@ function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string):
|
|
|
1453
1797
|
return value?.trim().toLowerCase() === expected.toLowerCase();
|
|
1454
1798
|
}
|
|
1455
1799
|
|
|
1800
|
+
/** True only when the exported live task carries this install attempt's nonce. */
|
|
1801
|
+
export function windowsTaskRegistrationOwnedByAttempt(xml: string, attemptNonce: string): boolean {
|
|
1802
|
+
if (!attemptNonce) return false;
|
|
1803
|
+
const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
|
|
1804
|
+
if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
|
|
1805
|
+
if (taskXmlHasPrefixedTag(scrubbed, "RegistrationInfo")) return false;
|
|
1806
|
+
if (taskXmlElementCount(scrubbed, "RegistrationInfo") !== 1) return false;
|
|
1807
|
+
const registrationInfo = taskXmlSection(scrubbed, "RegistrationInfo");
|
|
1808
|
+
return taskXmlDecodedValueEquals(
|
|
1809
|
+
registrationInfo,
|
|
1810
|
+
"Description",
|
|
1811
|
+
windowsTaskDescription(attemptNonce),
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1456
1815
|
/** Validate the security/lifecycle-critical fields of the registered scheduler task. */
|
|
1457
1816
|
export function windowsTaskRegistrationHealthy(
|
|
1458
1817
|
xml: string,
|
|
@@ -1523,7 +1882,10 @@ function installLaunchd(): void {
|
|
|
1523
1882
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
1524
1883
|
writeServiceApiTokenFile();
|
|
1525
1884
|
const p = plistPath();
|
|
1526
|
-
|
|
1885
|
+
// Capture this BEFORE writing: the write below makes the plist exist unconditionally,
|
|
1886
|
+
// so a post-write existsSync would call every fresh install an "installed" service.
|
|
1887
|
+
const wasInstalled = existsSync(p);
|
|
1888
|
+
writeServiceDefinitionFile(p, buildPlist(), "utf8");
|
|
1527
1889
|
// Best-effort: an absent job is fine here, and a failed unload is caught by the
|
|
1528
1890
|
// load verification below with a better message than a raw unload error.
|
|
1529
1891
|
runLaunchctl(["unload", p]);
|
|
@@ -1535,7 +1897,9 @@ function installLaunchd(): void {
|
|
|
1535
1897
|
`launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
|
|
1536
1898
|
+ "A previous job may still be bootstrapped. Try:\n"
|
|
1537
1899
|
+ ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n`
|
|
1538
|
-
|
|
1900
|
+
// macOS `service repair` delegates straight to installLaunchd, so this fires for
|
|
1901
|
+
// an already-installed service too; repair reloads it without re-registering.
|
|
1902
|
+
+ `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
|
|
1539
1903
|
);
|
|
1540
1904
|
}
|
|
1541
1905
|
writeServiceInstallState();
|
|
@@ -1572,8 +1936,8 @@ export function startLaunchd(deps: {
|
|
|
1572
1936
|
throw new Error(
|
|
1573
1937
|
`launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
|
|
1574
1938
|
+ (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
|
|
1939
|
+
? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service repair`
|
|
1940
|
+
: "The job is not loaded. Run 'ocx service repair' to reload it."),
|
|
1577
1941
|
);
|
|
1578
1942
|
}
|
|
1579
1943
|
function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
|
|
@@ -1584,6 +1948,52 @@ function uninstallLaunchd(): void {
|
|
|
1584
1948
|
if (existsSync(p)) unlinkSync(p);
|
|
1585
1949
|
}
|
|
1586
1950
|
|
|
1951
|
+
/**
|
|
1952
|
+
* Write a service definition with owner-only permissions.
|
|
1953
|
+
*
|
|
1954
|
+
* These files carry the outbound proxy environment (#2107), and a proxy URL routinely
|
|
1955
|
+
* carries `user:password`. `writeFileSync` without a mode lands at 0644 under the default
|
|
1956
|
+
* umask, so the credential would be world-readable on a shared host. Every other
|
|
1957
|
+
* secret-bearing write in this file already uses 0600 — the service API token and the
|
|
1958
|
+
* install state — and a service definition holding a proxy credential belongs in the same
|
|
1959
|
+
* class.
|
|
1960
|
+
*
|
|
1961
|
+
* The explicit `chmodSync` is not redundant: `mode` only applies when the file is
|
|
1962
|
+
* created, so an install over a definition left at 0644 by an earlier version would keep
|
|
1963
|
+
* the loose mode.
|
|
1964
|
+
*
|
|
1965
|
+
* On Windows the POSIX bits are advisory, so the ACL is the real boundary — and whether it
|
|
1966
|
+
* may soft-fail depends on what the definition actually contains. A definition carrying a
|
|
1967
|
+
* proxy credential is a secret publication and fails closed like the API token and the
|
|
1968
|
+
* install state do; one carrying only paths and a port is not worth refusing an install
|
|
1969
|
+
* over, since before #2107 these files had no hardening at all and a failure here would
|
|
1970
|
+
* regress a user who has no credential to protect.
|
|
1971
|
+
*/
|
|
1972
|
+
export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void {
|
|
1973
|
+
writeFileSync(path, content, { encoding, mode: 0o600 });
|
|
1974
|
+
try { chmodSync(path, 0o600); } catch { /* superseded by the Windows ACL below */ }
|
|
1975
|
+
if (process.platform === "win32") {
|
|
1976
|
+
hardenSecretPath(path, { required: definitionCarriesCredential(content) });
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* Does this service definition embed a credential-bearing proxy URL?
|
|
1982
|
+
*
|
|
1983
|
+
* Only the userinfo form leaks something: `http://user:pass@host` in any of the four proxy
|
|
1984
|
+
* variables. A bare `http://127.0.0.1:7890` is not a secret, and treating it as one would
|
|
1985
|
+
* make an icacls stall fail an install that had nothing to protect.
|
|
1986
|
+
*
|
|
1987
|
+
* The scan is over any URL in the rendered definition rather than over a `KEY=value` shape,
|
|
1988
|
+
* because the three formats render differently — systemd writes `Environment="K=V"`, the
|
|
1989
|
+
* plist writes `<key>K</key><string>V</string>`, and the Windows wrapper writes
|
|
1990
|
+
* `set "K=V"`. Keying on the assignment syntax silently missed the plist.
|
|
1991
|
+
*/
|
|
1992
|
+
export function definitionCarriesCredential(content: string): boolean {
|
|
1993
|
+
// A userinfo authority: scheme, then anything that is not a delimiter, then '@'.
|
|
1994
|
+
return /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>/@]+@/i.test(content);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1587
1997
|
// ── Windows (Task Scheduler) ──
|
|
1588
1998
|
/**
|
|
1589
1999
|
* In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
|
|
@@ -1592,7 +2002,7 @@ function uninstallLaunchd(): void {
|
|
|
1592
2002
|
function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
|
|
1593
2003
|
for (let attempt = 0; ; attempt++) {
|
|
1594
2004
|
try {
|
|
1595
|
-
|
|
2005
|
+
writeServiceDefinitionFile(path, content, encoding);
|
|
1596
2006
|
return;
|
|
1597
2007
|
} catch (err) {
|
|
1598
2008
|
const code = (err as NodeJS.ErrnoException).code;
|
|
@@ -1617,21 +2027,236 @@ function writeWindowsSchedulerAssets(): void {
|
|
|
1617
2027
|
writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
|
|
1618
2028
|
}
|
|
1619
2029
|
|
|
1620
|
-
|
|
1621
|
-
|
|
2030
|
+
const WINDOWS_SCHEDULER_STAGE_PREFIX = "opencodex-service-stage-";
|
|
2031
|
+
const ownedWindowsSchedulerStages = new Set<string>();
|
|
2032
|
+
|
|
2033
|
+
export interface WindowsSchedulerRegistrationStageDeps {
|
|
2034
|
+
createStageDir?: () => string;
|
|
2035
|
+
hardenDir?: (path: string) => void;
|
|
2036
|
+
writeXml?: (path: string, contents: string) => void;
|
|
2037
|
+
hardenPath?: (path: string) => void;
|
|
2038
|
+
removeStageDir?: (path: string) => void;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function cleanupWindowsSchedulerStage(
|
|
2042
|
+
stageDir: string,
|
|
2043
|
+
xmlPath: string,
|
|
2044
|
+
removeStageDir: (path: string) => void,
|
|
2045
|
+
): void {
|
|
2046
|
+
let cleanupError: unknown;
|
|
2047
|
+
try {
|
|
2048
|
+
unlinkSync(xmlPath);
|
|
2049
|
+
forgetEphemeralSecretPath(xmlPath);
|
|
2050
|
+
} catch (error) {
|
|
2051
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
|
2052
|
+
forgetEphemeralSecretPath(xmlPath);
|
|
2053
|
+
} else {
|
|
2054
|
+
cleanupError = error;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
try {
|
|
2058
|
+
removeStageDir(stageDir);
|
|
2059
|
+
forgetEphemeralSecretDir(stageDir);
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
|
2062
|
+
forgetEphemeralSecretDir(stageDir);
|
|
2063
|
+
} else if (cleanupError) {
|
|
2064
|
+
throw new AggregateError([cleanupError, error], "Task Scheduler staging cleanup failed.");
|
|
2065
|
+
} else {
|
|
2066
|
+
cleanupError = error;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
if (cleanupError) throw cleanupError;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
export function stageWindowsSchedulerRegistrationXml(
|
|
2073
|
+
attemptNonce: string,
|
|
2074
|
+
deps: WindowsSchedulerRegistrationStageDeps = {},
|
|
2075
|
+
): string {
|
|
2076
|
+
const createStageDir = deps.createStageDir
|
|
2077
|
+
?? (() => mkdtempSync(join(tmpdir(), WINDOWS_SCHEDULER_STAGE_PREFIX)));
|
|
2078
|
+
const hardenDir = deps.hardenDir
|
|
2079
|
+
?? ((path: string) => { hardenSecretDir(path, { required: true }); });
|
|
2080
|
+
const writeXml = deps.writeXml ?? ((path: string, contents: string) => {
|
|
2081
|
+
writeFileSync(path, contents, { encoding: "utf16le", flag: "wx", mode: 0o600 });
|
|
2082
|
+
});
|
|
2083
|
+
const hardenPath = deps.hardenPath
|
|
2084
|
+
?? ((path: string) => { hardenSecretPath(path, { required: true }); });
|
|
2085
|
+
const removeStageDir = deps.removeStageDir
|
|
2086
|
+
?? ((path: string) => { rmdirSync(path); });
|
|
2087
|
+
|
|
2088
|
+
let stageDir: string | null = null;
|
|
2089
|
+
let xmlPath: string | null = null;
|
|
2090
|
+
try {
|
|
2091
|
+
stageDir = createStageDir();
|
|
2092
|
+
try { chmodSync(stageDir, 0o700); } catch { /* required Windows ACL is authoritative */ }
|
|
2093
|
+
hardenDir(stageDir);
|
|
2094
|
+
xmlPath = join(stageDir, "task.xml");
|
|
2095
|
+
// This document points at the canonical launcher but does not publish or rewrite it.
|
|
2096
|
+
// The hardened private directory prevents another local account from replacing the
|
|
2097
|
+
// document while UAC is pending; the file harden independently proves its identity.
|
|
2098
|
+
writeXml(
|
|
2099
|
+
xmlPath,
|
|
2100
|
+
`\uFEFF${buildWindowsTaskXml(windowsServiceScriptPath(), windowsLauncherVbsPath(), attemptNonce)}`,
|
|
2101
|
+
);
|
|
2102
|
+
hardenPath(xmlPath);
|
|
2103
|
+
ownedWindowsSchedulerStages.add(xmlPath);
|
|
2104
|
+
return xmlPath;
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
if (stageDir) {
|
|
2107
|
+
try {
|
|
2108
|
+
cleanupWindowsSchedulerStage(stageDir, xmlPath ?? join(stageDir, "task.xml"), removeStageDir);
|
|
2109
|
+
} catch (cleanupError) {
|
|
2110
|
+
throw new AggregateError(
|
|
2111
|
+
[error, cleanupError],
|
|
2112
|
+
"Task Scheduler staging failed and its private temporary directory could not be removed.",
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
throw error;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function removeWindowsSchedulerRegistrationStage(xmlPath: string): void {
|
|
2121
|
+
if (!ownedWindowsSchedulerStages.has(xmlPath)) {
|
|
2122
|
+
throw new Error("Refusing to remove an unrecognized Task Scheduler staging path.");
|
|
2123
|
+
}
|
|
2124
|
+
const stageDir = dirname(xmlPath);
|
|
2125
|
+
cleanupWindowsSchedulerStage(
|
|
2126
|
+
stageDir,
|
|
2127
|
+
xmlPath,
|
|
2128
|
+
path => { rmdirSync(path); },
|
|
2129
|
+
);
|
|
2130
|
+
if (existsSync(stageDir)) {
|
|
2131
|
+
throw new Error("The private Task Scheduler staging directory still exists after cleanup.");
|
|
2132
|
+
}
|
|
2133
|
+
ownedWindowsSchedulerStages.delete(xmlPath);
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
export interface FreshWindowsSchedulerRegistrationDeps {
|
|
2137
|
+
create?: (args: string[]) => void;
|
|
2138
|
+
elevate?: (taskName: string, xml: string) => Promise<void>;
|
|
2139
|
+
probe?: () => WindowsSchedulerTaskProbe;
|
|
2140
|
+
queryXml?: () => string;
|
|
2141
|
+
rollback?: () => Promise<string | null>;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
export async function registerFreshWindowsSchedulerTask(
|
|
2145
|
+
xmlPath: string,
|
|
2146
|
+
attemptNonce: string,
|
|
2147
|
+
deps: FreshWindowsSchedulerRegistrationDeps = {},
|
|
2148
|
+
): Promise<void> {
|
|
2149
|
+
const args = buildWindowsSchtasksCreateArgsForXml(xmlPath);
|
|
2150
|
+
// Capture and validate the exact definition before an access-denied attempt can
|
|
2151
|
+
// cross the UAC boundary. The elevated fallback receives these immutable bytes,
|
|
2152
|
+
// never the caller-writable staging pathname.
|
|
2153
|
+
const expectedXml = decodeSchtasksOutput(readFileSync(xmlPath));
|
|
2154
|
+
if (
|
|
2155
|
+
!windowsTaskRegistrationHealthy(expectedXml)
|
|
2156
|
+
|| !windowsTaskRegistrationOwnedByAttempt(expectedXml, attemptNonce)
|
|
2157
|
+
) {
|
|
2158
|
+
throw new Error("The staged Task Scheduler registration failed OpenCodex ownership or shape validation.");
|
|
2159
|
+
}
|
|
2160
|
+
try {
|
|
2161
|
+
(deps.create ?? schtasks)(args);
|
|
2162
|
+
} catch (error) {
|
|
2163
|
+
if (
|
|
2164
|
+
!(error instanceof WindowsSchtasksError)
|
|
2165
|
+
|| error.operation !== "create"
|
|
2166
|
+
|| error.reason !== "access-denied"
|
|
2167
|
+
) {
|
|
2168
|
+
throw error;
|
|
2169
|
+
}
|
|
2170
|
+
// Register from the captured XML string inside the elevated process. Another
|
|
2171
|
+
// same-user process can mutate its own temp files, but cannot change this command.
|
|
2172
|
+
const elevate = deps.elevate ?? (async (taskName: string, xml: string) => {
|
|
2173
|
+
const exitCode = await runWindowsElevatedScheduledTaskRegistration(taskName, xml);
|
|
2174
|
+
if (exitCode !== 0) throw new Error(`Background service install failed with exit code ${exitCode}.`);
|
|
2175
|
+
});
|
|
2176
|
+
await elevate(TASK, expectedXml);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
const rollbackTask = deps.rollback ?? (() => rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK));
|
|
2180
|
+
const probe = (deps.probe ?? (() => probeWindowsSchedulerTask(TASK)))();
|
|
2181
|
+
if (probe.status === "absent") {
|
|
2182
|
+
throw new Error("Task Scheduler reported success, but the new registration is absent; no service cleanup was started.");
|
|
2183
|
+
}
|
|
2184
|
+
if (probe.status === "unknown") {
|
|
2185
|
+
const rollback = await rollbackTask();
|
|
2186
|
+
throw new Error(
|
|
2187
|
+
`Task Scheduler registration was not verifiably present after create (${probe.detail}).`
|
|
2188
|
+
+ (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
let registeredXml = "";
|
|
2193
|
+
let queryDetail: string | null = null;
|
|
2194
|
+
try {
|
|
2195
|
+
registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", TASK, "/xml"])))();
|
|
2196
|
+
} catch (error) {
|
|
2197
|
+
queryDetail = error instanceof Error ? error.message : String(error);
|
|
2198
|
+
}
|
|
2199
|
+
if (!registeredXml.trim()) {
|
|
2200
|
+
const rollback = await rollbackTask();
|
|
2201
|
+
throw new Error(
|
|
2202
|
+
"Task Scheduler registration was created, but its live XML could not be verified."
|
|
2203
|
+
+ (queryDetail ? ` Query failed: ${queryDetail}` : " The query returned an empty document.")
|
|
2204
|
+
+ (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
|
|
2205
|
+
);
|
|
2206
|
+
}
|
|
2207
|
+
if (
|
|
2208
|
+
!windowsTaskRegistrationHealthy(registeredXml)
|
|
2209
|
+
|| !windowsTaskRegistrationOwnedByAttempt(registeredXml, attemptNonce)
|
|
2210
|
+
) {
|
|
2211
|
+
const rollback = await rollbackTask();
|
|
2212
|
+
throw new Error(
|
|
2213
|
+
"Task Scheduler registration was created but failed the OpenCodex action/trigger or attempt-ownership verification."
|
|
2214
|
+
+ (rollback ? ` Cleanup also failed: ${rollback}` : " The invalid registration was rolled back."),
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function recordWindowsSchedulerOwnership(): boolean {
|
|
2220
|
+
// Ownership claiming is deliberately conservative: a legacy non-empty config root
|
|
2221
|
+
// without metadata stays unclaimed, but that must not turn a service reinstall into
|
|
2222
|
+
// an outage after prepareServiceInstall has stopped the previous manager.
|
|
2223
|
+
return recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
export interface RemoveNativeWindowsServiceDeps {
|
|
2227
|
+
status?: () => WinswStatus;
|
|
2228
|
+
uninstall?: () => void;
|
|
2229
|
+
sleep?: (ms: number) => void;
|
|
2230
|
+
settleChecks?: number;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
export function removeNativeWindowsServiceForScheduler(
|
|
2234
|
+
deps: RemoveNativeWindowsServiceDeps = {},
|
|
2235
|
+
): void {
|
|
2236
|
+
const status = deps.status ?? statusWinswRaw;
|
|
2237
|
+
const uninstall = deps.uninstall ?? uninstallWinswService;
|
|
2238
|
+
const sleep = deps.sleep ?? Bun.sleepSync;
|
|
2239
|
+
const settleChecks = Math.max(1, deps.settleChecks ?? 20);
|
|
1622
2240
|
// Transactional backend switch: installing the scheduler backend removes a native
|
|
1623
2241
|
// service first — two live managers would both respawn the proxy (conflict).
|
|
1624
|
-
if (
|
|
2242
|
+
if (status() !== "nonexistent") {
|
|
1625
2243
|
console.log("🔁 Removing the native (WinSW) service before installing the Task Scheduler backend...");
|
|
1626
2244
|
try {
|
|
1627
|
-
|
|
2245
|
+
uninstall();
|
|
1628
2246
|
} catch (err) {
|
|
1629
2247
|
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
2248
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
2249
|
+
for (let check = 0; check < settleChecks; check++) {
|
|
2250
|
+
if (status() === "nonexistent") return;
|
|
2251
|
+
if (check + 1 < settleChecks) sleep(250);
|
|
1633
2252
|
}
|
|
2253
|
+
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
2254
|
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function installWindows(): void {
|
|
2258
|
+
recordWindowsSchedulerOwnership();
|
|
2259
|
+
removeNativeWindowsServiceForScheduler();
|
|
1635
2260
|
// End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
|
|
1636
2261
|
// script mid-rewrite runs a torn batch file, and its open handle can fail the write.
|
|
1637
2262
|
try { stopWindows(); } catch { /* not running */ }
|
|
@@ -1803,6 +2428,22 @@ export function stopWindows(): void {
|
|
|
1803
2428
|
}
|
|
1804
2429
|
function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
|
|
1805
2430
|
function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
|
|
2431
|
+
|
|
2432
|
+
/**
|
|
2433
|
+
* Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
|
|
2434
|
+
* `schtasks /end` ends the task instance but often leaves wscript/cmd running the
|
|
2435
|
+
* `:loop` batch, which brings the proxy back during a stop or restart.
|
|
2436
|
+
*
|
|
2437
|
+
* The matching rule — canonical paths of THIS installation, as complete
|
|
2438
|
+
* command-line tokens — lives in lib/windows-service-wrappers so the update job
|
|
2439
|
+
* cannot drift away from it again.
|
|
2440
|
+
*/
|
|
2441
|
+
function killWindowsServiceWrapperProcesses(): void {
|
|
2442
|
+
killWindowsSchedulerWrappers({
|
|
2443
|
+
scriptPath: windowsServiceScriptPath(),
|
|
2444
|
+
launcherPath: windowsLauncherVbsPath(),
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
1806
2447
|
function uninstallWindows(): void {
|
|
1807
2448
|
const probe = probeWindowsSchedulerTask(TASK);
|
|
1808
2449
|
if (probe.status === "present") {
|
|
@@ -1836,7 +2477,7 @@ export function bakedServicePathsDiagnostic(): string | null {
|
|
|
1836
2477
|
if (!state?.bunPath || !state?.cliPath) return null;
|
|
1837
2478
|
const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
|
|
1838
2479
|
if (missing.length === 0) return null;
|
|
1839
|
-
return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service
|
|
2480
|
+
return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service repair' to re-bake`;
|
|
1840
2481
|
}
|
|
1841
2482
|
|
|
1842
2483
|
function serviceDiagnosticsSummary(): string {
|
|
@@ -1853,17 +2494,22 @@ function unitPath(): string {
|
|
|
1853
2494
|
return join(unitDir(), `${TASK}.service`);
|
|
1854
2495
|
}
|
|
1855
2496
|
|
|
1856
|
-
export function buildUnit(): string {
|
|
1857
|
-
const { bun, cli } = cliEntry();
|
|
2497
|
+
export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
|
|
2498
|
+
const { bun, bunRuntimeSource, cli } = cliEntry();
|
|
1858
2499
|
const log = logPath();
|
|
1859
2500
|
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
1860
2501
|
const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
|
|
2502
|
+
const codexSqliteHome = systemdEnvironmentAssignment("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute());
|
|
1861
2503
|
const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
|
|
1862
2504
|
const envLines = [
|
|
1863
2505
|
systemdEnvironmentAssignment("OCX_SERVICE", "1"),
|
|
2506
|
+
systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
|
|
2507
|
+
systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
|
|
1864
2508
|
systemdEnvironmentAssignment("PATH", path),
|
|
1865
2509
|
codexHome,
|
|
2510
|
+
codexSqliteHome,
|
|
1866
2511
|
opencodexHome,
|
|
2512
|
+
...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
|
|
1867
2513
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
1868
2514
|
return `[Unit]
|
|
1869
2515
|
Description=OpenCodex Proxy Server
|
|
@@ -1924,7 +2570,7 @@ function installSystemd(): void {
|
|
|
1924
2570
|
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
1925
2571
|
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
1926
2572
|
writeServiceApiTokenFile();
|
|
1927
|
-
|
|
2573
|
+
writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8");
|
|
1928
2574
|
sh("systemctl --user daemon-reload");
|
|
1929
2575
|
sh(`systemctl --user enable ${TASK}`);
|
|
1930
2576
|
sh(`systemctl --user restart ${TASK}`);
|
|
@@ -1987,6 +2633,11 @@ type ServiceOps = {
|
|
|
1987
2633
|
status: () => string; uninstall: () => void;
|
|
1988
2634
|
};
|
|
1989
2635
|
|
|
2636
|
+
type ServiceInstallCleanupOps = {
|
|
2637
|
+
status: () => string | null;
|
|
2638
|
+
stop: () => void;
|
|
2639
|
+
};
|
|
2640
|
+
|
|
1990
2641
|
function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
|
|
1991
2642
|
if (process.platform === "darwin")
|
|
1992
2643
|
return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
|
|
@@ -2012,6 +2663,67 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
|
|
|
2012
2663
|
return null;
|
|
2013
2664
|
}
|
|
2014
2665
|
|
|
2666
|
+
/**
|
|
2667
|
+
* Install-only manager operations. Unlike the ordinary status/stop helpers, these
|
|
2668
|
+
* distinguish confirmed absence from a failed manager query and propagate every
|
|
2669
|
+
* non-benign stop failure. Installing new assets is unsafe while either answer is
|
|
2670
|
+
* unknown because an old manager may still respawn a listener on the target port.
|
|
2671
|
+
*/
|
|
2672
|
+
function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInstallCleanupOps | null {
|
|
2673
|
+
if (process.platform === "darwin") {
|
|
2674
|
+
return {
|
|
2675
|
+
status: () => {
|
|
2676
|
+
const listing = sh("launchctl list");
|
|
2677
|
+
return listing.split("\n").some(line => line.includes(LABEL)) ? listing : null;
|
|
2678
|
+
},
|
|
2679
|
+
stop: () => { sh(`launchctl unload "${plistPath()}"`); },
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
if (process.platform === "win32") {
|
|
2683
|
+
if (backend === "native") {
|
|
2684
|
+
return {
|
|
2685
|
+
status: () => {
|
|
2686
|
+
const status = statusWinswRaw();
|
|
2687
|
+
if (status === "unknown") throw new Error("Native service status could not be verified.");
|
|
2688
|
+
return status === "nonexistent" ? null : status;
|
|
2689
|
+
},
|
|
2690
|
+
stop: stopWinswService,
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
return {
|
|
2694
|
+
status: () => {
|
|
2695
|
+
const probe = probeWindowsSchedulerTask(TASK);
|
|
2696
|
+
if (probe.status === "unknown") throw new Error(`Task Scheduler status could not be verified: ${probe.detail}`);
|
|
2697
|
+
return probe.status === "present" ? "present" : null;
|
|
2698
|
+
},
|
|
2699
|
+
stop: () => {
|
|
2700
|
+
try {
|
|
2701
|
+
schtasks(["/end", "/tn", TASK]);
|
|
2702
|
+
} catch (error) {
|
|
2703
|
+
if (!isWindowsSchedulerEndBenign(error)) throw error;
|
|
2704
|
+
}
|
|
2705
|
+
},
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
if (process.platform === "linux") {
|
|
2709
|
+
return {
|
|
2710
|
+
status: () => {
|
|
2711
|
+
// `list-unit-files <name>` exits non-zero when the unit has never been
|
|
2712
|
+
// installed, which made a clean first install look like an unknown manager
|
|
2713
|
+
// failure. `show LoadState` gives us the tri-state we actually need: a
|
|
2714
|
+
// healthy user manager returns `not-found` for a missing unit, while an
|
|
2715
|
+
// unreachable/permission-denied manager still makes `sh()` throw and the
|
|
2716
|
+
// caller therefore fails closed.
|
|
2717
|
+
const loadState = sh(`systemctl --user show ${TASK} --property=LoadState --value`).trim().toLowerCase();
|
|
2718
|
+
if (!loadState) throw new Error("systemd service status could not be verified.");
|
|
2719
|
+
return loadState === "not-found" ? null : loadState;
|
|
2720
|
+
},
|
|
2721
|
+
stop: () => { sh(`systemctl --user stop ${TASK}`); },
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
return null;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2015
2727
|
type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
|
|
2016
2728
|
|
|
2017
2729
|
function verifiedKillTarget(pid: number | null | undefined): number | null {
|
|
@@ -2107,6 +2819,159 @@ async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupR
|
|
|
2107
2819
|
}
|
|
2108
2820
|
}
|
|
2109
2821
|
|
|
2822
|
+
export interface ServiceInstallPreparationDeps {
|
|
2823
|
+
diagnose?: () => ServiceDiagnostic;
|
|
2824
|
+
managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null;
|
|
2825
|
+
stopTrackedProxy?: () => Promise<unknown>;
|
|
2826
|
+
platform?: NodeJS.Platform;
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
/**
|
|
2830
|
+
* Stop every manager that could own the install port, then stop the tracked
|
|
2831
|
+
* standalone listener. Any unknown status or cleanup failure rejects, so callers
|
|
2832
|
+
* cannot write assets or report success over a surviving old listener.
|
|
2833
|
+
*/
|
|
2834
|
+
export async function prepareServiceInstall(
|
|
2835
|
+
requestedBackend: ServiceBackend,
|
|
2836
|
+
deps: ServiceInstallPreparationDeps = {},
|
|
2837
|
+
): Promise<void> {
|
|
2838
|
+
const diagnostic = (deps.diagnose ?? diagnoseService)();
|
|
2839
|
+
const platform = deps.platform ?? process.platform;
|
|
2840
|
+
const resolveOps = deps.managerOps ?? platformServiceInstallCleanupOps;
|
|
2841
|
+
const backends: ServiceBackend[] = [];
|
|
2842
|
+
const addBackend = (backend: ServiceBackend) => {
|
|
2843
|
+
if (!backends.includes(backend)) backends.push(backend);
|
|
2844
|
+
};
|
|
2845
|
+
|
|
2846
|
+
if (platform === "win32") {
|
|
2847
|
+
// The recorded backend owns the old installation and must be stopped first.
|
|
2848
|
+
// A conflicting diagnostic means both managers exist, so stop both even when
|
|
2849
|
+
// the requested backend happens to match the recorded one.
|
|
2850
|
+
if (diagnostic.backend === "scheduler" || diagnostic.backend === "native") {
|
|
2851
|
+
addBackend(diagnostic.backend);
|
|
2852
|
+
if (diagnostic.conflict) addBackend(diagnostic.backend === "scheduler" ? "native" : "scheduler");
|
|
2853
|
+
}
|
|
2854
|
+
addBackend(requestedBackend);
|
|
2855
|
+
} else {
|
|
2856
|
+
addBackend(requestedBackend);
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
for (const backend of backends) {
|
|
2860
|
+
const manager = resolveOps(backend);
|
|
2861
|
+
if (!manager) throw new Error(`Background service manager is unavailable for ${backend}.`);
|
|
2862
|
+
if (manager.status() !== null) manager.stop();
|
|
2863
|
+
}
|
|
2864
|
+
await (deps.stopTrackedProxy ?? stopTrackedProxyIfRunning)();
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
export async function installServiceSafely(
|
|
2868
|
+
requestedBackend: ServiceBackend,
|
|
2869
|
+
install: () => void | Promise<void>,
|
|
2870
|
+
deps: ServiceInstallPreparationDeps = {},
|
|
2871
|
+
): Promise<void> {
|
|
2872
|
+
await prepareServiceInstall(requestedBackend, deps);
|
|
2873
|
+
await install();
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
export interface FreshWindowsSchedulerInstallDeps {
|
|
2877
|
+
stageRegistrationXml?: (attemptNonce: string) => string;
|
|
2878
|
+
register?: (xmlPath: string, attemptNonce: string) => Promise<void>;
|
|
2879
|
+
recordOwnership?: () => boolean;
|
|
2880
|
+
prepare?: () => Promise<void>;
|
|
2881
|
+
removeNativeService?: () => void;
|
|
2882
|
+
publishAssets?: () => void;
|
|
2883
|
+
runTask?: () => void;
|
|
2884
|
+
writeState?: () => void;
|
|
2885
|
+
rollbackTask?: (attemptNonce: string) => Promise<string | null>;
|
|
2886
|
+
removeStagedXml?: (xmlPath: string) => void;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
/**
|
|
2890
|
+
* Fresh Windows scheduler install with UAC before the destructive commit.
|
|
2891
|
+
*
|
|
2892
|
+
* The registration is created but never run before `prepare`: UAC cancellation and
|
|
2893
|
+
* create failure therefore cannot stop the existing proxy or trigger its native-routing
|
|
2894
|
+
* cleanup. Rollback proves ownership from the live registration's attempt nonce before
|
|
2895
|
+
* deleting, because the fixed task name can be replaced by another process at any time.
|
|
2896
|
+
*/
|
|
2897
|
+
export async function installFreshWindowsSchedulerSafely(
|
|
2898
|
+
deps: FreshWindowsSchedulerInstallDeps = {},
|
|
2899
|
+
): Promise<void> {
|
|
2900
|
+
const stage = deps.stageRegistrationXml ?? stageWindowsSchedulerRegistrationXml;
|
|
2901
|
+
const register = deps.register ?? registerFreshWindowsSchedulerTask;
|
|
2902
|
+
const recordOwnership = deps.recordOwnership ?? recordWindowsSchedulerOwnership;
|
|
2903
|
+
const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler"));
|
|
2904
|
+
const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler;
|
|
2905
|
+
const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets;
|
|
2906
|
+
const runTask = deps.runTask ?? startWindows;
|
|
2907
|
+
const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler"));
|
|
2908
|
+
const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => (
|
|
2909
|
+
rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK)
|
|
2910
|
+
));
|
|
2911
|
+
const removeStagedXml = deps.removeStagedXml ?? ((path: string) => {
|
|
2912
|
+
removeWindowsSchedulerRegistrationStage(path);
|
|
2913
|
+
});
|
|
2914
|
+
|
|
2915
|
+
let stagedXml: string | null = null;
|
|
2916
|
+
const attemptNonce = randomUUID();
|
|
2917
|
+
const configRootWasAbsent = !existsSync(getConfigDir());
|
|
2918
|
+
let registered = false;
|
|
2919
|
+
let started = false;
|
|
2920
|
+
try {
|
|
2921
|
+
stagedXml = stage(attemptNonce);
|
|
2922
|
+
await register(stagedXml, attemptNonce);
|
|
2923
|
+
registered = true;
|
|
2924
|
+
|
|
2925
|
+
// The destructive boundary begins only after Task Scheduler accepted the definition.
|
|
2926
|
+
// The registration has consumed its temporary XML. Remove it before claiming a newly
|
|
2927
|
+
// created config root, because ownership initialization intentionally requires emptiness.
|
|
2928
|
+
removeStagedXml(stagedXml);
|
|
2929
|
+
stagedXml = null;
|
|
2930
|
+
const ownershipRecorded = recordOwnership();
|
|
2931
|
+
if (!ownershipRecorded && configRootWasAbsent) {
|
|
2932
|
+
throw new Error(
|
|
2933
|
+
"The fresh OpenCodex config root could not be claimed for safe uninstall; "
|
|
2934
|
+
+ "aborting before service-manager cleanup or asset publication.",
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2937
|
+
await prepare();
|
|
2938
|
+
removeNativeService();
|
|
2939
|
+
publishAssets();
|
|
2940
|
+
runTask();
|
|
2941
|
+
started = true;
|
|
2942
|
+
writeState();
|
|
2943
|
+
} catch (error) {
|
|
2944
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2945
|
+
if (registered && !started) {
|
|
2946
|
+
const rollback = await rollbackTask(attemptNonce);
|
|
2947
|
+
throw new Error(
|
|
2948
|
+
`${detail}\n`
|
|
2949
|
+
+ (rollback
|
|
2950
|
+
? `The new Task Scheduler registration may remain: ${rollback}`
|
|
2951
|
+
: "The new Task Scheduler registration was rolled back. The previous proxy/routing state was not assumed restored."),
|
|
2952
|
+
);
|
|
2953
|
+
}
|
|
2954
|
+
if (started) {
|
|
2955
|
+
throw new Error(
|
|
2956
|
+
`${detail}\nThe scheduler task started, but install state was not published. `
|
|
2957
|
+
+ "The task was left in place; inspect `ocx service status` before retrying.",
|
|
2958
|
+
);
|
|
2959
|
+
}
|
|
2960
|
+
throw error;
|
|
2961
|
+
} finally {
|
|
2962
|
+
if (stagedXml) {
|
|
2963
|
+
try { removeStagedXml(stagedXml); } catch (error) {
|
|
2964
|
+
const code = error && typeof error === "object" && "code" in error
|
|
2965
|
+
? String((error as NodeJS.ErrnoException).code)
|
|
2966
|
+
: "";
|
|
2967
|
+
console.error(
|
|
2968
|
+
`⚠️ Failed to remove the private Task Scheduler staging directory${code ? ` (${code})` : ""}.`,
|
|
2969
|
+
);
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2110
2975
|
/**
|
|
2111
2976
|
* If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
|
|
2112
2977
|
* Returns true if a service was found and stopped.
|
|
@@ -2128,6 +2993,10 @@ export function stopServiceIfInstalled(): boolean {
|
|
|
2128
2993
|
if (statusWinswRaw() !== "nonexistent") {
|
|
2129
2994
|
try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
|
|
2130
2995
|
}
|
|
2996
|
+
// `schtasks /end` ends the task instance but the cmd `:loop` wrapper survives and
|
|
2997
|
+
// respawns its child seconds later (issue #764), resurrecting the proxy during a
|
|
2998
|
+
// stop or a tray restart. Kill the launcher/wrapper processes outright.
|
|
2999
|
+
killWindowsServiceWrapperProcesses();
|
|
2131
3000
|
if (stopped) return true;
|
|
2132
3001
|
} else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
|
|
2133
3002
|
try { stopSystemd(); return true; } catch { return false; }
|
|
@@ -2142,33 +3011,53 @@ function removeServiceInstallState(): void {
|
|
|
2142
3011
|
}
|
|
2143
3012
|
}
|
|
2144
3013
|
|
|
3014
|
+
type UninstallServiceHooksForTests = {
|
|
3015
|
+
platform: typeof process.platform;
|
|
3016
|
+
assertEnvironment: () => void;
|
|
3017
|
+
probeWindowsTask: () => WindowsSchedulerTaskProbe;
|
|
3018
|
+
uninstallWindowsTask: () => void;
|
|
3019
|
+
nativeStatus: () => WinswStatus;
|
|
3020
|
+
uninstallNative: () => void;
|
|
3021
|
+
removeInstallState: () => void;
|
|
3022
|
+
};
|
|
3023
|
+
|
|
3024
|
+
let uninstallServiceHooksForTests: UninstallServiceHooksForTests | null = null;
|
|
3025
|
+
|
|
3026
|
+
/** Test-only hooks for full-uninstall service removal. */
|
|
3027
|
+
export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksForTests | null): void {
|
|
3028
|
+
uninstallServiceHooksForTests = hooks;
|
|
3029
|
+
}
|
|
3030
|
+
|
|
2145
3031
|
/**
|
|
2146
3032
|
* Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
|
|
2147
|
-
* when no service exists
|
|
2148
|
-
*
|
|
3033
|
+
* when no service exists or the platform has no service manager. An installed native Windows
|
|
3034
|
+
* service or scheduler task that cannot be removed throws so the caller cannot erase state and
|
|
3035
|
+
* report success.
|
|
2149
3036
|
*/
|
|
2150
3037
|
export function uninstallServiceIfInstalled(): boolean {
|
|
2151
|
-
|
|
2152
|
-
|
|
3038
|
+
const hooks = uninstallServiceHooksForTests;
|
|
3039
|
+
(hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
|
|
3040
|
+
const platform = hooks?.platform ?? process.platform;
|
|
3041
|
+
if (platform === "darwin") {
|
|
2153
3042
|
if (existsSync(plistPath())) {
|
|
2154
3043
|
try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
|
|
2155
3044
|
}
|
|
2156
|
-
} else if (
|
|
3045
|
+
} else if (platform === "win32") {
|
|
2157
3046
|
let removed = false;
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
}
|
|
2162
|
-
if (
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
3047
|
+
const scheduler = (hooks?.probeWindowsTask ?? probeWindowsSchedulerTask)();
|
|
3048
|
+
if (scheduler.status === "unknown") {
|
|
3049
|
+
throw new Error(`Could not determine Task Scheduler state: ${scheduler.detail}`);
|
|
3050
|
+
}
|
|
3051
|
+
if (scheduler.status === "present") {
|
|
3052
|
+
(hooks?.uninstallWindowsTask ?? uninstallWindows)();
|
|
3053
|
+
removed = true;
|
|
3054
|
+
}
|
|
3055
|
+
if ((hooks?.nativeStatus ?? statusWinswRaw)() !== "nonexistent") {
|
|
3056
|
+
(hooks?.uninstallNative ?? uninstallWinswService)();
|
|
3057
|
+
removed = true;
|
|
2169
3058
|
}
|
|
2170
|
-
if (removed) { removeServiceInstallState(); return true; }
|
|
2171
|
-
} else if (
|
|
3059
|
+
if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return true; }
|
|
3060
|
+
} else if (platform === "linux" && existsSync(unitPath())) {
|
|
2172
3061
|
try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
|
|
2173
3062
|
try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
|
|
2174
3063
|
}
|
|
@@ -2251,7 +3140,7 @@ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticI
|
|
|
2251
3140
|
const detail = conflict
|
|
2252
3141
|
? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one"
|
|
2253
3142
|
: stale
|
|
2254
|
-
? "stale or missing service assets — run 'ocx service
|
|
3143
|
+
? "stale or missing service assets — run 'ocx service repair'"
|
|
2255
3144
|
: schedulerInstalled
|
|
2256
3145
|
? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
|
|
2257
3146
|
: nativeInstalled
|
|
@@ -2373,7 +3262,7 @@ export async function serviceStatusReport(
|
|
|
2373
3262
|
: null);
|
|
2374
3263
|
const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
|
|
2375
3264
|
? " launchd is running an OLDER plist than the one on disk.\n"
|
|
2376
|
-
+ ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service
|
|
3265
|
+
+ ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service repair\n`
|
|
2377
3266
|
: "";
|
|
2378
3267
|
|
|
2379
3268
|
return `⚠️ ${diag.summary}\n`
|
|
@@ -2385,6 +3274,7 @@ export async function serviceStatusReport(
|
|
|
2385
3274
|
}
|
|
2386
3275
|
|
|
2387
3276
|
export function normalizeServiceSubcommand(sub?: string): string {
|
|
3277
|
+
if (sub === "restart") return "repair";
|
|
2388
3278
|
return sub ?? "install";
|
|
2389
3279
|
}
|
|
2390
3280
|
|
|
@@ -2394,6 +3284,119 @@ export interface ParsedServiceArgs {
|
|
|
2394
3284
|
invalid: string[];
|
|
2395
3285
|
}
|
|
2396
3286
|
|
|
3287
|
+
export type ServiceInstallationState = "installed" | "absent" | "unknown";
|
|
3288
|
+
|
|
3289
|
+
export interface ServiceInstallationProbe {
|
|
3290
|
+
state: ServiceInstallationState;
|
|
3291
|
+
detail?: string;
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
export interface ServiceInstallationProbeHooks {
|
|
3295
|
+
platform?: NodeJS.Platform;
|
|
3296
|
+
exists?: (path: string) => boolean;
|
|
3297
|
+
probeWindowsTask?: () => WindowsSchedulerTaskProbe;
|
|
3298
|
+
nativeStatus?: () => WinswStatus;
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
/**
|
|
3302
|
+
* Read only enough registration state to choose between install and repair.
|
|
3303
|
+
* Windows must keep query failure distinct from proven absence: treating an
|
|
3304
|
+
* unreadable scheduler/SCM as absent would send a bare command into the
|
|
3305
|
+
* elevated registration path and recreate the original #2287 failure.
|
|
3306
|
+
*/
|
|
3307
|
+
export function probeServiceInstallation(
|
|
3308
|
+
hooks: ServiceInstallationProbeHooks = {},
|
|
3309
|
+
): ServiceInstallationProbe {
|
|
3310
|
+
const platform = hooks.platform ?? process.platform;
|
|
3311
|
+
const exists = hooks.exists ?? existsSync;
|
|
3312
|
+
if (platform === "darwin") {
|
|
3313
|
+
return { state: exists(plistPath()) ? "installed" : "absent" };
|
|
3314
|
+
}
|
|
3315
|
+
if (platform === "linux") {
|
|
3316
|
+
return { state: exists(unitPath()) ? "installed" : "absent" };
|
|
3317
|
+
}
|
|
3318
|
+
if (platform !== "win32") return { state: "absent" };
|
|
3319
|
+
|
|
3320
|
+
let scheduler: WindowsSchedulerTaskProbe;
|
|
3321
|
+
try {
|
|
3322
|
+
scheduler = (hooks.probeWindowsTask ?? probeWindowsSchedulerTask)();
|
|
3323
|
+
} catch (cause) {
|
|
3324
|
+
scheduler = { status: "unknown", detail: schtasksErrorDetail(cause) };
|
|
3325
|
+
}
|
|
3326
|
+
let native: WinswStatus;
|
|
3327
|
+
try {
|
|
3328
|
+
native = (hooks.nativeStatus ?? statusWinswRaw)();
|
|
3329
|
+
} catch {
|
|
3330
|
+
native = "unknown";
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
if (scheduler.status === "present" || native === "started" || native === "stopped") {
|
|
3334
|
+
return { state: "installed" };
|
|
3335
|
+
}
|
|
3336
|
+
if (scheduler.status === "unknown" || native === "unknown") {
|
|
3337
|
+
const parts = [
|
|
3338
|
+
scheduler.status === "unknown" ? `Task Scheduler: ${scheduler.detail}` : null,
|
|
3339
|
+
native === "unknown" ? "WinSW status could not be determined" : null,
|
|
3340
|
+
].filter((part): part is string => Boolean(part));
|
|
3341
|
+
return { state: "unknown", detail: parts.join("; ") };
|
|
3342
|
+
}
|
|
3343
|
+
return { state: "absent" };
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
/**
|
|
3347
|
+
* A bare invocation is an idempotent "make the installed service current"
|
|
3348
|
+
* operation. First-time setup still installs, but an existing registration must
|
|
3349
|
+
* use the repair path so Windows does not re-run the elevated `schtasks /create`.
|
|
3350
|
+
* Backend flags remain an explicit install request because they select which
|
|
3351
|
+
* registration mechanism to create.
|
|
3352
|
+
*/
|
|
3353
|
+
export function selectServiceSubcommand(
|
|
3354
|
+
parsed: ParsedServiceArgs,
|
|
3355
|
+
options: { hasExplicitSubcommand: boolean; installed: boolean },
|
|
3356
|
+
): string {
|
|
3357
|
+
if (!options.hasExplicitSubcommand && parsed.backend === null && options.installed) return "repair";
|
|
3358
|
+
return parsed.sub;
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
export type ServiceCommandPlan =
|
|
3362
|
+
| { ok: true; parsed: ParsedServiceArgs; command: string }
|
|
3363
|
+
| { ok: false; message: string };
|
|
3364
|
+
|
|
3365
|
+
export function planServiceCommand(
|
|
3366
|
+
args: string[],
|
|
3367
|
+
options: { platform?: NodeJS.Platform; probeInstallation?: () => ServiceInstallationProbe } = {},
|
|
3368
|
+
): ServiceCommandPlan {
|
|
3369
|
+
const parsed = parseServiceArgs(args);
|
|
3370
|
+
if (parsed.invalid.length > 0) {
|
|
3371
|
+
return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` };
|
|
3372
|
+
}
|
|
3373
|
+
if (parsed.backend && parsed.sub !== "install") {
|
|
3374
|
+
return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." };
|
|
3375
|
+
}
|
|
3376
|
+
if (parsed.backend === "native" && (options.platform ?? process.platform) !== "win32") {
|
|
3377
|
+
return { ok: false, message: "--native (WinSW) is Windows-only." };
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
const hasExplicitSubcommand = args.some(arg => !arg.startsWith("--"));
|
|
3381
|
+
let installed = false;
|
|
3382
|
+
if (!hasExplicitSubcommand && parsed.backend === null) {
|
|
3383
|
+
const probe = (options.probeInstallation ?? probeServiceInstallation)();
|
|
3384
|
+
if (probe.state === "unknown") {
|
|
3385
|
+
const suffix = probe.detail ? ` (${probe.detail})` : "";
|
|
3386
|
+
return {
|
|
3387
|
+
ok: false,
|
|
3388
|
+
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.`,
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
installed = probe.state === "installed";
|
|
3392
|
+
}
|
|
3393
|
+
return {
|
|
3394
|
+
ok: true,
|
|
3395
|
+
parsed,
|
|
3396
|
+
command: selectServiceSubcommand(parsed, { hasExplicitSubcommand, installed }),
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
3399
|
+
|
|
2397
3400
|
/**
|
|
2398
3401
|
* `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the
|
|
2399
3402
|
* subcommand; backend flags are only meaningful for `install` (validated by the caller).
|
|
@@ -2419,20 +3422,13 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
|
|
|
2419
3422
|
}
|
|
2420
3423
|
|
|
2421
3424
|
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.");
|
|
3425
|
+
const filteredArgs = args.filter((a): a is string => Boolean(a));
|
|
3426
|
+
const plan = planServiceCommand(filteredArgs);
|
|
3427
|
+
if (!plan.ok) {
|
|
3428
|
+
console.error(plan.message);
|
|
2434
3429
|
process.exit(1);
|
|
2435
3430
|
}
|
|
3431
|
+
const { parsed, command } = plan;
|
|
2436
3432
|
if (command === "repair") {
|
|
2437
3433
|
assertServiceEnvironmentMatchesInstall();
|
|
2438
3434
|
assertServiceAuthEnvironment();
|
|
@@ -2455,7 +3451,31 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
2455
3451
|
case "install":
|
|
2456
3452
|
assertServiceEnvironmentMatchesInstall();
|
|
2457
3453
|
assertServiceAuthEnvironment();
|
|
2458
|
-
|
|
3454
|
+
// A manually started proxy can still own the configured port while the service
|
|
3455
|
+
// registration is absent or unloaded. Stop both the registered manager and any
|
|
3456
|
+
// tracked standalone listener before loading the freshly written service assets.
|
|
3457
|
+
// Otherwise launchd/Task Scheduler can register successfully while its child
|
|
3458
|
+
// restart-loops on EADDRINUSE, and the old standalone process makes the install
|
|
3459
|
+
// verification report a false success.
|
|
3460
|
+
try {
|
|
3461
|
+
if (process.platform === "win32" && backend === "scheduler") {
|
|
3462
|
+
const scheduler = probeWindowsSchedulerTask(TASK);
|
|
3463
|
+
if (scheduler.status === "unknown") {
|
|
3464
|
+
throw new Error(`Task Scheduler state could not be verified before install: ${scheduler.detail}`);
|
|
3465
|
+
}
|
|
3466
|
+
if (scheduler.status === "absent") {
|
|
3467
|
+
await installFreshWindowsSchedulerSafely();
|
|
3468
|
+
} else {
|
|
3469
|
+
await installServiceSafely(backend, ops.install);
|
|
3470
|
+
}
|
|
3471
|
+
} else {
|
|
3472
|
+
await installServiceSafely(backend, ops.install);
|
|
3473
|
+
}
|
|
3474
|
+
} catch (error) {
|
|
3475
|
+
console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3476
|
+
process.exitCode = 1;
|
|
3477
|
+
break;
|
|
3478
|
+
}
|
|
2459
3479
|
// The wrapper was written moments ago in this process, so the configured port
|
|
2460
3480
|
// and the baked one cannot have diverged yet — unlike `start`, which reads the
|
|
2461
3481
|
// installed artifact instead.
|
|
@@ -2494,7 +3514,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
2494
3514
|
process.exitCode = 1;
|
|
2495
3515
|
break;
|
|
2496
3516
|
}
|
|
2497
|
-
const restore =
|
|
3517
|
+
const restore = await restoreNativeCodexAsync();
|
|
2498
3518
|
if (restore.success) console.log("✅ service stopped + native Codex restored.");
|
|
2499
3519
|
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
3520
|
// The Grok fence is the other managed config this command owns. Leaving it behind
|
|
@@ -2532,7 +3552,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
2532
3552
|
process.exit(1);
|
|
2533
3553
|
}
|
|
2534
3554
|
{
|
|
2535
|
-
const restore =
|
|
3555
|
+
const restore = await restoreNativeCodexAsync();
|
|
2536
3556
|
if (!restore.success) {
|
|
2537
3557
|
console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
|
|
2538
3558
|
}
|
|
@@ -2545,9 +3565,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
2545
3565
|
console.log("✅ service uninstalled.");
|
|
2546
3566
|
break;
|
|
2547
3567
|
default:
|
|
2548
|
-
console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]");
|
|
2549
|
-
console.error(" With no subcommand, installs
|
|
3568
|
+
console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
|
|
3569
|
+
console.error(" With no subcommand, installs when absent or repairs/restarts an existing service.");
|
|
2550
3570
|
console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
|
|
3571
|
+
console.error(" restart: alias of repair.");
|
|
2551
3572
|
console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
|
|
2552
3573
|
process.exit(1);
|
|
2553
3574
|
}
|