@bitkyc08/opencodex 2.60.0 → 2.61.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 +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.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 +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- 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.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- 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 +10 -0
- 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/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 +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -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-listener.ts +2 -5
- 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/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- 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/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- 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/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- 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 +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- 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/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- 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/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -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/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/schema.ts +3 -0
- package/src/router.ts +17 -2
- 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 +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- 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 +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- 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/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- 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 +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- 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 +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- 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 +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -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 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -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/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
package/src/service/cli.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { resolveServiceListenPort, reportServiceServing } from "./health";
|
|
|
13
13
|
import { platformOps, proxyStillLiveAfterStop, stopTrackedProxyForServiceCommand, installServiceSafely, installFreshWindowsSchedulerSafely, removeServiceInstallState, isServiceInstalled } from "./orchestration";
|
|
14
14
|
import { repairService } from "./repair";
|
|
15
15
|
import type { ServiceRepairVerb } from "./repair";
|
|
16
|
-
import { TASK, plistPath, readServiceBackend } from "./state";
|
|
16
|
+
import { TASK, plistPath, readServiceBackend, releaseServiceOwner, resolveServiceOwnership } from "./state";
|
|
17
|
+
import { foreignServiceOwnerRefusal, unknownServiceOwnerRefusal } from "./repair";
|
|
17
18
|
import type { ServiceBackend } from "./state";
|
|
18
19
|
import { unitPath } from "./systemd";
|
|
19
20
|
import { inspectWindowsSchedulerServiceStatus, schtasksErrorDetail, probeWindowsSchedulerTask } from "./windows-scheduler";
|
|
@@ -233,6 +234,15 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
233
234
|
case "install":
|
|
234
235
|
assertServiceEnvironmentMatchesInstall();
|
|
235
236
|
assertServiceAuthEnvironment();
|
|
237
|
+
// The install may advance provenance, but it may take back only the owner it observed
|
|
238
|
+
// before touching the registration. A desktop successor that claims ownership while
|
|
239
|
+
// the install is running must survive the late release below.
|
|
240
|
+
const ownershipBeforeInstall = resolveServiceOwnership();
|
|
241
|
+
if (ownershipBeforeInstall.kind === "unknown") {
|
|
242
|
+
console.error(`❌ ${unknownServiceOwnerRefusal(ownershipBeforeInstall.reason, "install")}`);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
236
246
|
// A manually started proxy can still own the configured port while the service
|
|
237
247
|
// registration is absent or unloaded. Stop both the registered manager and any
|
|
238
248
|
// tracked standalone listener before loading the freshly written service assets.
|
|
@@ -258,6 +268,23 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
258
268
|
process.exitCode = 1;
|
|
259
269
|
break;
|
|
260
270
|
}
|
|
271
|
+
// ONLY now. `install` is the verb that takes the runtime back, and it does so after the
|
|
272
|
+
// registration exists — releasing first meant a cancelled UAC prompt, a failed
|
|
273
|
+
// registration or an aborted cleanup left the retained npm registration looking
|
|
274
|
+
// CLI-owned, so the next incidental repair would reactivate it.
|
|
275
|
+
//
|
|
276
|
+
// `repair` and `restart` refuse under a foreign owner precisely because they run
|
|
277
|
+
// incidentally — from a tray helper, from `ocx update`, from a doctor suggestion — and
|
|
278
|
+
// undoing a takeover the user consented to must be something the user asked for.
|
|
279
|
+
{
|
|
280
|
+
const released = releaseServiceOwner(ownershipBeforeInstall, { allowRevisionAdvance: true });
|
|
281
|
+
if (released) {
|
|
282
|
+
console.log(
|
|
283
|
+
`ℹ️ The desktop app owned the background runtime (install ${released.installId}, `
|
|
284
|
+
+ `consent generation ${released.consentGeneration}); this install took it back.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
261
288
|
// The wrapper was written moments ago in this process, so the configured port
|
|
262
289
|
// and the baked one cannot have diverged yet — unlike `start`, which reads the
|
|
263
290
|
// installed artifact instead.
|
|
@@ -269,15 +296,34 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
269
296
|
// Same one-time marker and same guards (TTY, gh auth, agent deferral) apply.
|
|
270
297
|
await maybeShowStarPrompt();
|
|
271
298
|
break;
|
|
272
|
-
case "start":
|
|
299
|
+
case "start": {
|
|
273
300
|
// The installed launcher preserves the recorded CODEX_SQLITE_HOME: a
|
|
274
301
|
// changed sqlite_home/CODEX_SQLITE_HOME/CODEX_HOME would start the service
|
|
275
302
|
// on the recorded database while this shell resolves another, splitting
|
|
276
303
|
// native Codex history between databases. Same guard `stop` already runs.
|
|
277
304
|
assertServiceEnvironmentMatchesInstall();
|
|
305
|
+
// `start` activates the npm registration, so it refuses on the same terms repair does.
|
|
306
|
+
// The Windows tray starts the service automatically, which would otherwise put a second
|
|
307
|
+
// proxy beside the one the desktop app is running without anyone asking for it.
|
|
308
|
+
// `stop` and `uninstall` are deliberately NOT gated: they deactivate.
|
|
309
|
+
//
|
|
310
|
+
// Reported rather than thrown: the tray drives this through `runTrayProxyStart`, which
|
|
311
|
+
// does not catch, and a refusal is a decision rather than a crash.
|
|
312
|
+
const ownership = resolveServiceOwnership();
|
|
313
|
+
const refusal = ownership.kind === "unknown"
|
|
314
|
+
? unknownServiceOwnerRefusal(ownership.reason, "start")
|
|
315
|
+
: ownership.kind === "owned" && ownership.ownership.owner !== "cli"
|
|
316
|
+
? foreignServiceOwnerRefusal(ownership.ownership, "start")
|
|
317
|
+
: null;
|
|
318
|
+
if (refusal) {
|
|
319
|
+
console.error(`❌ ${refusal}`);
|
|
320
|
+
process.exitCode = 1;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
278
323
|
ops.start();
|
|
279
324
|
await reportServiceServing("started");
|
|
280
325
|
break;
|
|
326
|
+
}
|
|
281
327
|
case "stop": {
|
|
282
328
|
assertServiceEnvironmentMatchesInstall();
|
|
283
329
|
// Only stop what is actually installed. The unguarded call ran a real `launchctl unload`
|
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
|
+
}
|