@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922
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 +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +8 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +20 -1
- package/src/adapters/base.ts +23 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +32 -4
- package/src/adapters/command-code.ts +52 -4
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +20 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +14 -3
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +125 -36
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +34 -7
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google-http.ts +49 -10
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +66 -11
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +23 -58
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +17 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +13 -151
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +46 -7
- package/src/cli/doctor.ts +28 -9
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +203 -59
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +4 -7
- package/src/client/runtime.ts +26 -2
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/account-store.ts +65 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +43 -17
- package/src/codex/catalog/retained-sync.ts +24 -28
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +31 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +99 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +116 -54
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +12 -42
- package/src/codex/shim-templates.ts +29 -3
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/companion/settings.ts +132 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +52 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +31 -1
- package/src/config/schema/leaf-validators.ts +59 -0
- package/src/config.ts +3 -3
- package/src/generated/compatibility-version.json +604 -280
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +921 -0
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +134 -110
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +63 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +109 -16
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +27 -108
- package/src/oauth/login-cli.ts +80 -29
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +97 -19
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +179 -38
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/vendor-probes-key.ts +38 -23
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +47 -20
- package/src/providers/registry/entries-extended.ts +63 -4
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +56 -10
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/apply-patch-envelope.ts +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +3 -0
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +108 -117
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +60 -4
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +61 -20
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +142 -39
- package/src/server/index/spend-ledger-lifecycle.ts +92 -0
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +34 -38
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/config-routes.ts +18 -2
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +20 -0
- package/src/server/management/shared.ts +28 -4
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +10 -9
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +138 -12
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +169 -2
- package/src/server/request-metrics.ts +298 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +43 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- package/src/server/responses/core-opaque-recovery.ts +91 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +45 -15
- package/src/server/responses/passthrough-dispatch.ts +215 -44
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +109 -18
- package/src/server/responses/request-send-budget.ts +17 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +26 -6
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +35 -2
- package/src/server/search.ts +25 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +156 -4
- package/src/types/config.ts +48 -3
- package/src/types/provider.ts +91 -0
- package/src/types/request.ts +30 -2
- package/src/types/tools.ts +33 -0
- package/src/types.ts +4 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/alpha-search.ts +21 -1
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
package/src/service/health.ts
CHANGED
|
@@ -32,9 +32,10 @@ export function resolveServiceListenPort(override?: number): number {
|
|
|
32
32
|
return 10100;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function buildServiceShellCommand(bun: string, cli: string, port = resolveServiceListenPort()): string {
|
|
35
|
+
export function buildServiceShellCommand(bun: string, cli: string | null, port = resolveServiceListenPort()): string {
|
|
36
36
|
const tokenFile = serviceApiTokenFilePath();
|
|
37
|
-
|
|
37
|
+
const args = cli ? `${shellQuote(cli)} start` : "start";
|
|
38
|
+
return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${args} --port ${port}`;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AuthoritativeServiceStateRecord,
|
|
3
|
+
ServiceInstallStateRecord,
|
|
4
|
+
ServiceOwnershipRecord as OwnershipClaim,
|
|
5
|
+
ServiceStateRecordEvidence as InstallStateEvidence,
|
|
6
|
+
} from "./state-record.mjs";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
10
|
+
SERVICE_OWNERSHIP_PROTOCOL_VERSION,
|
|
11
|
+
selectAuthoritativeServiceState,
|
|
12
|
+
serviceStateFingerprint,
|
|
13
|
+
} from "./state-record.mjs";
|
|
14
|
+
|
|
15
|
+
import type { ServiceStateRecordEvidence } from "./state-record.mjs";
|
|
16
|
+
|
|
17
|
+
export type OwnershipResolution =
|
|
18
|
+
| { readonly kind: "none"; readonly revision: number; readonly needsRepair?: boolean }
|
|
19
|
+
| { readonly kind: "owned"; readonly ownership: { owner: string; installId: string; consentGeneration: number }; readonly revision: number }
|
|
20
|
+
| { readonly kind: "unknown"; readonly reason: string };
|
|
21
|
+
|
|
22
|
+
export declare const SERVICE_STATE_FILE: "service-state.json";
|
|
23
|
+
export declare function parseOwnershipClaim(value: unknown): import("./state-record.mjs").ServiceOwnershipRecord | null;
|
|
24
|
+
export declare function parseInstallStateRecord(value: unknown): import("./state-record.mjs").ServiceInstallStateRecord | null;
|
|
25
|
+
export declare function inspectInstallStateBytes(path: string, read: (path: string) => string): ServiceStateRecordEvidence;
|
|
26
|
+
export declare function resolveOwnershipFromEvidence(evidence: readonly ServiceStateRecordEvidence[]): OwnershipResolution;
|
|
27
|
+
export declare function serviceStateFilesFor(opencodexHomeDir: string, defaultHomeDir: string, platform?: NodeJS.Platform): string[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Compatibility surface for the shared install-state contract landed before C2 hardening. */
|
|
2
|
+
import {
|
|
3
|
+
inspectServiceStateRecords,
|
|
4
|
+
parseServiceInstallStateRecord,
|
|
5
|
+
parseServiceOwnershipRecord,
|
|
6
|
+
SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
7
|
+
SERVICE_OWNERSHIP_PROTOCOL_VERSION,
|
|
8
|
+
selectAuthoritativeServiceState,
|
|
9
|
+
serviceStateFingerprint,
|
|
10
|
+
serviceStatePathsForHomes,
|
|
11
|
+
} from "./state-record.mjs";
|
|
12
|
+
|
|
13
|
+
export const SERVICE_STATE_FILE = "service-state.json";
|
|
14
|
+
export const parseOwnershipClaim = parseServiceOwnershipRecord;
|
|
15
|
+
export const parseInstallStateRecord = parseServiceInstallStateRecord;
|
|
16
|
+
export const serviceStateFilesFor = serviceStatePathsForHomes;
|
|
17
|
+
export {
|
|
18
|
+
SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
19
|
+
SERVICE_OWNERSHIP_PROTOCOL_VERSION,
|
|
20
|
+
selectAuthoritativeServiceState,
|
|
21
|
+
serviceStateFingerprint,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function inspectInstallStateBytes(path, read) {
|
|
25
|
+
return inspectServiceStateRecords([path], read)[0];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveOwnershipFromEvidence(evidence) {
|
|
29
|
+
const selected = selectAuthoritativeServiceState(evidence);
|
|
30
|
+
if (selected.kind === "unknown" || selected.kind === "none") return selected;
|
|
31
|
+
return selected.state.ownership
|
|
32
|
+
? { kind: "owned", ownership: selected.state.ownership, revision: selected.revision }
|
|
33
|
+
: { kind: "none", revision: selected.revision };
|
|
34
|
+
}
|
package/src/service/launchd.ts
CHANGED
|
@@ -160,7 +160,7 @@ function launchdServiceCommand(
|
|
|
160
160
|
*/
|
|
161
161
|
export function expectedLaunchdCommand(
|
|
162
162
|
port: number,
|
|
163
|
-
deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string } } = {},
|
|
163
|
+
deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string | null } } = {},
|
|
164
164
|
): string {
|
|
165
165
|
const state = deps.state === undefined ? readServiceInstallState() : deps.state;
|
|
166
166
|
if (state?.launcherPath) return buildServiceLauncherShellCommand(state.launcherPath, port);
|
|
@@ -11,7 +11,7 @@ import type { ServiceDiagnostic } from "./diagnostics";
|
|
|
11
11
|
import { assertServiceEnvironmentMatchesInstall } from "./guards";
|
|
12
12
|
import { runLaunchctl, launchdEvictionTargets, launchctlBootoutBenign, probeLaunchdLoadState, installLaunchd, startLaunchd, stopLaunchd, statusLaunchd, uninstallLaunchd } from "./launchd";
|
|
13
13
|
import { assertSchedulerRegistrationBeforeStart } from "./repair";
|
|
14
|
-
import { SERVICE_MANAGED_ENV, TASK, plistPath,
|
|
14
|
+
import { SERVICE_MANAGED_ENV, TASK, plistPath, removeServiceInstallStateRecords, writeServiceInstallState } from "./state";
|
|
15
15
|
import type { ServiceBackend } from "./state";
|
|
16
16
|
import { unitPath, isSystemd, installSystemd, startSystemd, stopSystemd, statusSystemd, uninstallSystemd, systemdServiceInstallCleanupOps } from "./systemd";
|
|
17
17
|
import { writeWindowsSchedulerAssets, stageWindowsSchedulerRegistrationXml, removeWindowsSchedulerRegistrationStage, registerFreshWindowsSchedulerTask, recordWindowsSchedulerOwnership, removeNativeWindowsServiceForScheduler, installWindows, installWindowsNative, startWindows, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, statusWindows, statusWindowsXml, killWindowsServiceWrapperProcesses, uninstallWindows, classifyWindowsServiceStop } from "./windows-ops";
|
|
@@ -527,9 +527,7 @@ export function stopServiceIfInstalledDetailed(): ServiceStopOutcome {
|
|
|
527
527
|
|
|
528
528
|
/** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */
|
|
529
529
|
export function removeServiceInstallState(): void {
|
|
530
|
-
|
|
531
|
-
try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ }
|
|
532
|
-
}
|
|
530
|
+
removeServiceInstallStateRecords();
|
|
533
531
|
}
|
|
534
532
|
|
|
535
533
|
type UninstallServiceHooksForTests = {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { parseStrictSemver } from "../lib/strict-semver";
|
|
4
|
+
import type {
|
|
5
|
+
ServiceInstallState,
|
|
6
|
+
ServiceOwnershipSubject,
|
|
7
|
+
} from "./state";
|
|
8
|
+
import {
|
|
9
|
+
SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
10
|
+
SERVICE_OWNERSHIP_PROTOCOL_VERSION,
|
|
11
|
+
} from "./install-state-contract.mjs";
|
|
12
|
+
|
|
13
|
+
export type ManagingCliRole = "service-registration" | "path";
|
|
14
|
+
|
|
15
|
+
export type ManagingCliObservation =
|
|
16
|
+
| { readonly status: "absent" }
|
|
17
|
+
| { readonly status: "unknown"; readonly reason: string }
|
|
18
|
+
| { readonly status: "observed"; readonly version: string; readonly identity: string };
|
|
19
|
+
|
|
20
|
+
export type RegisteredManagingCliInvocation =
|
|
21
|
+
| { readonly status: "absent" }
|
|
22
|
+
| { readonly status: "unknown"; readonly reason: string }
|
|
23
|
+
| { readonly status: "resolved"; readonly executable: string; readonly args: readonly string[] };
|
|
24
|
+
|
|
25
|
+
/** Resolve the exact command baked into the preserved service registration. */
|
|
26
|
+
export function registeredManagingCliInvocation(
|
|
27
|
+
state: ServiceInstallState | null,
|
|
28
|
+
): RegisteredManagingCliInvocation {
|
|
29
|
+
if (!state) return { status: "absent" };
|
|
30
|
+
if (state.launcherPath) {
|
|
31
|
+
return isAbsolute(state.launcherPath)
|
|
32
|
+
? { status: "resolved", executable: state.launcherPath, args: [] }
|
|
33
|
+
: { status: "unknown", reason: "the recorded service launcher is not absolute" };
|
|
34
|
+
}
|
|
35
|
+
if (!state.bunPath || !isAbsolute(state.bunPath)) {
|
|
36
|
+
return { status: "unknown", reason: "the registered service executable is missing or not absolute" };
|
|
37
|
+
}
|
|
38
|
+
if (state.cliPath === null) return { status: "resolved", executable: state.bunPath, args: [] };
|
|
39
|
+
if (typeof state.cliPath === "string" && isAbsolute(state.cliPath)) {
|
|
40
|
+
return { status: "resolved", executable: state.bunPath, args: [state.cliPath] };
|
|
41
|
+
}
|
|
42
|
+
return { status: "unknown", reason: "the registered service CLI path is missing or not absolute" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ServiceTakeoverCompatibilityInput {
|
|
46
|
+
readonly state: ServiceInstallState | null;
|
|
47
|
+
readonly subject: ServiceOwnershipSubject;
|
|
48
|
+
readonly managers: Readonly<Record<ManagingCliRole, ManagingCliObservation>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ServiceTakeoverCompatibility =
|
|
52
|
+
| {
|
|
53
|
+
readonly kind: "supported";
|
|
54
|
+
readonly protocolVersion: typeof SERVICE_OWNERSHIP_PROTOCOL_VERSION;
|
|
55
|
+
readonly minimumCliVersion: typeof SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION;
|
|
56
|
+
/** Opaque binding over the approved subject and both managing-CLI observations. */
|
|
57
|
+
readonly token: string;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
readonly kind: "blocked";
|
|
61
|
+
readonly reason:
|
|
62
|
+
| "managing-cli-unknown"
|
|
63
|
+
| "managing-cli-unsupported"
|
|
64
|
+
| "managing-cli-unobserved"
|
|
65
|
+
| "service-protocol-unsupported";
|
|
66
|
+
readonly detail: string;
|
|
67
|
+
readonly minimumCliVersion: typeof SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function comparePrerelease(left: readonly (bigint | string)[], right: readonly (bigint | string)[]): number {
|
|
71
|
+
if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
72
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
73
|
+
const a = left[index];
|
|
74
|
+
const b = right[index];
|
|
75
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
|
|
76
|
+
if (a === b) continue;
|
|
77
|
+
if (typeof a === "bigint" && typeof b === "bigint") return a < b ? -1 : 1;
|
|
78
|
+
if (typeof a === "bigint") return -1;
|
|
79
|
+
if (typeof b === "bigint") return 1;
|
|
80
|
+
return a < b ? -1 : 1;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function versionSupportsOwnership(value: string): boolean {
|
|
86
|
+
const actual = parseStrictSemver(value);
|
|
87
|
+
const minimum = parseStrictSemver(SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION);
|
|
88
|
+
if (!actual || !minimum) return false;
|
|
89
|
+
for (let index = 0; index < actual.core.length; index += 1) {
|
|
90
|
+
if (actual.core[index] !== minimum.core[index]) return actual.core[index]! > minimum.core[index]!;
|
|
91
|
+
}
|
|
92
|
+
return comparePrerelease(actual.prerelease, minimum.prerelease) >= 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function compatibilityToken(input: ServiceTakeoverCompatibilityInput): string {
|
|
96
|
+
return createHash("sha256").update(JSON.stringify({
|
|
97
|
+
subject: input.subject,
|
|
98
|
+
protocolVersion: input.state?.ownershipProtocolVersion ?? null,
|
|
99
|
+
managers: {
|
|
100
|
+
"service-registration": input.managers["service-registration"],
|
|
101
|
+
path: input.managers.path,
|
|
102
|
+
},
|
|
103
|
+
})).digest("hex");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Decide whether permanent desktop ownership can be offered.
|
|
108
|
+
*
|
|
109
|
+
* Both managing surfaces are mandatory observations. `absent` is a trustworthy answer;
|
|
110
|
+
* `unknown` is not. An observed service registration additionally needs the protocol marker
|
|
111
|
+
* written by a CLI whose start/repair/update paths honor the ownership claim. This is what
|
|
112
|
+
* keeps the preserved registration from starting an older runtime at the next login.
|
|
113
|
+
*/
|
|
114
|
+
export function assessServiceTakeoverCompatibility(
|
|
115
|
+
input: ServiceTakeoverCompatibilityInput,
|
|
116
|
+
): ServiceTakeoverCompatibility {
|
|
117
|
+
const observed = Object.entries(input.managers) as Array<[ManagingCliRole, ManagingCliObservation]>;
|
|
118
|
+
const unknown = observed.find(([, manager]) => manager.status === "unknown");
|
|
119
|
+
if (unknown) return {
|
|
120
|
+
kind: "blocked",
|
|
121
|
+
reason: "managing-cli-unknown",
|
|
122
|
+
detail: `${unknown[0]} compatibility could not be determined`,
|
|
123
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
124
|
+
};
|
|
125
|
+
const present = observed.filter(([, manager]) => manager.status === "observed") as Array<[
|
|
126
|
+
ManagingCliRole,
|
|
127
|
+
Extract<ManagingCliObservation, { status: "observed" }>,
|
|
128
|
+
]>;
|
|
129
|
+
if (present.length === 0) return {
|
|
130
|
+
kind: "blocked",
|
|
131
|
+
reason: "managing-cli-unobserved",
|
|
132
|
+
detail: "no managing OpenCodex CLI installation was observed",
|
|
133
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
134
|
+
};
|
|
135
|
+
const unsupported = present.find(([, manager]) => !versionSupportsOwnership(manager.version));
|
|
136
|
+
if (unsupported) return {
|
|
137
|
+
kind: "blocked",
|
|
138
|
+
reason: "managing-cli-unsupported",
|
|
139
|
+
detail: `${unsupported[0]} uses OpenCodex ${unsupported[1].version}; ${SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION} or later is required`,
|
|
140
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
141
|
+
};
|
|
142
|
+
if (input.managers["service-registration"].status === "observed"
|
|
143
|
+
&& input.state?.ownershipProtocolVersion !== SERVICE_OWNERSHIP_PROTOCOL_VERSION) {
|
|
144
|
+
return {
|
|
145
|
+
kind: "blocked",
|
|
146
|
+
reason: "service-protocol-unsupported",
|
|
147
|
+
detail: "the preserved service registration was not written by an ownership-aware CLI",
|
|
148
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
kind: "supported",
|
|
153
|
+
protocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION,
|
|
154
|
+
minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
|
|
155
|
+
token: compatibilityToken(input),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function sameServiceTakeoverCompatibility(
|
|
160
|
+
left: ServiceTakeoverCompatibility,
|
|
161
|
+
right: ServiceTakeoverCompatibility,
|
|
162
|
+
): boolean {
|
|
163
|
+
return left.kind === "supported" && right.kind === "supported" && left.token === right.token;
|
|
164
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface OwnershipMutationLeaseOptions {
|
|
2
|
+
readonly waitMs?: number;
|
|
3
|
+
readonly now?: () => number;
|
|
4
|
+
readonly sleep?: (ms: number) => void;
|
|
5
|
+
readonly processAlive?: (pid: number) => boolean;
|
|
6
|
+
readonly beforeRelease?: (lockPath: string) => void;
|
|
7
|
+
readonly joinToken?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface OwnershipMutationLease { readonly token: string; release(): void }
|
|
11
|
+
|
|
12
|
+
export declare const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV: "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN";
|
|
13
|
+
|
|
14
|
+
export declare function ownershipMutationLeaseChildEnvironment(
|
|
15
|
+
environment: NodeJS.ProcessEnv,
|
|
16
|
+
token: string,
|
|
17
|
+
): NodeJS.ProcessEnv;
|
|
18
|
+
|
|
19
|
+
export declare function unprivilegedOwnershipMutationEnvironment(
|
|
20
|
+
environment: NodeJS.ProcessEnv,
|
|
21
|
+
): NodeJS.ProcessEnv;
|
|
22
|
+
|
|
23
|
+
export declare function acquireOwnershipMutationLease(
|
|
24
|
+
statePaths: readonly string[],
|
|
25
|
+
options?: OwnershipMutationLeaseOptions,
|
|
26
|
+
): OwnershipMutationLease;
|
|
27
|
+
|
|
28
|
+
export declare function withOwnershipMutationLease<T>(
|
|
29
|
+
statePaths: readonly string[],
|
|
30
|
+
run: () => T,
|
|
31
|
+
options?: OwnershipMutationLeaseOptions,
|
|
32
|
+
): T;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
rmdirSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { basename, dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const WAIT_MS = 2_000;
|
|
19
|
+
const POLL_MS = 20;
|
|
20
|
+
const STALE_MS = 30_000;
|
|
21
|
+
const PROCESS_INSTANCE = randomUUID();
|
|
22
|
+
const held = new Map();
|
|
23
|
+
const delegatedTokens = new Map();
|
|
24
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
25
|
+
export const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV = "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN";
|
|
26
|
+
|
|
27
|
+
export function ownershipMutationLeaseChildEnvironment(environment, token) {
|
|
28
|
+
return { ...environment, [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: token };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function unprivilegedOwnershipMutationEnvironment(environment) {
|
|
32
|
+
const child = { ...environment };
|
|
33
|
+
delete child[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV];
|
|
34
|
+
return child;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sleep(ms) { Atomics.wait(sleeper, 0, 0, ms); }
|
|
38
|
+
function processAlive(pid) {
|
|
39
|
+
try { process.kill(pid, 0); return true; }
|
|
40
|
+
catch (error) { return error?.code !== "ESRCH"; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function leasePath(statePaths) {
|
|
44
|
+
const authority = statePaths.at(-1);
|
|
45
|
+
if (!authority) throw new Error("cannot acquire ownership mutation lease without a service-state path");
|
|
46
|
+
try { return `${realpathSync.native(authority)}.mutation.lock`; }
|
|
47
|
+
catch {
|
|
48
|
+
try { return join(realpathSync.native(dirname(authority)), `${basename(authority)}.mutation.lock`); }
|
|
49
|
+
catch { return `${authority}.mutation.lock`; }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ownerName(record) {
|
|
54
|
+
return `v1-${record.pid}-${record.processInstance}-${record.token}.json`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseOwnerName(name) {
|
|
58
|
+
const match = /^v1-([1-9][0-9]*)-[0-9a-f-]+-[0-9a-f-]+[.]json$/i.exec(name);
|
|
59
|
+
if (!match) return null;
|
|
60
|
+
const pid = Number(match[1]);
|
|
61
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readOwner(path) {
|
|
65
|
+
try {
|
|
66
|
+
const lock = lstatSync(path);
|
|
67
|
+
if (!lock.isDirectory()) return null;
|
|
68
|
+
const entries = readdirSync(path);
|
|
69
|
+
if (entries.length !== 1) return null;
|
|
70
|
+
const ownerPath = join(path, entries[0]);
|
|
71
|
+
const owner = lstatSync(ownerPath);
|
|
72
|
+
if (!owner.isFile() || owner.size > 4096) return null;
|
|
73
|
+
const record = JSON.parse(readFileSync(ownerPath, "utf8"));
|
|
74
|
+
if (record?.version !== 1 || !Number.isSafeInteger(record.pid) || record.pid <= 0
|
|
75
|
+
|| typeof record.processInstance !== "string" || !record.processInstance
|
|
76
|
+
|| typeof record.token !== "string" || !record.token
|
|
77
|
+
|| !Number.isFinite(record.createdAt) || entries[0] !== ownerName(record)) return null;
|
|
78
|
+
const currentLock = lstatSync(path);
|
|
79
|
+
const currentOwner = lstatSync(ownerPath);
|
|
80
|
+
if (currentLock.dev !== lock.dev || currentLock.ino !== lock.ino
|
|
81
|
+
|| currentOwner.dev !== owner.dev || currentOwner.ino !== owner.ino
|
|
82
|
+
|| currentOwner.size !== owner.size) return null;
|
|
83
|
+
return { path, ownerPath, record, lockDev: lock.dev, lockIno: lock.ino, ownerDev: owner.dev, ownerIno: owner.ino, ownerSize: owner.size, mtimeMs: owner.mtimeMs };
|
|
84
|
+
} catch { return null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sameOwner(left, right) {
|
|
88
|
+
return left.record.token === right.record.token
|
|
89
|
+
&& left.record.pid === right.record.pid
|
|
90
|
+
&& left.record.processInstance === right.record.processInstance
|
|
91
|
+
&& left.lockDev === right.lockDev && left.lockIno === right.lockIno
|
|
92
|
+
&& left.ownerDev === right.ownerDev && left.ownerIno === right.ownerIno
|
|
93
|
+
&& left.ownerSize === right.ownerSize;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readIncompleteOwner(path) {
|
|
97
|
+
try {
|
|
98
|
+
const lock = lstatSync(path);
|
|
99
|
+
if (!lock.isDirectory()) return null;
|
|
100
|
+
const entries = readdirSync(path);
|
|
101
|
+
if (entries.length === 0) return { path, ownerPath: null, pid: null, mtimeMs: lock.mtimeMs };
|
|
102
|
+
if (entries.length !== 1) return null;
|
|
103
|
+
const pid = parseOwnerName(entries[0]);
|
|
104
|
+
if (!pid) return null;
|
|
105
|
+
const ownerPath = join(path, entries[0]);
|
|
106
|
+
const owner = lstatSync(ownerPath);
|
|
107
|
+
return owner.isFile() ? { path, ownerPath, pid, mtimeMs: owner.mtimeMs } : null;
|
|
108
|
+
} catch { return null; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function reclaim(path, now, alive) {
|
|
112
|
+
const observed = readOwner(path);
|
|
113
|
+
const incomplete = observed ? null : readIncompleteOwner(path);
|
|
114
|
+
if (!observed && !incomplete) return false;
|
|
115
|
+
const createdAt = observed ? Math.max(observed.record.createdAt, observed.mtimeMs) : incomplete.mtimeMs;
|
|
116
|
+
const pid = observed?.record.pid ?? incomplete.pid;
|
|
117
|
+
if (now() - createdAt <= STALE_MS || (pid !== null && alive(pid))) return false;
|
|
118
|
+
if (observed) {
|
|
119
|
+
const current = readOwner(path);
|
|
120
|
+
if (!current || !sameOwner(observed, current)) return false;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const ownerPath = observed?.ownerPath ?? incomplete.ownerPath;
|
|
124
|
+
if (ownerPath) unlinkSync(ownerPath);
|
|
125
|
+
rmdirSync(path);
|
|
126
|
+
return true;
|
|
127
|
+
} catch { return false; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function acquireOwnershipMutationLease(
|
|
131
|
+
statePaths,
|
|
132
|
+
options = {},
|
|
133
|
+
) {
|
|
134
|
+
const path = leasePath(statePaths);
|
|
135
|
+
const nested = held.get(path);
|
|
136
|
+
if (nested) {
|
|
137
|
+
nested.depth += 1;
|
|
138
|
+
return { token: nested.snapshot.record.token, release: () => release(path, options) };
|
|
139
|
+
}
|
|
140
|
+
const now = options.now ?? Date.now;
|
|
141
|
+
const alive = options.processAlive ?? processAlive;
|
|
142
|
+
const explicitJoinToken = options.joinToken;
|
|
143
|
+
const envJoinToken = process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV];
|
|
144
|
+
const joinToken = explicitJoinToken ?? envJoinToken ?? delegatedTokens.get(path);
|
|
145
|
+
if (joinToken) {
|
|
146
|
+
const owner = readOwner(path);
|
|
147
|
+
if (owner?.record.token === joinToken && alive(owner.record.pid)) {
|
|
148
|
+
delegatedTokens.set(path, joinToken);
|
|
149
|
+
if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV];
|
|
150
|
+
held.set(path, { depth: 1, snapshot: owner, delegated: true });
|
|
151
|
+
return { token: joinToken, release: () => release(path, options) };
|
|
152
|
+
}
|
|
153
|
+
delegatedTokens.delete(path);
|
|
154
|
+
if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV];
|
|
155
|
+
if (explicitJoinToken) throw new Error("ownership mutation lease delegation is invalid or no longer live");
|
|
156
|
+
}
|
|
157
|
+
const wait = options.waitMs ?? WAIT_MS;
|
|
158
|
+
const deadline = now() + wait;
|
|
159
|
+
if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
160
|
+
for (;;) {
|
|
161
|
+
const record = { version: 1, pid: process.pid, processInstance: PROCESS_INSTANCE, token: randomUUID(), createdAt: now() };
|
|
162
|
+
const ownerPath = join(path, ownerName(record));
|
|
163
|
+
let madeDirectory = false;
|
|
164
|
+
let descriptor = null;
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(path, { mode: 0o700 });
|
|
167
|
+
madeDirectory = true;
|
|
168
|
+
descriptor = openSync(ownerPath, "wx", 0o600);
|
|
169
|
+
writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8");
|
|
170
|
+
fsyncSync(descriptor);
|
|
171
|
+
closeSync(descriptor);
|
|
172
|
+
descriptor = null;
|
|
173
|
+
const snapshot = readOwner(path);
|
|
174
|
+
if (!snapshot || snapshot.record.token !== record.token) throw new Error("ownership mutation lease could not be verified");
|
|
175
|
+
held.set(path, { depth: 1, snapshot, delegated: false });
|
|
176
|
+
return { token: record.token, release: () => release(path, options) };
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (descriptor !== null) { try { closeSync(descriptor); } catch { /* stale recovery owns uncertain cleanup */ } }
|
|
179
|
+
if (madeDirectory) {
|
|
180
|
+
try { unlinkSync(ownerPath); } catch { /* partial owner is recovered after dead-PID proof */ }
|
|
181
|
+
try { rmdirSync(path); } catch { /* owner entry or successor keeps the directory live */ }
|
|
182
|
+
}
|
|
183
|
+
if (error?.code !== "EEXIST") throw error;
|
|
184
|
+
if (reclaim(path, now, alive)) continue;
|
|
185
|
+
if (now() >= deadline) throw new Error(`another process owns the runtime mutation lease at ${path}`);
|
|
186
|
+
(options.sleep ?? sleep)(POLL_MS);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function release(path, options) {
|
|
192
|
+
const currentHeld = held.get(path);
|
|
193
|
+
if (!currentHeld) return;
|
|
194
|
+
currentHeld.depth -= 1;
|
|
195
|
+
if (currentHeld.depth > 0) return;
|
|
196
|
+
held.delete(path);
|
|
197
|
+
if (currentHeld.delegated) return;
|
|
198
|
+
options.beforeRelease?.(path);
|
|
199
|
+
try {
|
|
200
|
+
const current = readOwner(path);
|
|
201
|
+
if (!current || !sameOwner(currentHeld.snapshot, current)) return;
|
|
202
|
+
unlinkSync(currentHeld.snapshot.ownerPath);
|
|
203
|
+
rmdirSync(path);
|
|
204
|
+
} catch { /* token-specific stale recovery handles an uncertain release */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function withOwnershipMutationLease(statePaths, run, options = {}) {
|
|
208
|
+
const lease = acquireOwnershipMutationLease(statePaths, options);
|
|
209
|
+
try { return run(); }
|
|
210
|
+
finally { lease.release(); }
|
|
211
|
+
}
|
package/src/service/repair.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { ServiceDiagnostic } from "./diagnostics";
|
|
|
5
5
|
import { assertServiceEnvironmentMatchesInstall, assertServiceAuthEnvironment } from "./guards";
|
|
6
6
|
import { installLaunchd, restartLaunchdJob } from "./launchd";
|
|
7
7
|
import type { LaunchdInstallOutcome } from "./launchd";
|
|
8
|
-
import { TASK, writeServiceInstallState, serviceSourceDir } from "./state";
|
|
8
|
+
import { TASK, writeServiceInstallState, serviceSourceDir, resolveServiceOwnership } from "./state";
|
|
9
|
+
import type { ServiceOwnership, ServiceOwnershipResolution } from "./state";
|
|
9
10
|
import { installSystemd } from "./systemd";
|
|
10
11
|
import { writeWindowsSchedulerAssets, reregisterWindowsSchedulerTask, windowsSchedulerRegistrationMatchesSnapshot, restoreWindowsSchedulerTaskIfAbsent, startWindows, stopWindows, statusWindowsXml } from "./windows-ops";
|
|
11
12
|
import { probeWindowsSchedulerTask, SCHEDULER_SETTLE_DELAYS_MS, settleDelay } from "./windows-scheduler";
|
|
@@ -35,6 +36,8 @@ export interface RepairServiceDeps {
|
|
|
35
36
|
repairSystemd?: () => void;
|
|
36
37
|
/** Restarts a launchd job the install path deliberately left alone. `restart` only. */
|
|
37
38
|
restartLaunchd?: () => void;
|
|
39
|
+
/** Resolves the recorded runtime owner across every state path. */
|
|
40
|
+
readOwnership?: () => ServiceOwnershipResolution;
|
|
38
41
|
/**
|
|
39
42
|
* Which CLI verb is being served. `repair` must leave a healthy service alone — that no-op
|
|
40
43
|
* IS the #4236 fix — while `restart` promises a new process, so on darwin it kicks the job
|
|
@@ -61,6 +64,37 @@ export interface RepairServiceDeps {
|
|
|
61
64
|
platform?: NodeJS.Platform;
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Why a repair stops when something other than this CLI owns the runtime.
|
|
69
|
+
*
|
|
70
|
+
* The npm service registration is KEPT — the maintainer's decision is that a user's install
|
|
71
|
+
* is never deleted — so the recorded owner is the only thing standing between a takeover the
|
|
72
|
+
* user consented to and the next `ocx service repair`, which would re-enable and restart the
|
|
73
|
+
* npm launcher without mentioning it. Repair runs incidentally: a tray helper, `ocx update`,
|
|
74
|
+
* a doctor suggestion. `install` is the deliberate act, so `install` is the verb that takes
|
|
75
|
+
* ownership back.
|
|
76
|
+
*/
|
|
77
|
+
export function foreignServiceOwnerRefusal(ownership: ServiceOwnership, action = "repair"): string {
|
|
78
|
+
return `Background service ${action} stopped: the desktop app owns the runtime `
|
|
79
|
+
+ `(install ${ownership.installId}, consent generation ${ownership.consentGeneration}).\n`
|
|
80
|
+
+ "The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted.\n"
|
|
81
|
+
+ "Quit the desktop app and run 'ocx service install' to hand the runtime back to this CLI.";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Why an unreadable or contradictory record stops a repair too.
|
|
86
|
+
*
|
|
87
|
+
* Collapsing "I could not read the claim" into "there is no claim" is how a consented
|
|
88
|
+
* takeover gets reactivated by a permissions error. `ocx service install` is deliberately
|
|
89
|
+
* not gated, so this never leaves an operator without a way forward.
|
|
90
|
+
*/
|
|
91
|
+
export function unknownServiceOwnerRefusal(reason: string, action = "repair"): string {
|
|
92
|
+
return `Background service ${action} stopped: ${reason}, so the runtime's recorded owner could `
|
|
93
|
+
+ "not be determined.\n"
|
|
94
|
+
+ "The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted.\n"
|
|
95
|
+
+ "Repair the service-state file or its permissions, then run 'ocx service install' to take the runtime back.";
|
|
96
|
+
}
|
|
97
|
+
|
|
64
98
|
async function assertSchedulerSnapshotBeforeStart(
|
|
65
99
|
readSchedulerXml: () => string,
|
|
66
100
|
expectedXml: string,
|
|
@@ -128,6 +162,16 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
|
|
|
128
162
|
throw new Error("Background service is not installed. Run 'ocx service install' first.");
|
|
129
163
|
}
|
|
130
164
|
|
|
165
|
+
// Before anything is asserted, written, stopped or started. A repair that has already
|
|
166
|
+
// rewritten the assets has changed the thing it was supposed to leave alone.
|
|
167
|
+
const ownership = (deps.readOwnership ?? resolveServiceOwnership)();
|
|
168
|
+
if (ownership.kind === "unknown") throw new Error(unknownServiceOwnerRefusal(ownership.reason));
|
|
169
|
+
// `!== "cli"` rather than `=== "desktop"`: this is the CLI's own repair path, so any owner
|
|
170
|
+
// that is not this CLI is one whose runtime it must not touch.
|
|
171
|
+
if (ownership.kind === "owned" && ownership.ownership.owner !== "cli") {
|
|
172
|
+
throw new Error(foreignServiceOwnerRefusal(ownership.ownership));
|
|
173
|
+
}
|
|
174
|
+
|
|
131
175
|
(deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)();
|
|
132
176
|
(deps.assertAuth ?? assertServiceAuthEnvironment)();
|
|
133
177
|
|