oh-my-codex 0.20.3 → 0.20.5
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/Cargo.lock +35 -18
- package/Cargo.toml +1 -1
- package/README.md +2 -2
- package/crates/omx-runtime/Cargo.toml +3 -0
- package/crates/omx-runtime/src/main.rs +390 -0
- package/crates/omx-runtime/tests/execution.rs +264 -0
- package/dist/adapt/__tests__/herdr-target.test.d.ts +2 -0
- package/dist/adapt/__tests__/herdr-target.test.d.ts.map +1 -0
- package/dist/adapt/__tests__/herdr-target.test.js +72 -0
- package/dist/adapt/__tests__/herdr-target.test.js.map +1 -0
- package/dist/adapt/contracts.d.ts +1 -1
- package/dist/adapt/contracts.d.ts.map +1 -1
- package/dist/adapt/contracts.js +1 -1
- package/dist/adapt/contracts.js.map +1 -1
- package/dist/adapt/herdr/__tests__/authority.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/authority.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/authority.test.js +49 -0
- package/dist/adapt/herdr/__tests__/authority.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/bridge.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/bridge.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/bridge.test.js +176 -0
- package/dist/adapt/herdr/__tests__/bridge.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/sanitize.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/sanitize.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/sanitize.test.js +45 -0
- package/dist/adapt/herdr/__tests__/sanitize.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/semantic.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/semantic.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/semantic.test.js +130 -0
- package/dist/adapt/herdr/__tests__/semantic.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/seq-store.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/seq-store.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/seq-store.test.js +54 -0
- package/dist/adapt/herdr/__tests__/seq-store.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/socket-live.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/socket-live.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/socket-live.test.js +104 -0
- package/dist/adapt/herdr/__tests__/socket-live.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/team-map.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/team-map.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/team-map.test.js +46 -0
- package/dist/adapt/herdr/__tests__/team-map.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/transport.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/transport.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/transport.test.js +160 -0
- package/dist/adapt/herdr/__tests__/transport.test.js.map +1 -0
- package/dist/adapt/herdr/__tests__/wiring.test.d.ts +2 -0
- package/dist/adapt/herdr/__tests__/wiring.test.d.ts.map +1 -0
- package/dist/adapt/herdr/__tests__/wiring.test.js +118 -0
- package/dist/adapt/herdr/__tests__/wiring.test.js.map +1 -0
- package/dist/adapt/herdr/authority.d.ts +34 -0
- package/dist/adapt/herdr/authority.d.ts.map +1 -0
- package/dist/adapt/herdr/authority.js +71 -0
- package/dist/adapt/herdr/authority.js.map +1 -0
- package/dist/adapt/herdr/bridge.d.ts +84 -0
- package/dist/adapt/herdr/bridge.d.ts.map +1 -0
- package/dist/adapt/herdr/bridge.js +222 -0
- package/dist/adapt/herdr/bridge.js.map +1 -0
- package/dist/adapt/herdr/sanitize.d.ts +20 -0
- package/dist/adapt/herdr/sanitize.d.ts.map +1 -0
- package/dist/adapt/herdr/sanitize.js +70 -0
- package/dist/adapt/herdr/sanitize.js.map +1 -0
- package/dist/adapt/herdr/semantic.d.ts +67 -0
- package/dist/adapt/herdr/semantic.d.ts.map +1 -0
- package/dist/adapt/herdr/semantic.js +170 -0
- package/dist/adapt/herdr/semantic.js.map +1 -0
- package/dist/adapt/herdr/seq-store.d.ts +32 -0
- package/dist/adapt/herdr/seq-store.d.ts.map +1 -0
- package/dist/adapt/herdr/seq-store.js +126 -0
- package/dist/adapt/herdr/seq-store.js.map +1 -0
- package/dist/adapt/herdr/team-map.d.ts +30 -0
- package/dist/adapt/herdr/team-map.d.ts.map +1 -0
- package/dist/adapt/herdr/team-map.js +37 -0
- package/dist/adapt/herdr/team-map.js.map +1 -0
- package/dist/adapt/herdr/transport.d.ts +115 -0
- package/dist/adapt/herdr/transport.d.ts.map +1 -0
- package/dist/adapt/herdr/transport.js +260 -0
- package/dist/adapt/herdr/transport.js.map +1 -0
- package/dist/adapt/herdr/wiring.d.ts +24 -0
- package/dist/adapt/herdr/wiring.d.ts.map +1 -0
- package/dist/adapt/herdr/wiring.js +62 -0
- package/dist/adapt/herdr/wiring.js.map +1 -0
- package/dist/adapt/herdr.d.ts +19 -0
- package/dist/adapt/herdr.d.ts.map +1 -0
- package/dist/adapt/herdr.js +115 -0
- package/dist/adapt/herdr.js.map +1 -0
- package/dist/adapt/index.d.ts +4 -0
- package/dist/adapt/index.d.ts.map +1 -1
- package/dist/adapt/index.js +48 -0
- package/dist/adapt/index.js.map +1 -1
- package/dist/adapt/registry.d.ts.map +1 -1
- package/dist/adapt/registry.js +11 -0
- package/dist/adapt/registry.js.map +1 -1
- package/dist/auth/__tests__/hotswap.test.js +60 -1
- package/dist/auth/__tests__/hotswap.test.js.map +1 -1
- package/dist/auth/__tests__/storage.test.js +33 -1
- package/dist/auth/__tests__/storage.test.js.map +1 -1
- package/dist/auth/hotswap.d.ts +16 -2
- package/dist/auth/hotswap.d.ts.map +1 -1
- package/dist/auth/hotswap.js +22 -7
- package/dist/auth/hotswap.js.map +1 -1
- package/dist/auth/storage.js +1 -1
- package/dist/auth/storage.js.map +1 -1
- package/dist/autopilot/__tests__/ralplan-gate.test.js +86 -1333
- package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
- package/dist/autopilot/ralplan-gate.d.ts.map +1 -1
- package/dist/autopilot/ralplan-gate.js +7 -6
- package/dist/autopilot/ralplan-gate.js.map +1 -1
- package/dist/cli/__tests__/agents-init.test.js +4 -23
- package/dist/cli/__tests__/agents-init.test.js.map +1 -1
- package/dist/cli/__tests__/api.test.js +61 -24
- package/dist/cli/__tests__/api.test.js.map +1 -1
- package/dist/cli/__tests__/auth.test.js +3 -1
- package/dist/cli/__tests__/auth.test.js.map +1 -1
- package/dist/cli/__tests__/codex-feature-probe.test.js +66 -1
- package/dist/cli/__tests__/codex-feature-probe.test.js.map +1 -1
- package/dist/cli/__tests__/codex-plugin-layout.test.js +70 -2
- package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
- package/dist/cli/__tests__/detached-leader-teardown.test.d.ts +2 -0
- package/dist/cli/__tests__/detached-leader-teardown.test.d.ts.map +1 -0
- package/dist/cli/__tests__/detached-leader-teardown.test.js +473 -0
- package/dist/cli/__tests__/detached-leader-teardown.test.js.map +1 -0
- package/dist/cli/__tests__/doctor-process-identity-readiness.test.d.ts +2 -0
- package/dist/cli/__tests__/doctor-process-identity-readiness.test.d.ts.map +1 -0
- package/dist/cli/__tests__/doctor-process-identity-readiness.test.js +103 -0
- package/dist/cli/__tests__/doctor-process-identity-readiness.test.js.map +1 -0
- package/dist/cli/__tests__/doctor-state-root-binding.test.d.ts +2 -0
- package/dist/cli/__tests__/doctor-state-root-binding.test.d.ts.map +1 -0
- package/dist/cli/__tests__/doctor-state-root-binding.test.js +139 -0
- package/dist/cli/__tests__/doctor-state-root-binding.test.js.map +1 -0
- package/dist/cli/__tests__/doctor-warning-copy.test.js +35 -3
- package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
- package/dist/cli/__tests__/exec.test.js +75 -0
- package/dist/cli/__tests__/exec.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +621 -460
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/launch-fallback.test.js +1020 -143
- package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
- package/dist/cli/__tests__/mcp-parity.test.js +4 -3
- package/dist/cli/__tests__/mcp-parity.test.js.map +1 -1
- package/dist/cli/__tests__/native-assets.test.js +398 -16
- package/dist/cli/__tests__/native-assets.test.js.map +1 -1
- package/dist/cli/__tests__/native-hook-claim-journal.test.js +53 -5
- package/dist/cli/__tests__/native-hook-claim-journal.test.js.map +1 -1
- package/dist/cli/__tests__/ralph.test.js +2 -2
- package/dist/cli/__tests__/ralph.test.js.map +1 -1
- package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js +57 -57
- package/dist/cli/__tests__/ralplan-bootstrap-3181.test.js.map +1 -1
- package/dist/cli/__tests__/ralplan-child-binding-3212.test.d.ts +2 -0
- package/dist/cli/__tests__/ralplan-child-binding-3212.test.d.ts.map +1 -0
- package/dist/cli/__tests__/ralplan-child-binding-3212.test.js +56 -0
- package/dist/cli/__tests__/ralplan-child-binding-3212.test.js.map +1 -0
- package/dist/cli/__tests__/ralplan.test.js +84 -12
- package/dist/cli/__tests__/ralplan.test.js.map +1 -1
- package/dist/cli/__tests__/session-scoped-runtime.test.js +1118 -43
- package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
- package/dist/cli/__tests__/setup-agents-overwrite.test.js +8 -55
- package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
- package/dist/cli/__tests__/setup-install-mode.test.js +183 -4
- package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
- package/dist/cli/__tests__/sparkshell-cli.test.js +93 -41
- package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
- package/dist/cli/__tests__/team.test.js +45 -1
- package/dist/cli/__tests__/team.test.js.map +1 -1
- package/dist/cli/__tests__/ultragoal.test.js +117 -2
- package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
- package/dist/cli/__tests__/uninstall.test.js +50 -5
- package/dist/cli/__tests__/uninstall.test.js.map +1 -1
- package/dist/cli/__tests__/update.test.js +669 -177
- package/dist/cli/__tests__/update.test.js.map +1 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js +32 -2
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
- package/dist/cli/api.d.ts.map +1 -1
- package/dist/cli/api.js +9 -10
- package/dist/cli/api.js.map +1 -1
- package/dist/cli/codex-feature-probe.d.ts +16 -0
- package/dist/cli/codex-feature-probe.d.ts.map +1 -1
- package/dist/cli/codex-feature-probe.js +44 -0
- package/dist/cli/codex-feature-probe.js.map +1 -1
- package/dist/cli/codex-home.d.ts.map +1 -1
- package/dist/cli/codex-home.js +7 -4
- package/dist/cli/codex-home.js.map +1 -1
- package/dist/cli/doctor.d.ts +17 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +277 -3
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +231 -12
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2298 -735
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/native-assets.d.ts +25 -0
- package/dist/cli/native-assets.d.ts.map +1 -1
- package/dist/cli/native-assets.js +448 -51
- package/dist/cli/native-assets.js.map +1 -1
- package/dist/cli/native-hook-claim-journal.d.ts +8 -4
- package/dist/cli/native-hook-claim-journal.d.ts.map +1 -1
- package/dist/cli/native-hook-claim-journal.js +30 -8
- package/dist/cli/native-hook-claim-journal.js.map +1 -1
- package/dist/cli/package-manager-ownership.d.ts +63 -0
- package/dist/cli/package-manager-ownership.d.ts.map +1 -0
- package/dist/cli/package-manager-ownership.js +323 -0
- package/dist/cli/package-manager-ownership.js.map +1 -0
- package/dist/cli/ralph.js +2 -2
- package/dist/cli/ralph.js.map +1 -1
- package/dist/cli/ralplan.d.ts +4 -2
- package/dist/cli/ralplan.d.ts.map +1 -1
- package/dist/cli/ralplan.js +76 -34
- package/dist/cli/ralplan.js.map +1 -1
- package/dist/cli/setup-preferences.d.ts +15 -0
- package/dist/cli/setup-preferences.d.ts.map +1 -1
- package/dist/cli/setup-preferences.js +27 -1
- package/dist/cli/setup-preferences.js.map +1 -1
- package/dist/cli/setup.d.ts +3 -0
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +55 -32
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/sparkshell.d.ts.map +1 -1
- package/dist/cli/sparkshell.js +52 -9
- package/dist/cli/sparkshell.js.map +1 -1
- package/dist/cli/ultragoal.d.ts.map +1 -1
- package/dist/cli/ultragoal.js +2 -2
- package/dist/cli/ultragoal.js.map +1 -1
- package/dist/cli/uninstall.d.ts.map +1 -1
- package/dist/cli/uninstall.js +27 -28
- package/dist/cli/uninstall.js.map +1 -1
- package/dist/cli/update-worker.d.ts +2 -0
- package/dist/cli/update-worker.d.ts.map +1 -0
- package/dist/cli/update-worker.js +142 -0
- package/dist/cli/update-worker.js.map +1 -0
- package/dist/cli/update.d.ts +14 -22
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +192 -241
- package/dist/cli/update.js.map +1 -1
- package/dist/config/__tests__/generator-idempotent.test.js +173 -29
- package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
- package/dist/config/__tests__/generator-notify.test.js +3 -1
- package/dist/config/__tests__/generator-notify.test.js.map +1 -1
- package/dist/config/generator.d.ts +25 -3
- package/dist/config/generator.d.ts.map +1 -1
- package/dist/config/generator.js +113 -35
- package/dist/config/generator.js.map +1 -1
- package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js +25 -0
- package/dist/goal-workflows/__tests__/codex-goal-snapshot.test.js.map +1 -1
- package/dist/goal-workflows/codex-goal-snapshot.d.ts +1 -1
- package/dist/goal-workflows/codex-goal-snapshot.d.ts.map +1 -1
- package/dist/goal-workflows/codex-goal-snapshot.js +2 -0
- package/dist/goal-workflows/codex-goal-snapshot.js.map +1 -1
- package/dist/hooks/__tests__/agents-overlay.test.js +3 -1
- package/dist/hooks/__tests__/agents-overlay.test.js.map +1 -1
- package/dist/hooks/__tests__/autopilot-skill-contract.test.js +27 -4
- package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
- package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.d.ts +2 -0
- package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.js +172 -0
- package/dist/hooks/__tests__/issue-3293-autopilot-handoff.test.js.map +1 -0
- package/dist/hooks/__tests__/keyword-detector.test.js +172 -33
- package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js +173 -11
- package/dist/hooks/__tests__/notify-fallback-watcher.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js +1 -0
- package/dist/hooks/__tests__/notify-hook-all-workers-idle.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js +65 -17
- package/dist/hooks/__tests__/notify-hook-auto-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js +27 -0
- package/dist/hooks/__tests__/notify-hook-cross-worktree-heartbeat.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js +10 -118
- package/dist/hooks/__tests__/notify-hook-managed-tmux.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-regression-205.test.js +7 -0
- package/dist/hooks/__tests__/notify-hook-regression-205.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js +2 -2
- package/dist/hooks/__tests__/notify-hook-team-dispatch.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +4 -1
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js +2 -1
- package/dist/hooks/__tests__/notify-hook-team-worker-fail-closed.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-worker-idle.test.js +1 -0
- package/dist/hooks/__tests__/notify-hook-worker-idle.test.js.map +1 -1
- package/dist/hooks/__tests__/prompt-guidance-contract.test.js +9 -0
- package/dist/hooks/__tests__/prompt-guidance-contract.test.js.map +1 -1
- package/dist/hooks/__tests__/session.test.js +2976 -557
- package/dist/hooks/__tests__/session.test.js.map +1 -1
- package/dist/hooks/agents-overlay.js +1 -1
- package/dist/hooks/agents-overlay.js.map +1 -1
- package/dist/hooks/extensibility/__tests__/dispatcher.test.js +108 -2
- package/dist/hooks/extensibility/__tests__/dispatcher.test.js.map +1 -1
- package/dist/hooks/extensibility/__tests__/sdk.test.js +151 -1
- package/dist/hooks/extensibility/__tests__/sdk.test.js.map +1 -1
- package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -1
- package/dist/hooks/extensibility/dispatcher.js +20 -3
- package/dist/hooks/extensibility/dispatcher.js.map +1 -1
- package/dist/hooks/extensibility/plugin-runner.js +1 -0
- package/dist/hooks/extensibility/plugin-runner.js.map +1 -1
- package/dist/hooks/extensibility/runtime.d.ts.map +1 -1
- package/dist/hooks/extensibility/runtime.js.map +1 -1
- package/dist/hooks/extensibility/sdk/paths.d.ts +1 -1
- package/dist/hooks/extensibility/sdk/paths.d.ts.map +1 -1
- package/dist/hooks/extensibility/sdk/paths.js +2 -2
- package/dist/hooks/extensibility/sdk/paths.js.map +1 -1
- package/dist/hooks/extensibility/sdk/runtime-state.d.ts +1 -1
- package/dist/hooks/extensibility/sdk/runtime-state.d.ts.map +1 -1
- package/dist/hooks/extensibility/sdk/runtime-state.js +55 -15
- package/dist/hooks/extensibility/sdk/runtime-state.js.map +1 -1
- package/dist/hooks/extensibility/sdk.d.ts +1 -0
- package/dist/hooks/extensibility/sdk.d.ts.map +1 -1
- package/dist/hooks/extensibility/sdk.js +1 -1
- package/dist/hooks/extensibility/sdk.js.map +1 -1
- package/dist/hooks/extensibility/types.d.ts +7 -0
- package/dist/hooks/extensibility/types.d.ts.map +1 -1
- package/dist/hooks/keyword-detector.d.ts +10 -3
- package/dist/hooks/keyword-detector.d.ts.map +1 -1
- package/dist/hooks/keyword-detector.js +116 -20
- package/dist/hooks/keyword-detector.js.map +1 -1
- package/dist/hooks/session.d.ts +180 -14
- package/dist/hooks/session.d.ts.map +1 -1
- package/dist/hooks/session.js +1402 -281
- package/dist/hooks/session.js.map +1 -1
- package/dist/hud/__tests__/hud-tmux-injection.test.js +6 -6
- package/dist/hud/__tests__/hud-tmux-injection.test.js.map +1 -1
- package/dist/hud/__tests__/index.test.js +26 -16
- package/dist/hud/__tests__/index.test.js.map +1 -1
- package/dist/hud/__tests__/reconcile.test.js +83 -78
- package/dist/hud/__tests__/reconcile.test.js.map +1 -1
- package/dist/hud/__tests__/state.test.js +36 -31
- package/dist/hud/__tests__/state.test.js.map +1 -1
- package/dist/hud/__tests__/tmux.test.js +155 -198
- package/dist/hud/__tests__/tmux.test.js.map +1 -1
- package/dist/hud/tmux.d.ts +42 -4
- package/dist/hud/tmux.d.ts.map +1 -1
- package/dist/hud/tmux.js +1017 -139
- package/dist/hud/tmux.js.map +1 -1
- package/dist/leader/__tests__/contract.test.js +71 -34
- package/dist/leader/__tests__/contract.test.js.map +1 -1
- package/dist/leader/contract.d.ts +1 -6
- package/dist/leader/contract.d.ts.map +1 -1
- package/dist/leader/contract.js +25 -24
- package/dist/leader/contract.js.map +1 -1
- package/dist/mcp/__tests__/hermes-bridge.test.js +17 -0
- package/dist/mcp/__tests__/hermes-bridge.test.js.map +1 -1
- package/dist/mcp/__tests__/server-lifecycle.test.js +4 -4
- package/dist/mcp/__tests__/state-paths.test.js +735 -4
- package/dist/mcp/__tests__/state-paths.test.js.map +1 -1
- package/dist/mcp/__tests__/state-server.test.js +1 -1
- package/dist/mcp/__tests__/state-server.test.js.map +1 -1
- package/dist/mcp/__tests__/trace-server.test.js +2 -2
- package/dist/mcp/__tests__/trace-server.test.js.map +1 -1
- package/dist/mcp/hermes-bridge.d.ts.map +1 -1
- package/dist/mcp/hermes-bridge.js +2 -2
- package/dist/mcp/hermes-bridge.js.map +1 -1
- package/dist/mcp/state-paths.d.ts +78 -4
- package/dist/mcp/state-paths.d.ts.map +1 -1
- package/dist/mcp/state-paths.js +543 -40
- package/dist/mcp/state-paths.js.map +1 -1
- package/dist/mcp/state-server.d.ts +4 -4
- package/dist/mcp/state-server.d.ts.map +1 -1
- package/dist/modes/__tests__/base-autopilot-gates.test.js +36 -7
- package/dist/modes/__tests__/base-autopilot-gates.test.js.map +1 -1
- package/dist/modes/__tests__/base-multi-state-compat.test.js +1 -1
- package/dist/modes/__tests__/base-multi-state-compat.test.js.map +1 -1
- package/dist/modes/__tests__/base-session-scope.test.js +17 -8
- package/dist/modes/__tests__/base-session-scope.test.js.map +1 -1
- package/dist/modes/base.d.ts +5 -5
- package/dist/modes/base.d.ts.map +1 -1
- package/dist/modes/base.js +76 -21
- package/dist/modes/base.js.map +1 -1
- package/dist/native-assets/archive.d.ts +17 -0
- package/dist/native-assets/archive.d.ts.map +1 -0
- package/dist/native-assets/archive.js +238 -0
- package/dist/native-assets/archive.js.map +1 -0
- package/dist/native-assets/policy.d.ts +42 -0
- package/dist/native-assets/policy.d.ts.map +1 -0
- package/dist/native-assets/policy.js +155 -0
- package/dist/native-assets/policy.js.map +1 -0
- package/dist/pipeline/__tests__/orchestrator.test.js +162 -40
- package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
- package/dist/pipeline/__tests__/stages.test.js +66 -149
- package/dist/pipeline/__tests__/stages.test.js.map +1 -1
- package/dist/pipeline/orchestrator.d.ts.map +1 -1
- package/dist/pipeline/orchestrator.js +124 -18
- package/dist/pipeline/orchestrator.js.map +1 -1
- package/dist/pipeline/stages/ralplan.d.ts.map +1 -1
- package/dist/pipeline/stages/ralplan.js +36 -8
- package/dist/pipeline/stages/ralplan.js.map +1 -1
- package/dist/ralplan/__tests__/consensus-gate.test.js +70 -1437
- package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -1
- package/dist/ralplan/__tests__/documented-leader-preflight.test.js +328 -39
- package/dist/ralplan/__tests__/documented-leader-preflight.test.js.map +1 -1
- package/dist/ralplan/__tests__/runtime.test.js +82 -102
- package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
- package/dist/ralplan/consensus-gate.d.ts +9 -1
- package/dist/ralplan/consensus-gate.d.ts.map +1 -1
- package/dist/ralplan/consensus-gate.js +40 -304
- package/dist/ralplan/consensus-gate.js.map +1 -1
- package/dist/ralplan/documented-leader-preflight.d.ts +15 -8
- package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -1
- package/dist/ralplan/documented-leader-preflight.js +356 -54
- package/dist/ralplan/documented-leader-preflight.js.map +1 -1
- package/dist/ralplan/runtime.d.ts +1 -1
- package/dist/ralplan/runtime.d.ts.map +1 -1
- package/dist/ralplan/runtime.js +68 -173
- package/dist/ralplan/runtime.js.map +1 -1
- package/dist/runtime/run-state.d.ts +5 -1
- package/dist/runtime/run-state.d.ts.map +1 -1
- package/dist/runtime/run-state.js +15 -8
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/scripts/__tests__/codex-native-hook.test.js +14071 -8859
- package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
- package/dist/scripts/__tests__/issue-3293-callsite-parity.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3293-callsite-parity.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3293-callsite-parity.test.js +225 -0
- package/dist/scripts/__tests__/issue-3293-callsite-parity.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.js +188 -0
- package/dist/scripts/__tests__/issue-3293-cancel-transaction.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.js +259 -0
- package/dist/scripts/__tests__/issue-3293-hook-owned-cancel.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.js +491 -0
- package/dist/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.js +708 -0
- package/dist/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.js.map +1 -0
- package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.d.ts +2 -0
- package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.js +208 -0
- package/dist/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.js.map +1 -0
- package/dist/scripts/__tests__/notify-state-io.test.js +26 -0
- package/dist/scripts/__tests__/notify-state-io.test.js.map +1 -1
- package/dist/scripts/__tests__/notify-tmux-injection.test.js +23 -1
- package/dist/scripts/__tests__/notify-tmux-injection.test.js.map +1 -1
- package/dist/scripts/__tests__/postinstall.test.js +19 -1
- package/dist/scripts/__tests__/postinstall.test.js.map +1 -1
- package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js +25 -8
- package/dist/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.js.map +1 -1
- package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js +60 -25
- package/dist/scripts/__tests__/role-intent-durable-recovery-3181.test.js.map +1 -1
- package/dist/scripts/__tests__/smoke-packed-install.test.js +155 -16
- package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
- package/dist/scripts/__tests__/verify-native-release-assets.test.d.ts +2 -0
- package/dist/scripts/__tests__/verify-native-release-assets.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/verify-native-release-assets.test.js +235 -0
- package/dist/scripts/__tests__/verify-native-release-assets.test.js.map +1 -0
- package/dist/scripts/codex-native-hook.d.ts +50 -1
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +3559 -451
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/codex-native-pre-post.d.ts +9 -0
- package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
- package/dist/scripts/codex-native-pre-post.js +35 -12
- package/dist/scripts/codex-native-pre-post.js.map +1 -1
- package/dist/scripts/generate-native-release-manifest.js +80 -51
- package/dist/scripts/generate-native-release-manifest.js.map +1 -1
- package/dist/scripts/notify-fallback-watcher.js +28 -39
- package/dist/scripts/notify-fallback-watcher.js.map +1 -1
- package/dist/scripts/notify-hook/state-io.d.ts.map +1 -1
- package/dist/scripts/notify-hook/state-io.js +5 -0
- package/dist/scripts/notify-hook/state-io.js.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.js +32 -3
- package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
- package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-worker-stop.js +23 -3
- package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
- package/dist/scripts/notify-hook/team-worker.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-worker.js +45 -5
- package/dist/scripts/notify-hook/team-worker.js.map +1 -1
- package/dist/scripts/notify-hook/tmux-injection.d.ts +1 -0
- package/dist/scripts/notify-hook/tmux-injection.d.ts.map +1 -1
- package/dist/scripts/notify-hook/tmux-injection.js +4 -1
- package/dist/scripts/notify-hook/tmux-injection.js.map +1 -1
- package/dist/scripts/notify-hook.js +78 -82
- package/dist/scripts/notify-hook.js.map +1 -1
- package/dist/scripts/postinstall-advisory.d.ts +14 -0
- package/dist/scripts/postinstall-advisory.d.ts.map +1 -0
- package/dist/scripts/postinstall-advisory.js +49 -0
- package/dist/scripts/postinstall-advisory.js.map +1 -0
- package/dist/scripts/postinstall.d.ts +1 -1
- package/dist/scripts/postinstall.d.ts.map +1 -1
- package/dist/scripts/postinstall.js +1 -1
- package/dist/scripts/postinstall.js.map +1 -1
- package/dist/scripts/smoke-packed-install.d.ts +32 -1
- package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
- package/dist/scripts/smoke-packed-install.js +413 -60
- package/dist/scripts/smoke-packed-install.js.map +1 -1
- package/dist/scripts/sync-plugin-mirror.d.ts.map +1 -1
- package/dist/scripts/sync-plugin-mirror.js +16 -1
- package/dist/scripts/sync-plugin-mirror.js.map +1 -1
- package/dist/scripts/verify-native-release-assets.js +37 -23
- package/dist/scripts/verify-native-release-assets.js.map +1 -1
- package/dist/state/__tests__/mode-state-context.test.js +12 -5
- package/dist/state/__tests__/mode-state-context.test.js.map +1 -1
- package/dist/state/__tests__/operations.test.js +460 -850
- package/dist/state/__tests__/operations.test.js.map +1 -1
- package/dist/state/__tests__/skill-active.test.js +400 -2
- package/dist/state/__tests__/skill-active.test.js.map +1 -1
- package/dist/state/mode-state-context.d.ts +3 -0
- package/dist/state/mode-state-context.d.ts.map +1 -1
- package/dist/state/mode-state-context.js +27 -13
- package/dist/state/mode-state-context.js.map +1 -1
- package/dist/state/operations.d.ts +3 -0
- package/dist/state/operations.d.ts.map +1 -1
- package/dist/state/operations.js +144 -40
- package/dist/state/operations.js.map +1 -1
- package/dist/state/skill-active.d.ts +18 -1
- package/dist/state/skill-active.d.ts.map +1 -1
- package/dist/state/skill-active.js +420 -23
- package/dist/state/skill-active.js.map +1 -1
- package/dist/state/workflow-transition-reconcile.d.ts +3 -0
- package/dist/state/workflow-transition-reconcile.d.ts.map +1 -1
- package/dist/state/workflow-transition-reconcile.js +10 -4
- package/dist/state/workflow-transition-reconcile.js.map +1 -1
- package/dist/state/workflow-transition.d.ts.map +1 -1
- package/dist/state/workflow-transition.js +3 -1
- package/dist/state/workflow-transition.js.map +1 -1
- package/dist/subagents/__tests__/leader-bootstrap-3181.test.js +27 -0
- package/dist/subagents/__tests__/leader-bootstrap-3181.test.js.map +1 -1
- package/dist/subagents/__tests__/tracker.test.js +837 -1048
- package/dist/subagents/__tests__/tracker.test.js.map +1 -1
- package/dist/subagents/tracker.d.ts +64 -60
- package/dist/subagents/tracker.d.ts.map +1 -1
- package/dist/subagents/tracker.js +809 -358
- package/dist/subagents/tracker.js.map +1 -1
- package/dist/team/__tests__/delivery-e2e-smoke.test.js +6 -7
- package/dist/team/__tests__/delivery-e2e-smoke.test.js.map +1 -1
- package/dist/team/__tests__/mcp-comm.test.js +104 -3
- package/dist/team/__tests__/mcp-comm.test.js.map +1 -1
- package/dist/team/__tests__/notice-ledger.test.d.ts +2 -0
- package/dist/team/__tests__/notice-ledger.test.d.ts.map +1 -0
- package/dist/team/__tests__/notice-ledger.test.js +131 -0
- package/dist/team/__tests__/notice-ledger.test.js.map +1 -0
- package/dist/team/__tests__/pane-teardown-residue-e2e.test.d.ts +2 -0
- package/dist/team/__tests__/pane-teardown-residue-e2e.test.d.ts.map +1 -0
- package/dist/team/__tests__/pane-teardown-residue-e2e.test.js +410 -0
- package/dist/team/__tests__/pane-teardown-residue-e2e.test.js.map +1 -0
- package/dist/team/__tests__/phase-controller.test.js +20 -0
- package/dist/team/__tests__/phase-controller.test.js.map +1 -1
- package/dist/team/__tests__/runtime.test.js +725 -59
- package/dist/team/__tests__/runtime.test.js.map +1 -1
- package/dist/team/__tests__/scaling.test.js +222 -237
- package/dist/team/__tests__/scaling.test.js.map +1 -1
- package/dist/team/__tests__/state-root.test.js +117 -2
- package/dist/team/__tests__/state-root.test.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +887 -92
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/__tests__/tmux-source-authority-realtmux.test.d.ts +2 -0
- package/dist/team/__tests__/tmux-source-authority-realtmux.test.d.ts.map +1 -0
- package/dist/team/__tests__/tmux-source-authority-realtmux.test.js +149 -0
- package/dist/team/__tests__/tmux-source-authority-realtmux.test.js.map +1 -0
- package/dist/team/__tests__/tmux-test-fixture.d.ts +69 -0
- package/dist/team/__tests__/tmux-test-fixture.d.ts.map +1 -1
- package/dist/team/__tests__/tmux-test-fixture.js +242 -15
- package/dist/team/__tests__/tmux-test-fixture.js.map +1 -1
- package/dist/team/__tests__/tmux-test-fixture.test.js +177 -1
- package/dist/team/__tests__/tmux-test-fixture.test.js.map +1 -1
- package/dist/team/__tests__/worker-runtime-identity.test.js +45 -72
- package/dist/team/__tests__/worker-runtime-identity.test.js.map +1 -1
- package/dist/team/notice-ledger.d.ts +54 -0
- package/dist/team/notice-ledger.d.ts.map +1 -0
- package/dist/team/notice-ledger.js +259 -0
- package/dist/team/notice-ledger.js.map +1 -0
- package/dist/team/phase-controller.d.ts.map +1 -1
- package/dist/team/phase-controller.js +2 -0
- package/dist/team/phase-controller.js.map +1 -1
- package/dist/team/runtime.d.ts +3 -1
- package/dist/team/runtime.d.ts.map +1 -1
- package/dist/team/runtime.js +314 -142
- package/dist/team/runtime.js.map +1 -1
- package/dist/team/scaling.d.ts.map +1 -1
- package/dist/team/scaling.js +161 -91
- package/dist/team/scaling.js.map +1 -1
- package/dist/team/state/monitor.d.ts +10 -0
- package/dist/team/state/monitor.d.ts.map +1 -1
- package/dist/team/state/monitor.js +13 -0
- package/dist/team/state/monitor.js.map +1 -1
- package/dist/team/state/types.d.ts +15 -0
- package/dist/team/state/types.d.ts.map +1 -1
- package/dist/team/state/types.js.map +1 -1
- package/dist/team/state-root.d.ts +12 -0
- package/dist/team/state-root.d.ts.map +1 -1
- package/dist/team/state-root.js +177 -0
- package/dist/team/state-root.js.map +1 -1
- package/dist/team/state.d.ts +18 -0
- package/dist/team/state.d.ts.map +1 -1
- package/dist/team/state.js +36 -0
- package/dist/team/state.js.map +1 -1
- package/dist/team/tmux-session.d.ts +40 -4
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +513 -166
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/ultragoal/__tests__/artifacts.test.js +950 -2
- package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
- package/dist/ultragoal/__tests__/docs-contract.test.js +1 -0
- package/dist/ultragoal/__tests__/docs-contract.test.js.map +1 -1
- package/dist/ultragoal/artifacts.d.ts +35 -1
- package/dist/ultragoal/artifacts.d.ts.map +1 -1
- package/dist/ultragoal/artifacts.js +232 -46
- package/dist/ultragoal/artifacts.js.map +1 -1
- package/dist/utils/__tests__/file-durability.test.js +58 -1
- package/dist/utils/__tests__/file-durability.test.js.map +1 -1
- package/dist/utils/__tests__/paths.test.js +21 -1
- package/dist/utils/__tests__/paths.test.js.map +1 -1
- package/dist/utils/file-durability.d.ts +10 -0
- package/dist/utils/file-durability.d.ts.map +1 -1
- package/dist/utils/file-durability.js +34 -4
- package/dist/utils/file-durability.js.map +1 -1
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +14 -6
- package/dist/utils/paths.js.map +1 -1
- package/dist/verification/__tests__/ci-rust-gates.test.js +49 -4
- package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js +60 -0
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
- package/dist/verification/__tests__/native-release-manifest.test.js +157 -4
- package/dist/verification/__tests__/native-release-manifest.test.js.map +1 -1
- package/package.json +13 -5
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +81 -17
- package/plugins/oh-my-codex/skills/autopilot/SKILL.md +13 -5
- package/plugins/oh-my-codex/skills/cancel/SKILL.md +30 -85
- package/plugins/oh-my-codex/skills/ralph/SKILL.md +5 -5
- package/plugins/oh-my-codex/skills/ralplan/SKILL.md +15 -17
- package/plugins/oh-my-codex/skills/ultragoal/SKILL.md +3 -2
- package/skills/autopilot/SKILL.md +13 -5
- package/skills/cancel/SKILL.md +30 -85
- package/skills/ralph/SKILL.md +5 -5
- package/skills/ralplan/SKILL.md +15 -17
- package/skills/ultragoal/SKILL.md +3 -2
- package/src/scripts/__tests__/codex-native-hook.test.ts +6091 -540
- package/src/scripts/__tests__/issue-3293-callsite-parity.test.ts +187 -0
- package/src/scripts/__tests__/issue-3293-cancel-transaction.test.ts +189 -0
- package/src/scripts/__tests__/issue-3293-hook-owned-cancel.test.ts +187 -0
- package/src/scripts/__tests__/issue-3311-ultragoal-native-outside-tmux.test.ts +591 -0
- package/src/scripts/__tests__/issue-3358-ultragoal-cancel-lifecycle.test.ts +701 -0
- package/src/scripts/__tests__/issue-3369-autopilot-ralplan-state-machine.test.ts +211 -0
- package/src/scripts/__tests__/notify-state-io.test.ts +29 -0
- package/src/scripts/__tests__/notify-tmux-injection.test.ts +26 -1
- package/src/scripts/__tests__/postinstall.test.ts +23 -1
- package/src/scripts/__tests__/role-intent-bootstrap-e2e-3181.test.ts +27 -8
- package/src/scripts/__tests__/role-intent-durable-recovery-3181.test.ts +61 -27
- package/src/scripts/__tests__/smoke-packed-install.test.ts +209 -18
- package/src/scripts/__tests__/verify-native-release-assets.test.ts +240 -0
- package/src/scripts/codex-native-hook.ts +3638 -475
- package/src/scripts/codex-native-pre-post.ts +43 -12
- package/src/scripts/generate-native-release-manifest.ts +83 -79
- package/src/scripts/notify-fallback-watcher.ts +38 -38
- package/src/scripts/notify-hook/state-io.ts +11 -0
- package/src/scripts/notify-hook/team-leader-nudge.ts +32 -4
- package/src/scripts/notify-hook/team-worker-stop.ts +23 -4
- package/src/scripts/notify-hook/team-worker.ts +43 -5
- package/src/scripts/notify-hook/tmux-injection.ts +5 -1
- package/src/scripts/notify-hook.ts +83 -82
- package/src/scripts/postinstall-advisory.ts +67 -0
- package/src/scripts/postinstall.ts +1 -1
- package/src/scripts/smoke-packed-install.ts +446 -59
- package/src/scripts/sync-plugin-mirror.ts +17 -1
- package/src/scripts/verify-native-release-assets.ts +39 -31
- package/templates/AGENTS.md +1 -0
|
@@ -3,10 +3,10 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { once } from 'node:events';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
|
-
import { mkdir, mkdtemp, readFile, readdir, rename, rm,
|
|
6
|
+
import { link, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import { __createDefaultPidProbeForTests, appendPromptSessionProvenanceRejection, __resetSessionPointerTransactionDependenciesForTests, __setSessionPointerTransactionDependenciesForTests, inspectSessionPointerLock, isSessionPointerLaunchAbort, isSessionStale, readSessionPointer, readSessionState, readUsableSessionState, reconcileNativeSessionStart, recoverSessionPointerLock, resetSessionMetrics, resolveSessionPointerContext, writeSessionEnd, writeSessionStart, } from '../session.js';
|
|
9
|
+
import { __createDefaultPidProbeForTests, __releasePointerLockForTests, appendPromptSessionProvenanceRejection, closeLaunchSessionBindingOnce, establishLaunchSessionBinding, finalizeBoundOnce, __resetSessionPointerTransactionDependenciesForTests, __setSessionPointerTransactionDependenciesForTests, inspectSessionPointerLock, isSessionPointerLaunchAbort, isSessionStale, readSessionPointer, readSessionState, readUsableSessionState, readNativeSessionOwner, readNativeSessionOwnerEvidence, reconcileNativeSessionStart, recoverSessionPointerLock, resetSessionMetrics, resolveSessionPointerContext, writeSessionEnd, writeNativeSessionOwner, updateDetachedSessionMetadata, writeSessionStart, } from '../session.js';
|
|
10
10
|
function todayIsoDate() {
|
|
11
11
|
return new Date().toISOString().slice(0, 10);
|
|
12
12
|
}
|
|
@@ -25,8 +25,17 @@ const SUCCESSOR_TOKEN = 'successor_token_123456789';
|
|
|
25
25
|
function codedError(code) {
|
|
26
26
|
return Object.assign(new Error(code), { code });
|
|
27
27
|
}
|
|
28
|
+
function isOwnerConflict(error) {
|
|
29
|
+
return isSessionPointerLaunchAbort(error)
|
|
30
|
+
&& error.code === 'session_pointer_owner_conflict';
|
|
31
|
+
}
|
|
28
32
|
async function withPointerDependencies(overrides, run) {
|
|
29
|
-
|
|
33
|
+
const runtimePlatform = overrides.runtimePlatform ?? process.platform;
|
|
34
|
+
__setSessionPointerTransactionDependenciesForTests({
|
|
35
|
+
atomicRenameNoReplace: defaultTestAtomicRenameNoReplace,
|
|
36
|
+
...overrides,
|
|
37
|
+
runtimePlatform,
|
|
38
|
+
});
|
|
30
39
|
try {
|
|
31
40
|
await run();
|
|
32
41
|
}
|
|
@@ -50,8 +59,11 @@ async function withOwnerEnvironment(sessionId, run) {
|
|
|
50
59
|
process.env.OMX_SESSION_ID = previous;
|
|
51
60
|
}
|
|
52
61
|
}
|
|
53
|
-
function
|
|
54
|
-
return {
|
|
62
|
+
function matchingObservation(platform = process.platform) {
|
|
63
|
+
return { kind: 'identity', identity: { platform, birth: '1' } };
|
|
64
|
+
}
|
|
65
|
+
function matchingWin32Observation() {
|
|
66
|
+
return { kind: 'identity', identity: { platform: 'win32', birth: '1' } };
|
|
55
67
|
}
|
|
56
68
|
async function writeLockOwner(cwd, owner) {
|
|
57
69
|
const context = resolveSessionPointerContext(cwd);
|
|
@@ -60,16 +72,64 @@ async function writeLockOwner(cwd, owner) {
|
|
|
60
72
|
return context.lockPath;
|
|
61
73
|
}
|
|
62
74
|
function validLockOwner(overrides = {}) {
|
|
75
|
+
const platform = (overrides.platform ?? 'linux');
|
|
76
|
+
const version = overrides.version === 2 || overrides.process_identity !== undefined ? 2 : 1;
|
|
77
|
+
const identity = overrides.process_identity ?? (version === 2 ? { platform, birth: '1' } : undefined);
|
|
63
78
|
return {
|
|
64
|
-
version
|
|
79
|
+
version,
|
|
65
80
|
token: TEST_TOKEN,
|
|
66
81
|
pid: process.pid,
|
|
67
|
-
platform
|
|
68
|
-
|
|
82
|
+
platform,
|
|
83
|
+
...(version === 2 && identity ? { process_identity: identity } : {}),
|
|
84
|
+
...(version === 1 ? { pid_start_ticks: 1 } : {}),
|
|
69
85
|
created_at: '2026-07-14T00:00:00.000Z',
|
|
70
86
|
...overrides,
|
|
71
87
|
};
|
|
72
88
|
}
|
|
89
|
+
/** Safe test seam for capability probes: no-replace rename without overwriting destinations. */
|
|
90
|
+
async function defaultTestAtomicRenameNoReplace(from, to) {
|
|
91
|
+
if (existsSync(to))
|
|
92
|
+
return 'not-moved';
|
|
93
|
+
await rename(from, to);
|
|
94
|
+
return 'moved';
|
|
95
|
+
}
|
|
96
|
+
/** Wrap a lock-directory-focused seam so capability probes keep working. */
|
|
97
|
+
function withProbeFallback(contextLockPath, move) {
|
|
98
|
+
return async (from, to) => {
|
|
99
|
+
if (from === contextLockPath
|
|
100
|
+
|| to === contextLockPath
|
|
101
|
+
|| from.startsWith(`${contextLockPath}.`)
|
|
102
|
+
|| to.startsWith(`${contextLockPath}.`)) {
|
|
103
|
+
return await move(from, to);
|
|
104
|
+
}
|
|
105
|
+
return await defaultTestAtomicRenameNoReplace(from, to);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async function assertDurableDirectoryRecovery(cwd, options = {}) {
|
|
109
|
+
const context = resolveSessionPointerContext(cwd);
|
|
110
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
111
|
+
await withPointerDependencies({
|
|
112
|
+
token: () => SUCCESSOR_TOKEN,
|
|
113
|
+
probePid: () => 'dead',
|
|
114
|
+
atomicRenameNoReplace: options.atomicRenameNoReplace ?? defaultTestAtomicRenameNoReplace,
|
|
115
|
+
}, async () => {
|
|
116
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
117
|
+
assert.equal(recovered.recovered, true, recovered.reason);
|
|
118
|
+
assert.equal(recovered.action, 'quarantined');
|
|
119
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
120
|
+
const completed = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.completed`;
|
|
121
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
122
|
+
assert.equal(existsSync(parked), true);
|
|
123
|
+
assert.equal(existsSync(completed), true);
|
|
124
|
+
assert.equal(existsSync(recovered.quarantinePath), true);
|
|
125
|
+
const quarantine = await lstat(recovered.quarantinePath);
|
|
126
|
+
const parkedOwner = await lstat(join(parked, 'owner.json'));
|
|
127
|
+
assert.deepEqual({ dev: quarantine.dev, ino: quarantine.ino }, { dev: parkedOwner.dev, ino: parkedOwner.ino });
|
|
128
|
+
const repeated = await recoverSessionPointerLock(cwd);
|
|
129
|
+
assert.equal(repeated.recovered, false);
|
|
130
|
+
assert.equal(repeated.status, 'absent');
|
|
131
|
+
});
|
|
132
|
+
}
|
|
73
133
|
async function linuxProcessStartTicks(pid) {
|
|
74
134
|
const stat = await readFile(`/proc/${pid}/stat`, 'utf-8');
|
|
75
135
|
const closingParen = stat.lastIndexOf(')');
|
|
@@ -132,8 +192,13 @@ describe('session lifecycle manager', () => {
|
|
|
132
192
|
it('writes session start/end lifecycle artifacts and archives session history', async () => {
|
|
133
193
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lifecycle-'));
|
|
134
194
|
const sessionId = 'sess-lifecycle-1';
|
|
195
|
+
let binding;
|
|
135
196
|
try {
|
|
136
|
-
await
|
|
197
|
+
const established = await establishLaunchSessionBinding(cwd, sessionId);
|
|
198
|
+
assert.equal(established.kind, 'committed-released');
|
|
199
|
+
if (established.kind !== 'committed-released')
|
|
200
|
+
return;
|
|
201
|
+
binding = established.binding;
|
|
137
202
|
const state = await readSessionState(cwd);
|
|
138
203
|
assert.ok(state);
|
|
139
204
|
assert.equal(state.session_id, sessionId);
|
|
@@ -142,7 +207,7 @@ describe('session lifecycle manager', () => {
|
|
|
142
207
|
assert.equal(isSessionStale(state), false);
|
|
143
208
|
const sessionPath = join(cwd, '.omx', 'state', 'session.json');
|
|
144
209
|
assert.equal(existsSync(sessionPath), true);
|
|
145
|
-
await
|
|
210
|
+
await finalizeBoundOnce(binding, 'test');
|
|
146
211
|
assert.equal(existsSync(sessionPath), false);
|
|
147
212
|
const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
|
|
148
213
|
assert.equal(existsSync(historyPath), true);
|
|
@@ -163,6 +228,8 @@ describe('session lifecycle manager', () => {
|
|
|
163
228
|
assert.match(dailyLog, /"event":"session_end"/);
|
|
164
229
|
}
|
|
165
230
|
finally {
|
|
231
|
+
if (binding)
|
|
232
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
166
233
|
await rm(cwd, { recursive: true, force: true });
|
|
167
234
|
}
|
|
168
235
|
});
|
|
@@ -174,8 +241,16 @@ describe('session lifecycle manager', () => {
|
|
|
174
241
|
warnings.push(value);
|
|
175
242
|
return true;
|
|
176
243
|
});
|
|
244
|
+
let binding;
|
|
177
245
|
try {
|
|
246
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-end-durability');
|
|
247
|
+
assert.equal(established.kind, 'committed-released');
|
|
248
|
+
if (established.kind !== 'committed-released')
|
|
249
|
+
return;
|
|
250
|
+
binding = established.binding;
|
|
178
251
|
await writeSessionEnd(cwd, 'sess-end-durability', {
|
|
252
|
+
binding,
|
|
253
|
+
context: binding.context,
|
|
179
254
|
platform: 'win32',
|
|
180
255
|
regularFileSync: async () => { throw codedError('EPERM'); },
|
|
181
256
|
});
|
|
@@ -184,6 +259,8 @@ describe('session lifecycle manager', () => {
|
|
|
184
259
|
]);
|
|
185
260
|
}
|
|
186
261
|
finally {
|
|
262
|
+
if (binding)
|
|
263
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
187
264
|
process.stderr.write = originalWrite;
|
|
188
265
|
await rm(cwd, { recursive: true, force: true });
|
|
189
266
|
}
|
|
@@ -197,13 +274,22 @@ describe('session lifecycle manager', () => {
|
|
|
197
274
|
return true;
|
|
198
275
|
});
|
|
199
276
|
const regularFileSync = async () => { throw codedError('EPERM'); };
|
|
277
|
+
let binding;
|
|
200
278
|
try {
|
|
279
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-end-release-failure');
|
|
280
|
+
assert.equal(established.kind, 'committed-released');
|
|
281
|
+
if (established.kind !== 'committed-released')
|
|
282
|
+
return;
|
|
283
|
+
binding = established.binding;
|
|
284
|
+
const establishedBinding = binding;
|
|
201
285
|
await withPointerDependencies({
|
|
202
286
|
fs: {
|
|
203
287
|
rmdir: async () => { throw new Error('release failure'); },
|
|
204
288
|
},
|
|
205
289
|
}, async () => {
|
|
206
290
|
await assert.rejects(writeSessionEnd(cwd, 'sess-end-release-failure', {
|
|
291
|
+
binding: establishedBinding,
|
|
292
|
+
context: establishedBinding.context,
|
|
207
293
|
platform: 'win32',
|
|
208
294
|
regularFileSync,
|
|
209
295
|
}));
|
|
@@ -211,6 +297,8 @@ describe('session lifecycle manager', () => {
|
|
|
211
297
|
assert.deepEqual(warnings, []);
|
|
212
298
|
}
|
|
213
299
|
finally {
|
|
300
|
+
if (binding)
|
|
301
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
214
302
|
process.stderr.write = originalWrite;
|
|
215
303
|
await rm(cwd, { recursive: true, force: true });
|
|
216
304
|
}
|
|
@@ -219,6 +307,7 @@ describe('session lifecycle manager', () => {
|
|
|
219
307
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-owner-'));
|
|
220
308
|
try {
|
|
221
309
|
await writeSessionStart(cwd, 'sess-current');
|
|
310
|
+
const beforePointer = await readFile(resolveSessionPointerContext(cwd).sessionPath, 'utf-8');
|
|
222
311
|
const stateDir = join(cwd, '.omx', 'state');
|
|
223
312
|
const sessionPath = join(stateDir, 'session.json');
|
|
224
313
|
const currentHudPath = join(stateDir, 'sessions', 'sess-current', 'hud-state.json');
|
|
@@ -227,21 +316,13 @@ describe('session lifecycle manager', () => {
|
|
|
227
316
|
await mkdir(join(stateDir, 'sessions', 'sess-ending'), { recursive: true });
|
|
228
317
|
await writeFile(currentHudPath, JSON.stringify({ turn_count: 2 }), 'utf-8');
|
|
229
318
|
await writeFile(endingHudPath, JSON.stringify({ turn_count: 1 }), 'utf-8');
|
|
230
|
-
await writeSessionEnd(cwd, 'sess-ending');
|
|
319
|
+
await assert.rejects(() => writeSessionEnd(cwd, 'sess-ending'), isSessionPointerLaunchAbort);
|
|
231
320
|
const state = await readSessionState(cwd);
|
|
232
321
|
assert.equal(state?.session_id, 'sess-current');
|
|
233
|
-
assert.equal(
|
|
322
|
+
assert.equal(await readFile(sessionPath, 'utf-8'), beforePointer);
|
|
234
323
|
assert.equal(existsSync(currentHudPath), true);
|
|
235
|
-
assert.equal(existsSync(endingHudPath),
|
|
236
|
-
|
|
237
|
-
.trim()
|
|
238
|
-
.split('\n')
|
|
239
|
-
.filter(Boolean);
|
|
240
|
-
assert.equal(historyLines.length, 1);
|
|
241
|
-
const historyEntry = JSON.parse(historyLines[0]);
|
|
242
|
-
assert.equal(historyEntry.session_id, 'sess-ending');
|
|
243
|
-
assert.equal(historyEntry.started_at, 'unknown');
|
|
244
|
-
assert.equal(historyEntry.preserved_active_session_id, 'sess-current');
|
|
324
|
+
assert.equal(existsSync(endingHudPath), true);
|
|
325
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
245
326
|
}
|
|
246
327
|
finally {
|
|
247
328
|
await rm(cwd, { recursive: true, force: true });
|
|
@@ -251,8 +332,13 @@ describe('session lifecycle manager', () => {
|
|
|
251
332
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-hud-cleanup-'));
|
|
252
333
|
const canonicalSessionId = 'omx-launch-hud';
|
|
253
334
|
const nativeSessionId = 'codex-native-hud';
|
|
335
|
+
let binding;
|
|
254
336
|
try {
|
|
255
|
-
await
|
|
337
|
+
const established = await establishLaunchSessionBinding(cwd, canonicalSessionId, { nativeSessionId });
|
|
338
|
+
assert.equal(established.kind, 'committed-released');
|
|
339
|
+
if (established.kind !== 'committed-released')
|
|
340
|
+
return;
|
|
341
|
+
binding = established.binding;
|
|
256
342
|
const stateDir = join(cwd, '.omx', 'state');
|
|
257
343
|
const rootHudPath = join(stateDir, 'hud-state.json');
|
|
258
344
|
const canonicalHudPath = join(stateDir, 'sessions', canonicalSessionId, 'hud-state.json');
|
|
@@ -262,19 +348,21 @@ describe('session lifecycle manager', () => {
|
|
|
262
348
|
await writeFile(rootHudPath, JSON.stringify({ last_turn_at: 'root', turn_count: 1 }), 'utf-8');
|
|
263
349
|
await writeFile(canonicalHudPath, JSON.stringify({ last_turn_at: 'canonical', turn_count: 2 }), 'utf-8');
|
|
264
350
|
await writeFile(nativeHudPath, JSON.stringify({ last_turn_at: 'native', turn_count: 9 }), 'utf-8');
|
|
265
|
-
await
|
|
351
|
+
await finalizeBoundOnce(binding, 'test');
|
|
266
352
|
assert.equal(existsSync(rootHudPath), false);
|
|
267
353
|
assert.equal(existsSync(canonicalHudPath), false);
|
|
268
354
|
assert.equal(existsSync(nativeHudPath), false);
|
|
269
355
|
}
|
|
270
356
|
finally {
|
|
357
|
+
if (binding)
|
|
358
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
271
359
|
await rm(cwd, { recursive: true, force: true });
|
|
272
360
|
}
|
|
273
361
|
});
|
|
274
362
|
it('preserves canonical session id while reconciling native SessionStart metadata', async () => {
|
|
275
363
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-reconcile-'));
|
|
276
364
|
try {
|
|
277
|
-
await writeSessionStart(cwd, 'omx-launch-1');
|
|
365
|
+
const established = await writeSessionStart(cwd, 'omx-launch-1');
|
|
278
366
|
const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-1', {
|
|
279
367
|
pid: 54321,
|
|
280
368
|
platform: 'win32',
|
|
@@ -283,10 +371,12 @@ describe('session lifecycle manager', () => {
|
|
|
283
371
|
assert.equal(reconciled.native_session_id, 'codex-native-1');
|
|
284
372
|
assert.equal(reconciled.pid, 54321);
|
|
285
373
|
assert.equal(reconciled.platform, 'win32');
|
|
374
|
+
assert.equal(reconciled.launch_lineage_token, established.launch_lineage_token);
|
|
286
375
|
const persisted = await readSessionState(cwd);
|
|
287
376
|
assert.equal(persisted?.session_id, 'omx-launch-1');
|
|
288
377
|
assert.equal(persisted?.native_session_id, 'codex-native-1');
|
|
289
378
|
assert.equal(persisted?.pid, 54321);
|
|
379
|
+
assert.equal(persisted?.launch_lineage_token, established.launch_lineage_token);
|
|
290
380
|
const dailyLogPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
291
381
|
const dailyLog = await readFile(dailyLogPath, 'utf-8');
|
|
292
382
|
assert.match(dailyLog, /"event":"session_start_reconciled"/);
|
|
@@ -296,74 +386,76 @@ describe('session lifecycle manager', () => {
|
|
|
296
386
|
await rm(cwd, { recursive: true, force: true });
|
|
297
387
|
}
|
|
298
388
|
});
|
|
299
|
-
it('
|
|
389
|
+
it('rejects replacing a live native session pointer with another native SessionStart', async () => {
|
|
300
390
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-fresh-'));
|
|
301
391
|
try {
|
|
302
392
|
await writeSessionStart(cwd, 'omx-old-session', {
|
|
303
393
|
nativeSessionId: 'codex-native-old',
|
|
304
394
|
});
|
|
305
|
-
|
|
395
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-new', {
|
|
306
396
|
pid: 54321,
|
|
307
397
|
platform: 'win32',
|
|
308
|
-
});
|
|
309
|
-
assert.equal(reconciled.session_id, 'codex-native-new');
|
|
310
|
-
assert.equal(reconciled.native_session_id, 'codex-native-new');
|
|
311
|
-
assert.equal(reconciled.previous_native_session_id, 'codex-native-old');
|
|
312
|
-
assert.equal(reconciled.owner_omx_session_id, 'omx-old-session');
|
|
313
|
-
assert.match(reconciled.native_session_switched_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
|
|
314
|
-
assert.equal(reconciled.pid, 54321);
|
|
398
|
+
}), isOwnerConflict);
|
|
315
399
|
const persisted = await readSessionState(cwd);
|
|
316
|
-
assert.equal(persisted?.session_id, '
|
|
317
|
-
assert.equal(persisted?.native_session_id, 'codex-native-
|
|
318
|
-
assert.equal(persisted?.previous_native_session_id, 'codex-native-old');
|
|
319
|
-
assert.equal(persisted?.owner_omx_session_id, 'omx-old-session');
|
|
320
|
-
const dailyLogPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
321
|
-
const dailyLog = await readFile(dailyLogPath, 'utf-8');
|
|
322
|
-
assert.match(dailyLog, /"event":"native_session_replaced"/);
|
|
323
|
-
assert.match(dailyLog, /"event":"session_start"/);
|
|
324
|
-
assert.match(dailyLog, /"previous_native_session_id":"codex-native-old"/);
|
|
325
|
-
assert.match(dailyLog, /"native_session_id":"codex-native-new"/);
|
|
400
|
+
assert.equal(persisted?.session_id, 'omx-old-session');
|
|
401
|
+
assert.equal(persisted?.native_session_id, 'codex-native-old');
|
|
326
402
|
}
|
|
327
403
|
finally {
|
|
328
404
|
await rm(cwd, { recursive: true, force: true });
|
|
329
405
|
}
|
|
330
406
|
});
|
|
331
|
-
it('
|
|
407
|
+
it('routes existing detached metadata updates through the exact binding', async () => {
|
|
332
408
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-preserve-'));
|
|
409
|
+
let binding;
|
|
333
410
|
try {
|
|
334
|
-
await
|
|
411
|
+
const established = await establishLaunchSessionBinding(cwd, 'omx-launch-1', {
|
|
335
412
|
nativeSessionId: 'codex-native-1',
|
|
336
413
|
tmuxSessionName: 'omx-detached-demo',
|
|
337
414
|
});
|
|
338
|
-
|
|
415
|
+
assert.equal(established.kind, 'committed-released');
|
|
416
|
+
if (established.kind !== 'committed-released')
|
|
417
|
+
return;
|
|
418
|
+
binding = established.binding;
|
|
419
|
+
const withPaneResult = await updateDetachedSessionMetadata(binding, {
|
|
339
420
|
tmuxSessionName: 'omx-detached-demo',
|
|
340
421
|
tmuxPaneId: '%42',
|
|
341
422
|
});
|
|
342
|
-
assert.equal(
|
|
343
|
-
|
|
344
|
-
assert.equal(withPane
|
|
345
|
-
|
|
423
|
+
assert.equal(withPaneResult.kind, 'committed-released');
|
|
424
|
+
const withPane = await readSessionState(cwd);
|
|
425
|
+
assert.equal(withPane?.native_session_id, 'codex-native-1');
|
|
426
|
+
assert.equal(withPane?.tmux_session_name, 'omx-detached-demo');
|
|
427
|
+
assert.equal(withPane?.tmux_pane_id, '%42');
|
|
428
|
+
const withoutPaneResult = await updateDetachedSessionMetadata(binding, {
|
|
346
429
|
tmuxSessionName: 'omx-detached-demo',
|
|
347
430
|
});
|
|
348
|
-
assert.equal(
|
|
349
|
-
|
|
350
|
-
assert.equal(withoutPane
|
|
431
|
+
assert.equal(withoutPaneResult.kind, 'committed-released');
|
|
432
|
+
const withoutPane = await readSessionState(cwd);
|
|
433
|
+
assert.equal(withoutPane?.native_session_id, 'codex-native-1');
|
|
434
|
+
assert.equal(withoutPane?.tmux_session_name, 'omx-detached-demo');
|
|
435
|
+
assert.equal(withoutPane?.tmux_pane_id, '%42');
|
|
351
436
|
}
|
|
352
437
|
finally {
|
|
438
|
+
if (binding)
|
|
439
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
353
440
|
await rm(cwd, { recursive: true, force: true });
|
|
354
441
|
}
|
|
355
442
|
});
|
|
356
|
-
it('lets an owner OMX launch session end
|
|
443
|
+
it('lets an owner OMX launch session end after rejecting an unrelated native replacement', async () => {
|
|
357
444
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-owner-end-'));
|
|
445
|
+
let binding;
|
|
358
446
|
try {
|
|
359
|
-
await
|
|
447
|
+
const established = await establishLaunchSessionBinding(cwd, 'omx-owner-session', {
|
|
360
448
|
nativeSessionId: 'codex-native-old',
|
|
361
449
|
});
|
|
362
|
-
|
|
450
|
+
assert.equal(established.kind, 'committed-released');
|
|
451
|
+
if (established.kind !== 'committed-released')
|
|
452
|
+
return;
|
|
453
|
+
binding = established.binding;
|
|
454
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-new', {
|
|
363
455
|
pid: process.pid,
|
|
364
456
|
platform: 'win32',
|
|
365
|
-
});
|
|
366
|
-
await
|
|
457
|
+
}), isOwnerConflict);
|
|
458
|
+
await finalizeBoundOnce(binding, 'test');
|
|
367
459
|
assert.equal(await readSessionState(cwd), null);
|
|
368
460
|
const historyLines = (await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8'))
|
|
369
461
|
.trim()
|
|
@@ -372,64 +464,73 @@ describe('session lifecycle manager', () => {
|
|
|
372
464
|
assert.equal(historyLines.length, 1);
|
|
373
465
|
const historyEntry = JSON.parse(historyLines[0]);
|
|
374
466
|
assert.equal(historyEntry.session_id, 'omx-owner-session');
|
|
375
|
-
assert.equal(historyEntry.native_session_id, 'codex-native-
|
|
376
|
-
assert.equal(historyEntry.active_session_id,
|
|
467
|
+
assert.equal(historyEntry.native_session_id, 'codex-native-old');
|
|
468
|
+
assert.equal(historyEntry.active_session_id, undefined);
|
|
377
469
|
}
|
|
378
470
|
finally {
|
|
471
|
+
if (binding)
|
|
472
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
379
473
|
await rm(cwd, { recursive: true, force: true });
|
|
380
474
|
}
|
|
381
475
|
});
|
|
382
|
-
it('preserves
|
|
476
|
+
it('preserves canonical session metadata when reconciling the same native session', async () => {
|
|
383
477
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-owner-reconcile-'));
|
|
478
|
+
let binding;
|
|
384
479
|
try {
|
|
385
|
-
await
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
480
|
+
await withPointerDependencies({
|
|
481
|
+
runtimePlatform: 'win32',
|
|
482
|
+
observeProcess: () => matchingWin32Observation(),
|
|
483
|
+
}, async () => {
|
|
484
|
+
const established = await establishLaunchSessionBinding(cwd, 'omx-owner-session', {
|
|
485
|
+
nativeSessionId: 'codex-native-old',
|
|
486
|
+
platform: 'win32',
|
|
487
|
+
});
|
|
488
|
+
assert.equal(established.kind, 'committed-released');
|
|
489
|
+
if (established.kind !== 'committed-released')
|
|
490
|
+
return;
|
|
491
|
+
binding = established.binding;
|
|
492
|
+
const reconciled = await reconcileNativeSessionStart(cwd, 'codex-native-old', {
|
|
493
|
+
pid: process.pid,
|
|
494
|
+
platform: 'win32',
|
|
495
|
+
});
|
|
496
|
+
assert.equal(reconciled.session_id, 'omx-owner-session');
|
|
497
|
+
assert.equal(reconciled.native_session_id, 'codex-native-old');
|
|
498
|
+
assert.equal(reconciled.previous_native_session_id, undefined);
|
|
499
|
+
assert.equal(reconciled.owner_omx_session_id, undefined);
|
|
500
|
+
assert.equal(reconciled.pid, process.pid);
|
|
501
|
+
await finalizeBoundOnce(binding, 'test');
|
|
502
|
+
assert.equal(await readSessionState(cwd), null);
|
|
395
503
|
});
|
|
396
|
-
assert.equal(reconciled.session_id, 'codex-native-new');
|
|
397
|
-
assert.equal(reconciled.native_session_id, 'codex-native-new');
|
|
398
|
-
assert.equal(reconciled.previous_native_session_id, 'codex-native-old');
|
|
399
|
-
assert.equal(reconciled.owner_omx_session_id, 'omx-owner-session');
|
|
400
|
-
assert.equal(reconciled.pid, process.pid);
|
|
401
|
-
await writeSessionEnd(cwd, 'omx-owner-session');
|
|
402
|
-
assert.equal(await readSessionState(cwd), null);
|
|
403
504
|
}
|
|
404
505
|
finally {
|
|
506
|
+
if (binding)
|
|
507
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
405
508
|
await rm(cwd, { recursive: true, force: true });
|
|
406
509
|
}
|
|
407
510
|
});
|
|
408
|
-
it('
|
|
511
|
+
it('rejects chained native SessionStart replacements and preserves the original pointer', async () => {
|
|
409
512
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-owner-chain-'));
|
|
513
|
+
let binding;
|
|
410
514
|
try {
|
|
411
|
-
await
|
|
515
|
+
const established = await establishLaunchSessionBinding(cwd, 'omx-owner-session', {
|
|
412
516
|
nativeSessionId: 'codex-native-a',
|
|
413
517
|
});
|
|
414
|
-
|
|
518
|
+
assert.equal(established.kind, 'committed-released');
|
|
519
|
+
if (established.kind !== 'committed-released')
|
|
520
|
+
return;
|
|
521
|
+
binding = established.binding;
|
|
522
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-b', {
|
|
415
523
|
pid: process.pid,
|
|
416
524
|
platform: 'win32',
|
|
417
|
-
});
|
|
418
|
-
|
|
525
|
+
}), isOwnerConflict);
|
|
526
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-c', {
|
|
419
527
|
pid: process.pid,
|
|
420
528
|
platform: 'win32',
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
assert.equal(
|
|
424
|
-
assert.equal(
|
|
425
|
-
|
|
426
|
-
const dailyLogPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
427
|
-
const dailyLog = await readFile(dailyLogPath, 'utf-8');
|
|
428
|
-
assert.match(dailyLog, /"session_id":"omx-owner-session"/);
|
|
429
|
-
assert.match(dailyLog, /"active_session_id":"codex-native-b"/);
|
|
430
|
-
assert.match(dailyLog, /"previous_native_session_id":"codex-native-b"/);
|
|
431
|
-
assert.match(dailyLog, /"replaced_by_native_session_id":"codex-native-c"/);
|
|
432
|
-
await writeSessionEnd(cwd, 'omx-owner-session');
|
|
529
|
+
}), isOwnerConflict);
|
|
530
|
+
const persisted = await readSessionState(cwd);
|
|
531
|
+
assert.equal(persisted?.session_id, 'omx-owner-session');
|
|
532
|
+
assert.equal(persisted?.native_session_id, 'codex-native-a');
|
|
533
|
+
await finalizeBoundOnce(binding, 'test');
|
|
433
534
|
assert.equal(await readSessionState(cwd), null);
|
|
434
535
|
const historyLines = (await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8'))
|
|
435
536
|
.trim()
|
|
@@ -437,30 +538,28 @@ describe('session lifecycle manager', () => {
|
|
|
437
538
|
.filter(Boolean);
|
|
438
539
|
const historyEntry = JSON.parse(historyLines.at(-1) ?? '{}');
|
|
439
540
|
assert.equal(historyEntry.session_id, 'omx-owner-session');
|
|
440
|
-
assert.equal(historyEntry.native_session_id, 'codex-native-
|
|
441
|
-
assert.equal(historyEntry.active_session_id,
|
|
541
|
+
assert.equal(historyEntry.native_session_id, 'codex-native-a');
|
|
542
|
+
assert.equal(historyEntry.active_session_id, undefined);
|
|
442
543
|
}
|
|
443
544
|
finally {
|
|
545
|
+
if (binding)
|
|
546
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
444
547
|
await rm(cwd, { recursive: true, force: true });
|
|
445
548
|
}
|
|
446
549
|
});
|
|
447
|
-
it('
|
|
550
|
+
it('rejects replacing a live wrapper-owned native session', async () => {
|
|
448
551
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-non-omx-fresh-'));
|
|
449
552
|
try {
|
|
450
553
|
await writeSessionStart(cwd, 'codex-native-old', {
|
|
451
554
|
nativeSessionId: 'codex-native-old',
|
|
452
555
|
});
|
|
453
|
-
|
|
556
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'codex-native-new', {
|
|
454
557
|
pid: 54321,
|
|
455
558
|
platform: 'win32',
|
|
456
|
-
});
|
|
457
|
-
assert.equal(reconciled.session_id, 'codex-native-new');
|
|
458
|
-
assert.equal(reconciled.native_session_id, 'codex-native-new');
|
|
459
|
-
assert.equal(reconciled.previous_native_session_id, undefined);
|
|
460
|
-
assert.equal(reconciled.pid, 54321);
|
|
559
|
+
}), isOwnerConflict);
|
|
461
560
|
const persisted = await readSessionState(cwd);
|
|
462
|
-
assert.equal(persisted?.session_id, 'codex-native-
|
|
463
|
-
assert.equal(persisted?.native_session_id, 'codex-native-
|
|
561
|
+
assert.equal(persisted?.session_id, 'codex-native-old');
|
|
562
|
+
assert.equal(persisted?.native_session_id, 'codex-native-old');
|
|
464
563
|
assert.equal(persisted?.previous_native_session_id, undefined);
|
|
465
564
|
}
|
|
466
565
|
finally {
|
|
@@ -606,14 +705,29 @@ describe('isSessionStale', () => {
|
|
|
606
705
|
});
|
|
607
706
|
assert.equal(stale, true);
|
|
608
707
|
});
|
|
609
|
-
it('
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
708
|
+
it('classifies identity-less live non-Linux pointers as identity-indeterminate', async () => {
|
|
709
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-non-linux-identityless-'));
|
|
710
|
+
try {
|
|
711
|
+
const context = resolveSessionPointerContext(cwd);
|
|
712
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
713
|
+
await writeFile(context.sessionPath, JSON.stringify({
|
|
714
|
+
session_id: 'sess-darwin-identityless',
|
|
715
|
+
started_at: '2026-07-14T00:00:00.000Z',
|
|
716
|
+
cwd,
|
|
717
|
+
pid: process.pid,
|
|
718
|
+
platform: 'darwin',
|
|
719
|
+
}), 'utf-8');
|
|
720
|
+
await withPointerDependencies({
|
|
721
|
+
runtimePlatform: 'darwin',
|
|
722
|
+
probePid: () => 'alive',
|
|
723
|
+
observeProcess: () => ({ kind: 'unsupported' }),
|
|
724
|
+
}, async () => {
|
|
725
|
+
assert.equal((await readSessionPointer(context)).status, 'identity-indeterminate');
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
finally {
|
|
729
|
+
await rm(cwd, { recursive: true, force: true });
|
|
730
|
+
}
|
|
617
731
|
});
|
|
618
732
|
});
|
|
619
733
|
describe('session pointer transaction', () => {
|
|
@@ -623,13 +737,39 @@ describe('session pointer transaction', () => {
|
|
|
623
737
|
&& error.operation === 'pointer-context-resolve'
|
|
624
738
|
&& !('pointerPath' in error));
|
|
625
739
|
});
|
|
740
|
+
it('keeps the default synthetic identity aligned with the host transaction platform', async () => {
|
|
741
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-platform-fixture-'));
|
|
742
|
+
try {
|
|
743
|
+
const context = resolveSessionPointerContext(cwd);
|
|
744
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
745
|
+
await writeFile(context.sessionPath, JSON.stringify({
|
|
746
|
+
session_id: 'sess-platform-fixture',
|
|
747
|
+
started_at: '2026-07-14T00:00:00.000Z',
|
|
748
|
+
cwd,
|
|
749
|
+
pid: process.pid,
|
|
750
|
+
platform: process.platform,
|
|
751
|
+
identity_schema_version: 2,
|
|
752
|
+
process_identity: { platform: process.platform, birth: '1' },
|
|
753
|
+
}), 'utf-8');
|
|
754
|
+
await withPointerDependencies({
|
|
755
|
+
probePid: () => 'alive',
|
|
756
|
+
observeProcess: () => matchingObservation(),
|
|
757
|
+
}, async () => {
|
|
758
|
+
assert.equal((await readSessionPointer(context)).status, 'usable');
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
finally {
|
|
762
|
+
__resetSessionPointerTransactionDependenciesForTests();
|
|
763
|
+
await rm(cwd, { recursive: true, force: true });
|
|
764
|
+
}
|
|
765
|
+
});
|
|
626
766
|
it('classifies only the exact selected pointer as absent, usable, stale, indeterminate, malformed, or foreign', async () => {
|
|
627
767
|
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-status-'));
|
|
628
768
|
try {
|
|
629
769
|
const context = resolveSessionPointerContext(cwd);
|
|
630
770
|
await withPointerDependencies({
|
|
631
771
|
probePid: () => 'alive',
|
|
632
|
-
|
|
772
|
+
observeProcess: () => matchingObservation(),
|
|
633
773
|
}, async () => {
|
|
634
774
|
assert.equal((await readSessionPointer(context)).status, 'absent');
|
|
635
775
|
await mkdir(context.baseStateDir, { recursive: true });
|
|
@@ -644,12 +784,12 @@ describe('session pointer transaction', () => {
|
|
|
644
784
|
assert.equal((await readSessionPointer(context)).status, 'usable');
|
|
645
785
|
__setSessionPointerTransactionDependenciesForTests({
|
|
646
786
|
probePid: () => 'dead',
|
|
647
|
-
|
|
787
|
+
observeProcess: () => matchingObservation(),
|
|
648
788
|
});
|
|
649
789
|
assert.equal((await readSessionPointer(context)).status, 'stale-dead');
|
|
650
790
|
__setSessionPointerTransactionDependenciesForTests({
|
|
651
791
|
probePid: () => 'indeterminate',
|
|
652
|
-
|
|
792
|
+
observeProcess: () => matchingObservation(),
|
|
653
793
|
});
|
|
654
794
|
assert.equal((await readSessionPointer(context)).status, 'identity-indeterminate');
|
|
655
795
|
await writeFile(context.sessionPath, '{ not-json', 'utf-8');
|
|
@@ -669,6 +809,277 @@ describe('session pointer transaction', () => {
|
|
|
669
809
|
await rm(cwd, { recursive: true, force: true });
|
|
670
810
|
}
|
|
671
811
|
});
|
|
812
|
+
it('covers the process identity decision table through pointer and lock inspection', async () => {
|
|
813
|
+
const hashA = 'aaa'.repeat(21) + 'a';
|
|
814
|
+
const hashB = 'bbb'.repeat(21) + 'b';
|
|
815
|
+
const identityOwner = (identity) => validLockOwner({
|
|
816
|
+
pid: 4242,
|
|
817
|
+
version: 2,
|
|
818
|
+
platform: identity.platform,
|
|
819
|
+
process_identity: identity,
|
|
820
|
+
});
|
|
821
|
+
const legacyOwner = (platform = 'linux', startTicks = 1) => validLockOwner({
|
|
822
|
+
pid: 4242,
|
|
823
|
+
platform,
|
|
824
|
+
pid_start_ticks: startTicks,
|
|
825
|
+
});
|
|
826
|
+
const identitylessOwner = (platform) => validLockOwner({
|
|
827
|
+
pid: 4242,
|
|
828
|
+
platform,
|
|
829
|
+
pid_start_ticks: undefined,
|
|
830
|
+
});
|
|
831
|
+
const identity = (platform, birth, cmdline_hash) => ({
|
|
832
|
+
kind: 'identity',
|
|
833
|
+
identity: { platform, birth, ...(cmdline_hash ? { cmdline_hash } : {}) },
|
|
834
|
+
});
|
|
835
|
+
const classifierCases = [
|
|
836
|
+
{
|
|
837
|
+
name: 'usable match',
|
|
838
|
+
recordedState: {
|
|
839
|
+
platform: 'linux', pid_start_ticks: 1, identity_schema_version: 2,
|
|
840
|
+
process_identity: { platform: 'linux', birth: '1' },
|
|
841
|
+
},
|
|
842
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1')],
|
|
843
|
+
lockOwner: identityOwner({ platform: 'linux', birth: '1' }),
|
|
844
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
name: 'darwin match',
|
|
848
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'darwin', birth: '1234.567' } },
|
|
849
|
+
runtimePlatform: 'darwin', probePid: 'alive', observations: [identity('darwin', '1234.567')],
|
|
850
|
+
lockOwner: identityOwner({ platform: 'darwin', birth: '1234.567' }),
|
|
851
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
name: 'windows exact FILETIME',
|
|
855
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'win32', birth: '132580896000000000' } },
|
|
856
|
+
runtimePlatform: 'win32', probePid: 'alive', observations: [identity('win32', '132580896000000000')],
|
|
857
|
+
lockOwner: identityOwner({ platform: 'win32', birth: '132580896000000000' }),
|
|
858
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
name: 'dead PID',
|
|
862
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
863
|
+
runtimePlatform: 'linux', probePid: 'dead', observations: [],
|
|
864
|
+
lockOwner: legacyOwner(),
|
|
865
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'dead',
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
name: 'birth mismatch',
|
|
869
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
870
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '2'), identity('linux', '2')],
|
|
871
|
+
lockOwner: legacyOwner(),
|
|
872
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
name: 'TOCTOU retry agree',
|
|
876
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
877
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '2'), identity('linux', '1')],
|
|
878
|
+
lockOwner: legacyOwner(),
|
|
879
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
name: 'TOCTOU retry disagree',
|
|
883
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
884
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '2'), identity('linux', '3')],
|
|
885
|
+
lockOwner: legacyOwner(),
|
|
886
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
name: 'EPERM probePid',
|
|
890
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
891
|
+
runtimePlatform: 'linux', probePid: 'indeterminate', observations: [],
|
|
892
|
+
lockOwner: legacyOwner(),
|
|
893
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
name: 'provider denied',
|
|
897
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
898
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [{ kind: 'denied' }],
|
|
899
|
+
lockOwner: legacyOwner(),
|
|
900
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
name: 'provider error',
|
|
904
|
+
recordedState: { platform: 'linux', pid_start_ticks: 1 },
|
|
905
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [{ kind: 'error' }],
|
|
906
|
+
lockOwner: legacyOwner(),
|
|
907
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
name: 'provider unsupported',
|
|
911
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'freebsd', birth: '1' } },
|
|
912
|
+
runtimePlatform: 'freebsd', probePid: 'alive', observations: [{ kind: 'unsupported' }],
|
|
913
|
+
lockOwner: identityOwner({ platform: 'freebsd', birth: '1' }),
|
|
914
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
name: 'foreign platform',
|
|
918
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'darwin', birth: '1' } },
|
|
919
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1')],
|
|
920
|
+
lockOwner: identityOwner({ platform: 'darwin', birth: '1' }),
|
|
921
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
name: 'cmdline hash match',
|
|
925
|
+
recordedState: {
|
|
926
|
+
identity_schema_version: 2,
|
|
927
|
+
process_identity: { platform: 'linux', birth: '1', cmdline_hash: hashA },
|
|
928
|
+
},
|
|
929
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1', hashA)],
|
|
930
|
+
lockOwner: identityOwner({ platform: 'linux', birth: '1', cmdline_hash: hashA }),
|
|
931
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
name: 'cmdline hash mismatch',
|
|
935
|
+
recordedState: {
|
|
936
|
+
identity_schema_version: 2,
|
|
937
|
+
process_identity: { platform: 'linux', birth: '1', cmdline_hash: hashA },
|
|
938
|
+
},
|
|
939
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1', hashB)],
|
|
940
|
+
lockOwner: identityOwner({ platform: 'linux', birth: '1', cmdline_hash: hashA }),
|
|
941
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
name: 'legacy v1 upgrade Linux',
|
|
945
|
+
recordedState: { platform: 'linux', pid_start_ticks: 111 },
|
|
946
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '111')],
|
|
947
|
+
lockOwner: legacyOwner('linux', 111),
|
|
948
|
+
expectedPointerStatus: 'usable', expectedLockStatus: 'live',
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
name: 'legacy v1 mismatch Linux',
|
|
952
|
+
recordedState: { platform: 'linux', pid_start_ticks: 111 },
|
|
953
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '222'), identity('linux', '222')],
|
|
954
|
+
lockOwner: legacyOwner('linux', 111),
|
|
955
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: 'legacy identity-less live non-Linux',
|
|
959
|
+
recordedState: { platform: 'darwin' },
|
|
960
|
+
runtimePlatform: 'darwin', probePid: 'alive', observations: [{ kind: 'unsupported' }],
|
|
961
|
+
lockOwner: identitylessOwner('darwin'),
|
|
962
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
name: 'future schema v3',
|
|
966
|
+
recordedState: { identity_schema_version: 3, process_identity: { platform: 'linux', birth: '1' } },
|
|
967
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [identity('linux', '1')],
|
|
968
|
+
lockOwner: identitylessOwner('darwin'),
|
|
969
|
+
expectedPointerStatus: 'identity-indeterminate', expectedLockStatus: 'identity-indeterminate',
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
name: 'malformed JSON',
|
|
973
|
+
rawPointer: '{not-json',
|
|
974
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [], lockOwner: 'malformed',
|
|
975
|
+
expectedPointerStatus: 'malformed', expectedLockStatus: 'malformed',
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
name: 'same-second Darwin collision',
|
|
979
|
+
recordedState: { identity_schema_version: 2, process_identity: { platform: 'darwin', birth: '1000.500' } },
|
|
980
|
+
runtimePlatform: 'darwin', probePid: 'alive', observations: [identity('darwin', '1000.999'), identity('darwin', '1000.999')],
|
|
981
|
+
lockOwner: identityOwner({ platform: 'darwin', birth: '1000.500' }),
|
|
982
|
+
expectedPointerStatus: 'stale-dead', expectedLockStatus: 'reused',
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
name: 'absent pointer',
|
|
986
|
+
runtimePlatform: 'linux', probePid: 'alive', observations: [],
|
|
987
|
+
expectedPointerStatus: 'absent', expectedLockStatus: 'absent',
|
|
988
|
+
},
|
|
989
|
+
];
|
|
990
|
+
for (const testCase of classifierCases) {
|
|
991
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-classifier-'));
|
|
992
|
+
try {
|
|
993
|
+
const context = resolveSessionPointerContext(cwd);
|
|
994
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
995
|
+
if (testCase.rawPointer !== undefined) {
|
|
996
|
+
await writeFile(context.sessionPath, testCase.rawPointer, 'utf-8');
|
|
997
|
+
}
|
|
998
|
+
else if (testCase.recordedState) {
|
|
999
|
+
await writeFile(context.sessionPath, JSON.stringify({
|
|
1000
|
+
session_id: 'sess-classifier',
|
|
1001
|
+
started_at: '2026-07-14T00:00:00.000Z',
|
|
1002
|
+
cwd,
|
|
1003
|
+
pid: 4242,
|
|
1004
|
+
...testCase.recordedState,
|
|
1005
|
+
}), 'utf-8');
|
|
1006
|
+
}
|
|
1007
|
+
if (testCase.lockOwner !== undefined) {
|
|
1008
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
1009
|
+
await writeFile(join(context.lockPath, 'owner.json'), testCase.lockOwner === 'malformed' ? '{not-json' : JSON.stringify(testCase.lockOwner), 'utf-8');
|
|
1010
|
+
}
|
|
1011
|
+
let observerCalls = 0;
|
|
1012
|
+
const observer = () => {
|
|
1013
|
+
const index = observerCalls++;
|
|
1014
|
+
return testCase.observations[Math.min(index, Math.max(testCase.observations.length - 1, 0))] ?? { kind: 'error' };
|
|
1015
|
+
};
|
|
1016
|
+
await withPointerDependencies({
|
|
1017
|
+
runtimePlatform: testCase.runtimePlatform,
|
|
1018
|
+
probePid: () => testCase.probePid,
|
|
1019
|
+
observeProcess: observer,
|
|
1020
|
+
}, async () => {
|
|
1021
|
+
assert.equal((await readSessionPointer(context)).status, testCase.expectedPointerStatus, testCase.name);
|
|
1022
|
+
});
|
|
1023
|
+
let lockObserverCalls = 0;
|
|
1024
|
+
const lockObserver = () => {
|
|
1025
|
+
const index = lockObserverCalls++;
|
|
1026
|
+
return testCase.observations[Math.min(index, Math.max(testCase.observations.length - 1, 0))] ?? { kind: 'error' };
|
|
1027
|
+
};
|
|
1028
|
+
await withPointerDependencies({
|
|
1029
|
+
runtimePlatform: testCase.runtimePlatform,
|
|
1030
|
+
probePid: () => testCase.probePid,
|
|
1031
|
+
observeProcess: lockObserver,
|
|
1032
|
+
}, async () => {
|
|
1033
|
+
assert.equal((await inspectSessionPointerLock(cwd)).status, testCase.expectedLockStatus, testCase.name);
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
finally {
|
|
1037
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
it('rejects every schema version other than v2 as identity-indeterminate', async () => {
|
|
1042
|
+
const schemaVersions = [0, 1, -1, null, '2', 'bad', 3];
|
|
1043
|
+
for (const schemaVersion of schemaVersions) {
|
|
1044
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-identity-schema-rejection-'));
|
|
1045
|
+
try {
|
|
1046
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1047
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
1048
|
+
await writeFile(context.sessionPath, JSON.stringify({
|
|
1049
|
+
session_id: 'sess-schema-rejection',
|
|
1050
|
+
started_at: '2026-07-14T00:00:00.000Z',
|
|
1051
|
+
cwd,
|
|
1052
|
+
pid: 4242,
|
|
1053
|
+
platform: 'linux',
|
|
1054
|
+
pid_start_ticks: 1,
|
|
1055
|
+
identity_schema_version: schemaVersion,
|
|
1056
|
+
}), 'utf-8');
|
|
1057
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
1058
|
+
await writeFile(join(context.lockPath, 'owner.json'), JSON.stringify(validLockOwner({
|
|
1059
|
+
pid: 4242,
|
|
1060
|
+
platform: 'darwin',
|
|
1061
|
+
pid_start_ticks: undefined,
|
|
1062
|
+
})), 'utf-8');
|
|
1063
|
+
await withPointerDependencies({
|
|
1064
|
+
runtimePlatform: 'linux',
|
|
1065
|
+
probePid: () => 'alive',
|
|
1066
|
+
observeProcess: () => matchingObservation('linux'),
|
|
1067
|
+
}, async () => {
|
|
1068
|
+
assert.equal((await readSessionPointer(context)).status, 'identity-indeterminate', String(schemaVersion));
|
|
1069
|
+
});
|
|
1070
|
+
await withPointerDependencies({
|
|
1071
|
+
runtimePlatform: 'linux',
|
|
1072
|
+
probePid: () => 'alive',
|
|
1073
|
+
observeProcess: () => matchingObservation('linux'),
|
|
1074
|
+
}, async () => {
|
|
1075
|
+
assert.equal((await inspectSessionPointerLock(cwd)).status, 'identity-indeterminate', String(schemaVersion));
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
finally {
|
|
1079
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
672
1083
|
it('maps only ESRCH to a definitely dead default PID probe', () => {
|
|
673
1084
|
const alive = __createDefaultPidProbeForTests(() => { });
|
|
674
1085
|
const esrch = __createDefaultPidProbeForTests(() => { throw codedError('ESRCH'); });
|
|
@@ -686,11 +1097,11 @@ describe('session pointer transaction', () => {
|
|
|
686
1097
|
it('never reaps dead, reused, indeterminate, missing, or malformed lock evidence', async () => {
|
|
687
1098
|
const cases = [
|
|
688
1099
|
{ name: 'dead', owner: validLockOwner(), probePid: 'dead', expected: 'dead' },
|
|
689
|
-
{ name: 'reused', owner: validLockOwner(), probePid: 'alive', identity: {
|
|
1100
|
+
{ name: 'reused', owner: validLockOwner(), probePid: 'alive', observation: { kind: 'identity', identity: { platform: 'linux', birth: '2' } }, expected: 'reused' },
|
|
690
1101
|
{ name: 'indeterminate-probe', owner: validLockOwner(), probePid: 'indeterminate', expected: 'identity-indeterminate' },
|
|
691
|
-
{ name: 'indeterminate-identity', owner: validLockOwner(), probePid: 'alive',
|
|
692
|
-
{ name: '
|
|
693
|
-
{ name: 'missing-required-hash', owner: validLockOwner({ pid_cmdline_hash: 'a'.repeat(64) }), probePid: 'alive',
|
|
1102
|
+
{ name: 'indeterminate-identity', owner: validLockOwner(), probePid: 'alive', observation: { kind: 'error' }, expected: 'identity-indeterminate' },
|
|
1103
|
+
{ name: 'foreign-identity', owner: validLockOwner(), probePid: 'alive', observation: { kind: 'identity', identity: { platform: 'darwin', birth: '1' } }, expected: 'identity-indeterminate' },
|
|
1104
|
+
{ name: 'missing-required-hash', owner: validLockOwner({ pid_cmdline_hash: 'a'.repeat(64) }), probePid: 'alive', observation: matchingObservation(), expected: 'identity-indeterminate' },
|
|
694
1105
|
{ name: 'missing', probePid: 'alive', expected: 'missing' },
|
|
695
1106
|
{ name: 'malformed', owner: { version: 1 }, probePid: 'alive', expected: 'malformed' },
|
|
696
1107
|
];
|
|
@@ -706,7 +1117,7 @@ describe('session pointer transaction', () => {
|
|
|
706
1117
|
await withPointerDependencies({
|
|
707
1118
|
token: () => TEST_TOKEN,
|
|
708
1119
|
probePid: () => testCase.probePid,
|
|
709
|
-
|
|
1120
|
+
observeProcess: () => testCase.observation ?? matchingObservation(),
|
|
710
1121
|
}, async () => {
|
|
711
1122
|
await assert.rejects(writeSessionStart(cwd, 'sess-lock', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
712
1123
|
&& error.code === 'session_pointer_lock_recovery_required'
|
|
@@ -745,595 +1156,2603 @@ describe('session pointer transaction', () => {
|
|
|
745
1156
|
await rm(cwd, { recursive: true, force: true });
|
|
746
1157
|
}
|
|
747
1158
|
});
|
|
748
|
-
it('
|
|
749
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-
|
|
1159
|
+
it('atomically quarantines dead canonical owner evidence and stays idempotent', async () => {
|
|
1160
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-preserved-'));
|
|
1161
|
+
try {
|
|
1162
|
+
await assertDurableDirectoryRecovery(cwd);
|
|
1163
|
+
}
|
|
1164
|
+
finally {
|
|
1165
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
it('launches only after explicit recovery of a dead canonical owner lock', async () => {
|
|
1169
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recover-launch-'));
|
|
750
1170
|
try {
|
|
751
1171
|
const context = resolveSessionPointerContext(cwd);
|
|
752
1172
|
await writeLockOwner(cwd, validLockOwner());
|
|
753
1173
|
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead' }, async () => {
|
|
1174
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-blocked', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
1175
|
+
&& error.code === 'session_pointer_lock_recovery_required'
|
|
1176
|
+
&& error.lockOwnerStatus === 'dead');
|
|
1177
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
1178
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1179
|
+
assert.equal(recovered.recovered, true);
|
|
1180
|
+
assert.equal(recovered.action, 'quarantined');
|
|
1181
|
+
await writeSessionStart(cwd, 'sess-after-recovery', { platform: 'win32' });
|
|
1182
|
+
assert.equal((await readSessionState(cwd))?.session_id, 'sess-after-recovery');
|
|
1183
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
finally {
|
|
1187
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
it('does not claim a successor lock when the inspected dead canonical owner is displaced before the exact rename', async () => {
|
|
1191
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-canonical-race-'));
|
|
1192
|
+
try {
|
|
1193
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1194
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1195
|
+
const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
|
|
1196
|
+
const displacedPath = `${context.lockPath}.displaced`;
|
|
1197
|
+
const staleOwner = JSON.stringify(validLockOwner());
|
|
1198
|
+
const successorOwner = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }));
|
|
1199
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1200
|
+
let displaced = false;
|
|
1201
|
+
await withPointerDependencies({
|
|
1202
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1203
|
+
probePid: () => 'dead',
|
|
1204
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
1205
|
+
if (from === context.lockPath && to === `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`) {
|
|
1206
|
+
displaced = true;
|
|
1207
|
+
await rename(from, displacedPath);
|
|
1208
|
+
await mkdir(context.lockPath);
|
|
1209
|
+
await writeFile(successorTemp, successorOwner, 'utf-8');
|
|
1210
|
+
return 'not-moved';
|
|
1211
|
+
}
|
|
1212
|
+
if (existsSync(to))
|
|
1213
|
+
return 'not-moved';
|
|
1214
|
+
await rename(from, to);
|
|
1215
|
+
return 'moved';
|
|
1216
|
+
},
|
|
1217
|
+
}, async () => {
|
|
754
1218
|
const recovered = await recoverSessionPointerLock(cwd);
|
|
755
|
-
assert.equal(recovered.status, 'dead');
|
|
756
|
-
assert.equal(recovered.evidenceSource, 'owner.json');
|
|
757
|
-
assert.equal(recovered.safeToRecover, false);
|
|
758
1219
|
assert.equal(recovered.recovered, false);
|
|
759
1220
|
assert.equal(recovered.action, 'none');
|
|
760
|
-
assert.
|
|
1221
|
+
assert.match(recovered.reason, /Atomic recovery move|recovery checkpoint/i);
|
|
761
1222
|
});
|
|
1223
|
+
assert.equal(displaced, true);
|
|
1224
|
+
assert.equal(await readFile(join(displacedPath, 'owner.json'), 'utf-8'), staleOwner);
|
|
1225
|
+
assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
|
|
1226
|
+
assert.equal(await readFile(successorTemp, 'utf-8'), successorOwner);
|
|
1227
|
+
assert.equal(existsSync(ownerPath), false);
|
|
762
1228
|
}
|
|
763
1229
|
finally {
|
|
764
1230
|
await rm(cwd, { recursive: true, force: true });
|
|
765
1231
|
}
|
|
766
1232
|
});
|
|
767
|
-
it('
|
|
768
|
-
|
|
769
|
-
t.skip('Linux process start identity is required for deterministic PID reuse protection.');
|
|
770
|
-
return;
|
|
771
|
-
}
|
|
772
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-sigkill-'));
|
|
773
|
-
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });
|
|
1233
|
+
it('rolls back a claim that lands on a displaced successor canonical owner', async () => {
|
|
1234
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-canonical-claim-race-'));
|
|
774
1235
|
try {
|
|
775
|
-
assert.ok(child.pid);
|
|
776
|
-
await once(child, 'spawn');
|
|
777
1236
|
const context = resolveSessionPointerContext(cwd);
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1237
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1238
|
+
const parkedPath = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1239
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1240
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1241
|
+
const staleOwner = JSON.stringify(validLockOwner());
|
|
1242
|
+
const successorPid = process.pid + 100_000;
|
|
1243
|
+
const successorOwner = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN, pid: successorPid }));
|
|
1244
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1245
|
+
let moved = false;
|
|
1246
|
+
let rollbackAttempted = false;
|
|
1247
|
+
await withPointerDependencies({
|
|
1248
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1249
|
+
probePid: () => 'dead',
|
|
1250
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
1251
|
+
if (from === context.lockPath && to === parkedPath) {
|
|
1252
|
+
await rename(from, to);
|
|
1253
|
+
await mkdir(context.lockPath);
|
|
1254
|
+
await writeFile(ownerPath, successorOwner, 'utf-8');
|
|
1255
|
+
moved = true;
|
|
1256
|
+
return 'moved';
|
|
1257
|
+
}
|
|
1258
|
+
if (from === parkedPath && to === context.lockPath) {
|
|
1259
|
+
rollbackAttempted = true;
|
|
1260
|
+
return 'not-moved';
|
|
1261
|
+
}
|
|
1262
|
+
if (existsSync(to))
|
|
1263
|
+
return 'not-moved';
|
|
1264
|
+
await rename(from, to);
|
|
1265
|
+
return 'moved';
|
|
1266
|
+
},
|
|
1267
|
+
}, async () => {
|
|
1268
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1269
|
+
assert.equal(recovered.recovered, false);
|
|
1270
|
+
assert.equal(recovered.action, 'none');
|
|
1271
|
+
assert.match(recovered.reason, /captured object|token-bound|source pathname/i);
|
|
1272
|
+
});
|
|
1273
|
+
assert.equal(moved, true);
|
|
1274
|
+
assert.equal(rollbackAttempted, true);
|
|
1275
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), successorOwner);
|
|
1276
|
+
assert.equal(await readFile(join(parkedPath, 'owner.json'), 'utf-8'), staleOwner);
|
|
1277
|
+
assert.equal(await readFile(quarantinePath, 'utf-8'), staleOwner);
|
|
1278
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
797
1279
|
}
|
|
798
1280
|
finally {
|
|
799
|
-
if (child.exitCode === null && child.signalCode === null)
|
|
800
|
-
child.kill('SIGKILL');
|
|
801
1281
|
await rm(cwd, { recursive: true, force: true });
|
|
802
1282
|
}
|
|
803
1283
|
});
|
|
804
|
-
it('
|
|
805
|
-
const
|
|
806
|
-
{ name: 'live-temp', files: [[`owner.${TEST_TOKEN}.tmp`, JSON.stringify(validLockOwner())]], probePid: 'alive', identity: matchingProcessIdentity() },
|
|
807
|
-
{ name: 'reused', files: [['owner.json', JSON.stringify(validLockOwner())]], probePid: 'alive', identity: { status: 'matching', startTicks: 2 } },
|
|
808
|
-
{ name: 'malformed', files: [['owner.json', '{']], probePid: 'dead' },
|
|
809
|
-
{ name: 'ambiguous', files: [['owner.json', JSON.stringify(validLockOwner())], [`owner.${SUCCESSOR_TOKEN}.tmp`, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }))]], probePid: 'dead' },
|
|
810
|
-
];
|
|
811
|
-
for (const testCase of cases) {
|
|
812
|
-
const cwd = await mkdtemp(join(tmpdir(), `omx-session-lock-recovery-${testCase.name}-`));
|
|
813
|
-
try {
|
|
814
|
-
const context = resolveSessionPointerContext(cwd);
|
|
815
|
-
await mkdir(context.lockPath, { recursive: true });
|
|
816
|
-
await Promise.all(testCase.files.map(async ([name, contents]) => await writeFile(join(context.lockPath, name), contents, 'utf-8')));
|
|
817
|
-
await withPointerDependencies({
|
|
818
|
-
token: () => SUCCESSOR_TOKEN,
|
|
819
|
-
probePid: () => testCase.probePid,
|
|
820
|
-
readProcessIdentity: () => testCase.identity ?? matchingProcessIdentity(),
|
|
821
|
-
}, async () => {
|
|
822
|
-
const before = await readdir(context.lockPath);
|
|
823
|
-
const recovered = await recoverSessionPointerLock(cwd);
|
|
824
|
-
assert.equal(recovered.recovered, false);
|
|
825
|
-
assert.equal(recovered.action, 'none');
|
|
826
|
-
assert.equal(recovered.safeToRecover, false);
|
|
827
|
-
assert.deepEqual(await readdir(context.lockPath), before);
|
|
828
|
-
});
|
|
829
|
-
}
|
|
830
|
-
finally {
|
|
831
|
-
await rm(cwd, { recursive: true, force: true });
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
});
|
|
835
|
-
it('claims stale evidence before quarantine so a successor cannot be mistaken for it', async () => {
|
|
836
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-race-'));
|
|
1284
|
+
it('live canonical successor survives a refused recovery claim and stays recognizable, releasable, and acquirable', async () => {
|
|
1285
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-live-successor-'));
|
|
837
1286
|
try {
|
|
838
1287
|
const context = resolveSessionPointerContext(cwd);
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
const
|
|
842
|
-
|
|
1288
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1289
|
+
const parkedPath = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1290
|
+
const successorPid = process.pid + 100_000;
|
|
1291
|
+
const successorOwner = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN, pid: successorPid }));
|
|
1292
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1293
|
+
let rollbackAttempted = false;
|
|
843
1294
|
await withPointerDependencies({
|
|
844
1295
|
token: () => SUCCESSOR_TOKEN,
|
|
845
|
-
probePid: () => 'dead',
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1296
|
+
probePid: (pid) => pid === successorPid ? 'alive' : 'dead',
|
|
1297
|
+
observeProcess: () => matchingObservation(),
|
|
1298
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
1299
|
+
if (from === context.lockPath && to === parkedPath) {
|
|
849
1300
|
await rename(from, to);
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1301
|
+
await mkdir(context.lockPath);
|
|
1302
|
+
await writeFile(ownerPath, successorOwner, 'utf-8');
|
|
1303
|
+
return 'moved';
|
|
1304
|
+
}
|
|
1305
|
+
if (from === parkedPath && to === context.lockPath) {
|
|
1306
|
+
rollbackAttempted = true;
|
|
1307
|
+
return 'not-moved';
|
|
1308
|
+
}
|
|
1309
|
+
if (existsSync(to))
|
|
1310
|
+
return 'not-moved';
|
|
1311
|
+
await rename(from, to);
|
|
1312
|
+
return 'moved';
|
|
855
1313
|
},
|
|
856
1314
|
}, async () => {
|
|
857
|
-
const
|
|
858
|
-
assert.equal(
|
|
1315
|
+
const refused = await recoverSessionPointerLock(cwd);
|
|
1316
|
+
assert.equal(refused.recovered, false);
|
|
1317
|
+
assert.equal(refused.action, 'none');
|
|
1318
|
+
const inspected = await inspectSessionPointerLock(cwd);
|
|
1319
|
+
assert.equal(inspected.status, 'live');
|
|
1320
|
+
assert.equal(inspected.safeToRecover, false);
|
|
1321
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), successorOwner);
|
|
1322
|
+
assert.deepEqual(await __releasePointerLockForTests(cwd, SUCCESSOR_TOKEN), []);
|
|
1323
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1324
|
+
const started = await writeSessionStart(cwd, 'sess-after-survival', { platform: 'win32' });
|
|
1325
|
+
assert.equal(started.session_id, 'sess-after-survival');
|
|
1326
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
859
1327
|
});
|
|
860
|
-
assert.equal(
|
|
861
|
-
assert.
|
|
862
|
-
assert.match(renames[0][1], new RegExp(`owner\\.${TEST_TOKEN}\\.${SUCCESSOR_TOKEN}\\.recovery$`));
|
|
863
|
-
assert.match(renames[1][0], new RegExp(`owner\\.${TEST_TOKEN}\\.${SUCCESSOR_TOKEN}\\.recovery$`));
|
|
864
|
-
assert.equal(renames[1][1].includes('.quarantine.'), true);
|
|
1328
|
+
assert.equal(rollbackAttempted, true);
|
|
1329
|
+
assert.equal(existsSync(parkedPath), true);
|
|
865
1330
|
}
|
|
866
1331
|
finally {
|
|
867
1332
|
await rm(cwd, { recursive: true, force: true });
|
|
868
1333
|
}
|
|
869
1334
|
});
|
|
870
|
-
it('
|
|
871
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-
|
|
1335
|
+
it('fails closed without mutating a foreign target when the lock directory is replaced by a symlink before the claim rename', async () => {
|
|
1336
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-lock-symlink-'));
|
|
1337
|
+
const foreign = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-foreign-'));
|
|
872
1338
|
try {
|
|
873
1339
|
const context = resolveSessionPointerContext(cwd);
|
|
874
|
-
const
|
|
875
|
-
const
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
await
|
|
879
|
-
|
|
1340
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1341
|
+
const foreignOwner = join(foreign, 'owner.json');
|
|
1342
|
+
const marker = JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }));
|
|
1343
|
+
let replaced = false;
|
|
1344
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1345
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => {
|
|
1346
|
+
if (!replaced && from === ownerPath) {
|
|
1347
|
+
replaced = true;
|
|
1348
|
+
await rm(context.lockPath, { recursive: true });
|
|
1349
|
+
await writeFile(foreignOwner, marker);
|
|
1350
|
+
await symlink(foreign, context.lockPath);
|
|
1351
|
+
}
|
|
1352
|
+
await link(from, to);
|
|
1353
|
+
} } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1354
|
+
assert.equal(await readFile(foreignOwner, 'utf-8'), marker);
|
|
1355
|
+
assert.equal((await readdir(foreign)).some((entry) => entry.includes('.quarantine.')), false);
|
|
1356
|
+
}
|
|
1357
|
+
finally {
|
|
1358
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1359
|
+
await rm(foreign, { recursive: true, force: true });
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
it('does not follow an evidence symlink swapped in before the claim rename', async () => {
|
|
1363
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-evidence-symlink-'));
|
|
1364
|
+
try {
|
|
1365
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1366
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1367
|
+
const foreign = join(cwd, 'foreign-owner');
|
|
1368
|
+
const marker = 'foreign evidence marker';
|
|
1369
|
+
let replaced = false;
|
|
1370
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1371
|
+
await writeFile(foreign, marker);
|
|
1372
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => {
|
|
1373
|
+
if (!replaced && from === ownerPath) {
|
|
1374
|
+
replaced = true;
|
|
1375
|
+
await rm(ownerPath);
|
|
1376
|
+
await symlink(foreign, ownerPath);
|
|
1377
|
+
}
|
|
1378
|
+
await link(from, to);
|
|
1379
|
+
} } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1380
|
+
assert.equal(await readFile(foreign, 'utf-8'), marker);
|
|
1381
|
+
assert.equal(await readlink(ownerPath), foreign);
|
|
1382
|
+
}
|
|
1383
|
+
finally {
|
|
1384
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
it('preserves a non-file evidence replacement as exact recovery residue without clobbering', async () => {
|
|
1388
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-evidence-directory-'));
|
|
1389
|
+
try {
|
|
1390
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1391
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1392
|
+
const marker = join(ownerPath, 'marker');
|
|
1393
|
+
let replaced = false;
|
|
1394
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1395
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => {
|
|
1396
|
+
if (!replaced && from === ownerPath) {
|
|
1397
|
+
replaced = true;
|
|
1398
|
+
await rm(ownerPath);
|
|
1399
|
+
await mkdir(ownerPath);
|
|
1400
|
+
await writeFile(marker, 'directory marker');
|
|
1401
|
+
}
|
|
1402
|
+
await link(from, to);
|
|
1403
|
+
} } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1404
|
+
assert.equal(await readFile(marker, 'utf-8'), 'directory marker');
|
|
1405
|
+
}
|
|
1406
|
+
finally {
|
|
1407
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
it('retries a transient token-bound park collision without allocating a second parked name', async () => {
|
|
1411
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-park-retry-'));
|
|
1412
|
+
try {
|
|
1413
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1414
|
+
const parkedPath = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1415
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1416
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1417
|
+
const foreignMarker = 'foreign transient park destination';
|
|
1418
|
+
const parkAttempts = [];
|
|
1419
|
+
let tokenCalls = 0;
|
|
1420
|
+
let collision = true;
|
|
1421
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
880
1422
|
await withPointerDependencies({
|
|
881
|
-
token: () =>
|
|
1423
|
+
token: () => {
|
|
1424
|
+
tokenCalls += 1;
|
|
1425
|
+
return SUCCESSOR_TOKEN;
|
|
1426
|
+
},
|
|
882
1427
|
probePid: () => 'dead',
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
await mkdir(
|
|
889
|
-
await writeFile(
|
|
1428
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
1429
|
+
if (from === context.lockPath && to === parkedPath) {
|
|
1430
|
+
parkAttempts.push(to);
|
|
1431
|
+
if (collision) {
|
|
1432
|
+
collision = false;
|
|
1433
|
+
await mkdir(to);
|
|
1434
|
+
await writeFile(join(to, 'foreign'), foreignMarker, 'utf-8');
|
|
1435
|
+
return 'not-moved';
|
|
890
1436
|
}
|
|
891
|
-
|
|
892
|
-
|
|
1437
|
+
}
|
|
1438
|
+
if (existsSync(to))
|
|
1439
|
+
return 'not-moved';
|
|
1440
|
+
await rename(from, to);
|
|
1441
|
+
return 'moved';
|
|
893
1442
|
},
|
|
894
1443
|
}, async () => {
|
|
1444
|
+
const refused = await recoverSessionPointerLock(cwd);
|
|
1445
|
+
assert.equal(refused.recovered, false);
|
|
1446
|
+
assert.equal(refused.action, 'none');
|
|
1447
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
1448
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
1449
|
+
assert.equal(await readFile(join(parkedPath, 'foreign'), 'utf-8'), foreignMarker);
|
|
1450
|
+
await rm(parkedPath, { recursive: true, force: true });
|
|
895
1451
|
const recovered = await recoverSessionPointerLock(cwd);
|
|
896
|
-
assert.equal(recovered.recovered,
|
|
897
|
-
assert.equal(recovered.action, '
|
|
898
|
-
assert.match(recovered.reason, /changed before recovery claim/i);
|
|
1452
|
+
assert.equal(recovered.recovered, true, recovered.reason);
|
|
1453
|
+
assert.equal(recovered.action, 'quarantined');
|
|
899
1454
|
});
|
|
900
|
-
assert.equal(
|
|
901
|
-
assert.deepEqual(
|
|
902
|
-
assert.equal(
|
|
1455
|
+
assert.equal(tokenCalls, 1);
|
|
1456
|
+
assert.deepEqual(parkAttempts, [parkedPath, parkedPath]);
|
|
1457
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1458
|
+
assert.equal(existsSync(parkedPath), true);
|
|
1459
|
+
assert.equal(existsSync(quarantinePath), true);
|
|
1460
|
+
assert.equal(existsSync(checkpointPath), false);
|
|
1461
|
+
assert.equal(existsSync(checkpointPath.replace(/\.json$/, '.completed')), true);
|
|
903
1462
|
}
|
|
904
1463
|
finally {
|
|
905
1464
|
await rm(cwd, { recursive: true, force: true });
|
|
906
1465
|
}
|
|
907
1466
|
});
|
|
908
|
-
it('
|
|
909
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-
|
|
1467
|
+
it('rolls back a claim when checkpoint creation fails', async () => {
|
|
1468
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-write-fail-'));
|
|
910
1469
|
try {
|
|
911
1470
|
const context = resolveSessionPointerContext(cwd);
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1471
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1472
|
+
let fail = true;
|
|
1473
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { writeFile: async (path, data, options) => { if (fail && path.includes('.recovery.')) {
|
|
1474
|
+
fail = false;
|
|
1475
|
+
throw codedError('EBUSY');
|
|
1476
|
+
} await writeFile(path, data, options); } } }, async () => {
|
|
1477
|
+
assert.equal((await recoverSessionPointerLock(cwd)).recovered, false);
|
|
1478
|
+
assert.deepEqual(await readdir(context.lockPath), ['owner.json']);
|
|
1479
|
+
assert.equal((await recoverSessionPointerLock(cwd)).recovered, true);
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
finally {
|
|
1483
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
it('rolls back a double-EBUSY evidence park failure so a normal retry succeeds', async () => {
|
|
1487
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-'));
|
|
1488
|
+
try {
|
|
1489
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1490
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1491
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1492
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1493
|
+
const staleOwner = JSON.stringify(validLockOwner());
|
|
1494
|
+
let failures = 2;
|
|
1495
|
+
let tokenCalls = 0;
|
|
1496
|
+
let linkAttempts = 0;
|
|
1497
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
918
1498
|
await withPointerDependencies({
|
|
919
|
-
token: () =>
|
|
1499
|
+
token: () => {
|
|
1500
|
+
tokenCalls += 1;
|
|
1501
|
+
return SUCCESSOR_TOKEN;
|
|
1502
|
+
},
|
|
920
1503
|
probePid: () => 'dead',
|
|
921
1504
|
fs: {
|
|
922
|
-
|
|
923
|
-
if (
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
await writeFile(successorTemp, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })), 'utf-8');
|
|
1505
|
+
link: async (from, to) => {
|
|
1506
|
+
if (to === quarantinePath && failures > 0) {
|
|
1507
|
+
failures -= 1;
|
|
1508
|
+
linkAttempts += 1;
|
|
1509
|
+
throw codedError('EBUSY');
|
|
928
1510
|
}
|
|
929
|
-
await
|
|
1511
|
+
await link(from, to);
|
|
930
1512
|
},
|
|
931
1513
|
},
|
|
932
1514
|
}, async () => {
|
|
1515
|
+
const first = await recoverSessionPointerLock(cwd);
|
|
1516
|
+
assert.equal(first.recovered, false);
|
|
1517
|
+
assert.equal(first.action, 'none');
|
|
1518
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
1519
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), staleOwner);
|
|
1520
|
+
assert.equal(existsSync(quarantinePath), false);
|
|
1521
|
+
const second = await recoverSessionPointerLock(cwd);
|
|
1522
|
+
assert.equal(second.recovered, false);
|
|
1523
|
+
assert.equal(second.action, 'none');
|
|
1524
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
1525
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), staleOwner);
|
|
1526
|
+
assert.equal(existsSync(quarantinePath), false);
|
|
933
1527
|
const recovered = await recoverSessionPointerLock(cwd);
|
|
934
|
-
assert.equal(recovered.recovered,
|
|
935
|
-
assert.equal(recovered.action, '
|
|
936
|
-
assert.match(recovered.reason, /canonical lock directory was not empty/i);
|
|
937
|
-
assert.ok(recovered.quarantinePath);
|
|
1528
|
+
assert.equal(recovered.recovered, true, recovered.reason);
|
|
1529
|
+
assert.equal(recovered.action, 'quarantined');
|
|
938
1530
|
});
|
|
939
|
-
assert.equal(
|
|
940
|
-
assert.
|
|
941
|
-
assert.equal(
|
|
1531
|
+
assert.equal(tokenCalls, 1);
|
|
1532
|
+
assert.equal(linkAttempts, 2);
|
|
1533
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1534
|
+
assert.equal(await readFile(quarantinePath, 'utf-8'), staleOwner);
|
|
1535
|
+
assert.equal(existsSync(checkpointPath), false);
|
|
1536
|
+
assert.equal(existsSync(checkpointPath.replace(/\.json$/, '.completed')), true);
|
|
942
1537
|
}
|
|
943
1538
|
finally {
|
|
944
1539
|
await rm(cwd, { recursive: true, force: true });
|
|
945
1540
|
}
|
|
946
1541
|
});
|
|
947
|
-
it('
|
|
948
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-
|
|
1542
|
+
it('resumes an evidence-pending checkpoint without minting another recovery token', async () => {
|
|
1543
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-resume-'));
|
|
949
1544
|
try {
|
|
1545
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1546
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1547
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1548
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1549
|
+
const owner = JSON.stringify(validLockOwner());
|
|
950
1550
|
await writeLockOwner(cwd, validLockOwner());
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
await
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
}, async () => {
|
|
960
|
-
await assert.rejects(writeSessionStart(cwd, 'sess-timeout', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
961
|
-
&& error.code === 'session_pointer_lock_timeout'
|
|
962
|
-
&& error.lockOwnerStatus === 'live');
|
|
1551
|
+
await link(ownerPath, claimPath);
|
|
1552
|
+
await rename(ownerPath, parkPath);
|
|
1553
|
+
const parked = await lstat(parkPath);
|
|
1554
|
+
const lock = await lstat(context.lockPath);
|
|
1555
|
+
await writeFile(`${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1556
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must reuse checkpoint token'); }, probePid: () => 'dead' }, async () => {
|
|
1557
|
+
const resumed = await recoverSessionPointerLock(cwd);
|
|
1558
|
+
assert.equal(resumed.recovered, true);
|
|
963
1559
|
});
|
|
964
|
-
assert.
|
|
965
|
-
assert.equal(
|
|
966
|
-
assert.equal(existsSync(resolveSessionPointerContext(cwd).lockPath), true);
|
|
1560
|
+
assert.equal(await readFile(`${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`, 'utf-8'), owner);
|
|
1561
|
+
assert.equal(existsSync(`${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`), false);
|
|
967
1562
|
}
|
|
968
1563
|
finally {
|
|
969
1564
|
await rm(cwd, { recursive: true, force: true });
|
|
970
1565
|
}
|
|
971
1566
|
});
|
|
972
|
-
it('
|
|
973
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
1567
|
+
it('resumes the actual distinct-token evidence park path after an interruption', async () => {
|
|
1568
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-distinct-park-token-'));
|
|
974
1569
|
try {
|
|
975
1570
|
const context = resolveSessionPointerContext(cwd);
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1571
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1572
|
+
const claimToken = 'claim_token_123456789';
|
|
1573
|
+
const parkToken = 'park_token_123456789';
|
|
1574
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${claimToken}.recovery`);
|
|
1575
|
+
const parkPath = `${context.lockPath}.parked-${parkToken}`;
|
|
1576
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1577
|
+
await link(ownerPath, claimPath);
|
|
1578
|
+
await rename(ownerPath, parkPath);
|
|
1579
|
+
const parked = await lstat(parkPath);
|
|
1580
|
+
const lock = await lstat(context.lockPath);
|
|
1581
|
+
await writeFile(`${context.lockPath}.recovery.${TEST_TOKEN}.${claimToken}.json`, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${claimToken}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1582
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint a replacement token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
|
|
1583
|
+
assert.equal(await readFile(`${context.lockPath}.quarantine.${TEST_TOKEN}.${claimToken}`, 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1584
|
+
}
|
|
1585
|
+
finally {
|
|
1586
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
it('resumes a checkpoint written before evidence parking', async () => {
|
|
1590
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-source-checkpoint-'));
|
|
1591
|
+
try {
|
|
1592
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1593
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1594
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1595
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1596
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1597
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1598
|
+
await link(ownerPath, claimPath);
|
|
1599
|
+
const owner = await lstat(ownerPath);
|
|
1600
|
+
const lock = await lstat(context.lockPath);
|
|
1601
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: owner.dev, ino: owner.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1602
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
|
|
990
1603
|
assert.equal(existsSync(context.lockPath), false);
|
|
991
|
-
|
|
992
|
-
token: () => TEST_TOKEN,
|
|
993
|
-
fs: {
|
|
994
|
-
writeFile: async (path, data, options) => {
|
|
995
|
-
if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
|
|
996
|
-
throw new Error('owner write failure');
|
|
997
|
-
await writeFile(path, data, options);
|
|
998
|
-
},
|
|
999
|
-
rmdir: async (path) => {
|
|
1000
|
-
if (path === context.lockPath)
|
|
1001
|
-
throw new Error('keep lock evidence');
|
|
1002
|
-
await rm(path, { recursive: false });
|
|
1003
|
-
},
|
|
1004
|
-
},
|
|
1005
|
-
}, async () => {
|
|
1006
|
-
await assert.rejects(writeSessionStart(cwd, 'sess-owner-residue', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
1007
|
-
&& error.code === 'session_pointer_lock_recovery_required'
|
|
1008
|
-
&& error.primaryOperation === 'lock-owner-publish'
|
|
1009
|
-
&& error.secondaryFailures?.[0]?.phase === 'remove-unpublished-lock');
|
|
1010
|
-
});
|
|
1011
|
-
assert.equal(existsSync(context.lockPath), true);
|
|
1604
|
+
assert.equal(existsSync(checkpointPath), false);
|
|
1012
1605
|
}
|
|
1013
1606
|
finally {
|
|
1014
1607
|
await rm(cwd, { recursive: true, force: true });
|
|
1015
1608
|
}
|
|
1016
1609
|
});
|
|
1017
|
-
it('
|
|
1018
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
1019
|
-
let syncCalls = 0;
|
|
1020
|
-
const regularFileSync = async (platform) => {
|
|
1021
|
-
assert.equal(platform, 'win32');
|
|
1022
|
-
syncCalls += 1;
|
|
1023
|
-
throw codedError('EPERM');
|
|
1024
|
-
};
|
|
1610
|
+
it('resumes after quarantine hard-link creation but before claim and park cleanup', async () => {
|
|
1611
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-dual-evidence-checkpoint-'));
|
|
1025
1612
|
try {
|
|
1026
|
-
const state = await writeSessionStart(cwd, 'sess-windows-eperm', {
|
|
1027
|
-
platform: 'win32',
|
|
1028
|
-
regularFileSync,
|
|
1029
|
-
});
|
|
1030
1613
|
const context = resolveSessionPointerContext(cwd);
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1614
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1615
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1616
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1617
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1618
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1619
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1620
|
+
await link(ownerPath, claimPath);
|
|
1621
|
+
await rename(ownerPath, parkPath);
|
|
1622
|
+
await link(parkPath, quarantinePath);
|
|
1623
|
+
const parked = await lstat(parkPath);
|
|
1624
|
+
const lock = await lstat(context.lockPath);
|
|
1625
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1626
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
|
|
1627
|
+
assert.equal(await readFile(quarantinePath, 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1628
|
+
assert.equal(existsSync(checkpointPath), false);
|
|
1035
1629
|
}
|
|
1036
1630
|
finally {
|
|
1037
1631
|
await rm(cwd, { recursive: true, force: true });
|
|
1038
1632
|
}
|
|
1039
1633
|
});
|
|
1040
|
-
it('
|
|
1041
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
1042
|
-
const originalWrite = process.stderr.write;
|
|
1043
|
-
const warnings = [];
|
|
1044
|
-
process.stderr.write = ((value) => {
|
|
1045
|
-
warnings.push({ value, lockExists: existsSync(resolveSessionPointerContext(cwd).lockPath) });
|
|
1046
|
-
return true;
|
|
1047
|
-
});
|
|
1048
|
-
const regularFileSync = async () => { throw codedError('EPERM'); };
|
|
1634
|
+
it('reports absent recovery on a fresh workspace without a state directory', async () => {
|
|
1635
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-fresh-'));
|
|
1049
1636
|
try {
|
|
1050
|
-
await
|
|
1051
|
-
assert.
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
await
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1637
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1638
|
+
assert.equal(recovered.recovered, false);
|
|
1639
|
+
assert.equal(recovered.status, 'absent');
|
|
1640
|
+
assert.match(recovered.reason, /No session pointer lock exists/);
|
|
1641
|
+
}
|
|
1642
|
+
finally {
|
|
1643
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
it('fails closed when recovery checkpoint discovery cannot be enumerated', async () => {
|
|
1647
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-readdir-error-'));
|
|
1648
|
+
try {
|
|
1649
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1650
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1651
|
+
await withPointerDependencies({ probePid: () => 'dead', fs: { readdir: async () => { throw codedError('EACCES'); } } }, async () => {
|
|
1652
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1653
|
+
assert.equal(recovered.recovered, false);
|
|
1654
|
+
assert.equal(recovered.status, 'io-error');
|
|
1655
|
+
assert.match(recovered.reason, /enumerate.*checkpoints/i);
|
|
1068
1656
|
});
|
|
1069
|
-
assert.deepEqual(
|
|
1657
|
+
assert.deepEqual(await readdir(context.lockPath), ['owner.json']);
|
|
1070
1658
|
}
|
|
1071
1659
|
finally {
|
|
1072
|
-
process.stderr.write = originalWrite;
|
|
1073
1660
|
await rm(cwd, { recursive: true, force: true });
|
|
1074
1661
|
}
|
|
1075
1662
|
});
|
|
1076
|
-
it('
|
|
1077
|
-
const
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1663
|
+
it('fails closed for malformed checkpoint JSON without minting a recovery token', async () => {
|
|
1664
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-malformed-checkpoint-'));
|
|
1665
|
+
try {
|
|
1666
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1667
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1668
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1669
|
+
await writeFile(checkpointPath, '{not json', 'utf-8');
|
|
1670
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => {
|
|
1671
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1672
|
+
assert.equal(recovered.recovered, false);
|
|
1673
|
+
assert.equal(recovered.safeToRecover, false);
|
|
1674
|
+
});
|
|
1675
|
+
assert.equal(await readFile(checkpointPath, 'utf-8'), '{not json');
|
|
1676
|
+
assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1677
|
+
}
|
|
1678
|
+
finally {
|
|
1679
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
for (const [label, checkpoint] of [
|
|
1683
|
+
['wrong version', { version: 2, phase: 'evidence-pending', sourcePath: 'x', parkPath: 'x', identity: { dev: 0, ino: 0 } }],
|
|
1684
|
+
['wrong phase', { version: 1, phase: 'claim-pending', sourcePath: 'x', parkPath: 'x', identity: { dev: 0, ino: 0 } }],
|
|
1685
|
+
['out-of-root path', { version: 1, phase: 'evidence-pending', sourcePath: '/foreign/owner.json', parkPath: '/foreign/parked', identity: { dev: 0, ino: 0 } }],
|
|
1686
|
+
])
|
|
1687
|
+
it(`fails closed for ${label} checkpoint`, async () => {
|
|
1688
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-invalid-checkpoint-'));
|
|
1689
|
+
try {
|
|
1690
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1691
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1692
|
+
const path = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1693
|
+
await writeFile(path, JSON.stringify(checkpoint), 'utf-8');
|
|
1694
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1695
|
+
assert.equal(await readFile(path, 'utf-8'), JSON.stringify(checkpoint));
|
|
1696
|
+
assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1697
|
+
}
|
|
1698
|
+
finally {
|
|
1699
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
it('refuses multiple checkpoints without mutating either', async () => {
|
|
1703
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-multiple-checkpoints-'));
|
|
1704
|
+
try {
|
|
1705
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1706
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1707
|
+
for (const token of [SUCCESSOR_TOKEN, FOREIGN_TOKEN])
|
|
1708
|
+
await writeFile(`${context.lockPath}.recovery.${TEST_TOKEN}.${token}.json`, '{}', 'utf-8');
|
|
1709
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => assert.match((await recoverSessionPointerLock(cwd)).reason, /Multiple recovery checkpoints/));
|
|
1710
|
+
assert.equal(existsSync(join(context.lockPath, 'owner.json')), true);
|
|
1711
|
+
}
|
|
1712
|
+
finally {
|
|
1713
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
it('preserves a checkpoint when its claim is missing', async () => {
|
|
1717
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-missing-claim-'));
|
|
1718
|
+
try {
|
|
1719
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1720
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
1721
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1722
|
+
await writeFile(parkPath, 'stale', 'utf-8');
|
|
1723
|
+
const stat = await lstat(parkPath);
|
|
1724
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1725
|
+
const lock = await lstat(context.lockPath);
|
|
1726
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: join(context.lockPath, 'owner.json'), parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: stat.dev, ino: stat.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1727
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint token'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1728
|
+
assert.equal(await readFile(checkpointPath, 'utf-8').then(Boolean), true);
|
|
1729
|
+
assert.equal(await readFile(parkPath, 'utf-8'), 'stale');
|
|
1730
|
+
}
|
|
1731
|
+
finally {
|
|
1732
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1735
|
+
it('refuses an advanced checkpoint when a live successor replaces the bound lock directory', async () => {
|
|
1736
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-checkpoint-successor-swap-'));
|
|
1737
|
+
try {
|
|
1738
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1739
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1740
|
+
const claimPath = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1741
|
+
const parkPath = `${context.lockPath}.parked-${SUCCESSOR_TOKEN}`;
|
|
1742
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1743
|
+
const quarantinePath = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1744
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1745
|
+
await link(ownerPath, claimPath);
|
|
1746
|
+
await rename(ownerPath, parkPath);
|
|
1747
|
+
const parked = await lstat(parkPath);
|
|
1748
|
+
const lock = await lstat(context.lockPath);
|
|
1749
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 1, sourcePath: ownerPath, parkPath, lockParkPath: `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`, identity: { dev: parked.dev, ino: parked.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, phase: 'evidence-pending' }));
|
|
1750
|
+
await rename(parkPath, quarantinePath);
|
|
1751
|
+
await rm(claimPath);
|
|
1752
|
+
await rename(context.lockPath, `${context.lockPath}.displaced`);
|
|
1753
|
+
const successorPid = process.pid + 100_000;
|
|
1754
|
+
const successor = JSON.stringify(validLockOwner({ token: FOREIGN_TOKEN, pid: successorPid }));
|
|
1755
|
+
await mkdir(context.lockPath);
|
|
1756
|
+
await writeFile(ownerPath, successor, 'utf-8');
|
|
1757
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint'); }, probePid: (pid) => pid === successorPid ? 'alive' : 'dead', observeProcess: () => matchingObservation() }, async () => {
|
|
1758
|
+
const refused = await recoverSessionPointerLock(cwd);
|
|
1759
|
+
assert.equal(refused.recovered, false);
|
|
1760
|
+
assert.match(refused.reason, /recovery state/i);
|
|
1761
|
+
assert.deepEqual(await readdir(context.lockPath), ['owner.json']);
|
|
1762
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), successor);
|
|
1763
|
+
assert.deepEqual(await __releasePointerLockForTests(cwd, FOREIGN_TOKEN), []);
|
|
1764
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1765
|
+
});
|
|
1766
|
+
assert.equal(await readFile(quarantinePath, 'utf-8'), JSON.stringify(validLockOwner()));
|
|
1767
|
+
assert.equal(existsSync(checkpointPath), true);
|
|
1768
|
+
}
|
|
1769
|
+
finally {
|
|
1770
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
it('does not remove a live successor swapped in before final checkpoint lock removal', async () => {
|
|
1774
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-final-successor-'));
|
|
1775
|
+
try {
|
|
1776
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1777
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1778
|
+
const checkpointPath = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1779
|
+
const lockPark = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1780
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1781
|
+
const owner = await lstat(ownerPath);
|
|
1782
|
+
const lock = await lstat(context.lockPath);
|
|
1783
|
+
await writeFile(checkpointPath, JSON.stringify({ version: 3, sourcePath: ownerPath, identity: { dev: owner.dev, ino: owner.ino }, evidenceIdentity: { dev: owner.dev, ino: owner.ino }, evidenceBytes: JSON.stringify(validLockOwner()), lockIdentity: { dev: lock.dev, ino: lock.ino }, lockParkPath: lockPark, phase: 'evidence-pending' }));
|
|
1784
|
+
const successor = JSON.stringify(validLockOwner({ token: FOREIGN_TOKEN, pid: process.pid + 100_000 }));
|
|
1785
|
+
let moved = false;
|
|
1786
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint'); }, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => {
|
|
1787
|
+
if (from === context.lockPath && to === lockPark) {
|
|
1788
|
+
await rename(from, to);
|
|
1789
|
+
await mkdir(context.lockPath);
|
|
1790
|
+
await writeFile(ownerPath, successor);
|
|
1791
|
+
moved = true;
|
|
1792
|
+
return 'moved';
|
|
1793
|
+
}
|
|
1794
|
+
return 'not-moved';
|
|
1795
|
+
} }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1796
|
+
assert.equal(moved, true);
|
|
1797
|
+
assert.equal(await readFile(ownerPath, 'utf8'), successor);
|
|
1798
|
+
assert.deepEqual(await __releasePointerLockForTests(cwd, FOREIGN_TOKEN), []);
|
|
1799
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
1800
|
+
assert.equal(existsSync(lockPark), true);
|
|
1801
|
+
}
|
|
1802
|
+
finally {
|
|
1803
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
it('refuses a quarantine path that appears after its preflight check without overwriting it', async () => {
|
|
1807
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-quarantine-race-'));
|
|
1808
|
+
try {
|
|
1809
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1810
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1811
|
+
const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1812
|
+
const foreign = 'foreign quarantine marker';
|
|
1813
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1814
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => { if (to === quarantine)
|
|
1815
|
+
await writeFile(to, foreign); await link(from, to); } } }, async () => {
|
|
1816
|
+
const result = await recoverSessionPointerLock(cwd);
|
|
1817
|
+
assert.equal(result.recovered, false);
|
|
1818
|
+
assert.match(result.reason, /EEXIST|quarantine/i);
|
|
1819
|
+
});
|
|
1820
|
+
assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1821
|
+
assert.equal(await readFile(quarantine, 'utf8'), foreign);
|
|
1822
|
+
}
|
|
1823
|
+
finally {
|
|
1824
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
it('fails closed without mutation when a recovery claim destination appears before the no-clobber claim link', async () => {
|
|
1828
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-claim-exist-'));
|
|
1829
|
+
try {
|
|
1830
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1831
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1832
|
+
const claim = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1833
|
+
let moved = false;
|
|
1834
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1835
|
+
await withPointerDependencies({
|
|
1836
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1837
|
+
probePid: () => 'dead',
|
|
1838
|
+
fs: {
|
|
1839
|
+
writeFile: async (path, data, options) => {
|
|
1840
|
+
await writeFile(path, data, options);
|
|
1841
|
+
if (path.includes('.recovery.') && path.endsWith('.json'))
|
|
1842
|
+
await writeFile(claim, 'foreign claim');
|
|
1102
1843
|
},
|
|
1844
|
+
},
|
|
1845
|
+
atomicRenameNoReplace: withProbeFallback(context.lockPath, async () => {
|
|
1846
|
+
moved = true;
|
|
1847
|
+
return 'moved';
|
|
1103
1848
|
}),
|
|
1104
|
-
},
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1849
|
+
}, async () => {
|
|
1850
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1851
|
+
assert.equal(recovered.recovered, false, recovered.reason);
|
|
1852
|
+
assert.match(recovered.reason, /foreign|changed|claim/i);
|
|
1853
|
+
});
|
|
1854
|
+
assert.equal(moved, false);
|
|
1855
|
+
assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1856
|
+
assert.equal(await readFile(claim, 'utf8'), 'foreign claim');
|
|
1857
|
+
}
|
|
1858
|
+
finally {
|
|
1859
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
it('preserves a swapped parked quarantine claim rather than unlinking it', async () => {
|
|
1863
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-parked-claim-swap-'));
|
|
1864
|
+
try {
|
|
1865
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1866
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1867
|
+
const lockPark = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1868
|
+
const claim = join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`);
|
|
1869
|
+
const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1870
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1871
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1872
|
+
const owner = await lstat(ownerPath);
|
|
1873
|
+
const lock = await lstat(context.lockPath);
|
|
1874
|
+
await link(ownerPath, claim);
|
|
1875
|
+
await link(ownerPath, quarantine);
|
|
1876
|
+
await rm(claim);
|
|
1877
|
+
await writeFile(claim, 'foreign parked claim');
|
|
1878
|
+
await rename(context.lockPath, lockPark);
|
|
1879
|
+
await writeFile(checkpoint, JSON.stringify({ version: 2, sourcePath: ownerPath, identity: { dev: owner.dev, ino: owner.ino }, lockIdentity: { dev: lock.dev, ino: lock.ino }, lockParkPath: lockPark, phase: 'evidence-quarantined' }));
|
|
1880
|
+
await withPointerDependencies({ token: () => { throw new Error('resume must not mint'); }, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => { if (from === checkpoint) {
|
|
1881
|
+
await rename(from, to);
|
|
1882
|
+
return 'moved';
|
|
1883
|
+
} throw new Error('directory move must not occur'); } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, true));
|
|
1884
|
+
assert.equal(await readFile(join(lockPark, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`), 'utf8'), 'foreign parked claim');
|
|
1885
|
+
assert.equal(await readFile(quarantine, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1886
|
+
assert.equal(existsSync(lockPark), true);
|
|
1887
|
+
}
|
|
1888
|
+
finally {
|
|
1889
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
it('fails closed without mutation when evidence lstat fails non-ENOENT before the claim', async () => {
|
|
1893
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-evidence-eacces-'));
|
|
1894
|
+
try {
|
|
1895
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1896
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1897
|
+
const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1898
|
+
let armed = false;
|
|
1899
|
+
let moved = false;
|
|
1900
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1901
|
+
await withPointerDependencies({
|
|
1902
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1903
|
+
probePid: () => 'dead',
|
|
1904
|
+
fs: {
|
|
1905
|
+
link: async (from, to) => {
|
|
1906
|
+
await link(from, to);
|
|
1907
|
+
if (to === quarantine)
|
|
1908
|
+
armed = true;
|
|
1909
|
+
},
|
|
1910
|
+
lstat: async (path) => {
|
|
1911
|
+
if (armed && path === ownerPath)
|
|
1912
|
+
throw codedError('EACCES');
|
|
1913
|
+
return await lstat(path);
|
|
1116
1914
|
},
|
|
1915
|
+
},
|
|
1916
|
+
atomicRenameNoReplace: withProbeFallback(context.lockPath, async () => {
|
|
1917
|
+
moved = true;
|
|
1918
|
+
return 'moved';
|
|
1117
1919
|
}),
|
|
1118
|
-
},
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1920
|
+
}, async () => {
|
|
1921
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
1922
|
+
assert.equal(recovered.recovered, false, recovered.reason);
|
|
1923
|
+
});
|
|
1924
|
+
assert.equal(moved, false);
|
|
1925
|
+
assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1926
|
+
}
|
|
1927
|
+
finally {
|
|
1928
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1929
|
+
}
|
|
1930
|
+
});
|
|
1931
|
+
it('restores evidence and keeps the exact quarantine inspect diagnostic when quarantine lstat fails non-ENOENT', async () => {
|
|
1932
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-quarantine-eacces-'));
|
|
1933
|
+
try {
|
|
1934
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1935
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
1936
|
+
const quarantine = `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`;
|
|
1937
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1938
|
+
let armed = false;
|
|
1939
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1940
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', fs: { link: async (from, to) => { await link(from, to); if (to === quarantine)
|
|
1941
|
+
armed = true; }, lstat: async (path) => { if (armed && path === quarantine)
|
|
1942
|
+
throw codedError('EACCES'); return await lstat(path); } } }, async () => { const result = await recoverSessionPointerLock(cwd); assert.equal(result.recovered, false); assert.match(result.reason, /EACCES/); });
|
|
1943
|
+
assert.equal(await readFile(ownerPath, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1944
|
+
assert.equal(await readFile(quarantine, 'utf8'), JSON.stringify(validLockOwner()));
|
|
1945
|
+
assert.equal(existsSync(checkpoint), true);
|
|
1946
|
+
}
|
|
1947
|
+
finally {
|
|
1948
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
it('preserves a swapped parked lock directory rather than removing it', async () => {
|
|
1952
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-directory-swap-'));
|
|
1953
|
+
try {
|
|
1954
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1955
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
1956
|
+
const original = `${parked}.original`;
|
|
1957
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1958
|
+
await withPointerDependencies({
|
|
1959
|
+
token: () => SUCCESSOR_TOKEN,
|
|
1960
|
+
probePid: () => 'dead',
|
|
1961
|
+
atomicRenameNoReplace: withProbeFallback(context.lockPath, async (from, to) => {
|
|
1962
|
+
if (from === context.lockPath && to === parked) {
|
|
1963
|
+
await rename(from, original);
|
|
1964
|
+
await mkdir(from);
|
|
1965
|
+
await writeFile(join(from, 'foreign'), 'foreign');
|
|
1966
|
+
await rename(from, to);
|
|
1967
|
+
return 'moved';
|
|
1968
|
+
}
|
|
1969
|
+
return await defaultTestAtomicRenameNoReplace(from, to);
|
|
1970
|
+
}),
|
|
1971
|
+
}, async () => {
|
|
1972
|
+
assert.equal((await recoverSessionPointerLock(cwd)).recovered, false);
|
|
1973
|
+
});
|
|
1974
|
+
assert.equal(await readFile(join(parked, 'foreign'), 'utf8'), 'foreign');
|
|
1975
|
+
assert.equal(existsSync(join(original, 'owner.json')), true);
|
|
1976
|
+
}
|
|
1977
|
+
finally {
|
|
1978
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1981
|
+
for (const [label, checkpoint] of [
|
|
1982
|
+
['wrong version', { version: 99, phase: 'evidence-pending', sourcePath: 'x', identity: { dev: 0, ino: 0 } }],
|
|
1983
|
+
['wrong phase', { version: 1, phase: 'claim-pending', sourcePath: 'x', identity: { dev: 0, ino: 0 } }],
|
|
1984
|
+
['out-of-root path', { version: 1, phase: 'evidence-pending', sourcePath: '/foreign/owner.json', identity: { dev: 0, ino: 0 } }],
|
|
1985
|
+
])
|
|
1986
|
+
it(`fails closed for ${label} checkpoint`, async () => {
|
|
1987
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-invalid-checkpoint-'));
|
|
1988
|
+
try {
|
|
1989
|
+
const context = resolveSessionPointerContext(cwd);
|
|
1990
|
+
const path = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
1991
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
1992
|
+
await writeFile(path, JSON.stringify(checkpoint));
|
|
1993
|
+
await withPointerDependencies({ token: () => { throw new Error('must not mint'); }, probePid: () => 'dead' }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
1994
|
+
assert.equal(await readFile(path, 'utf8'), JSON.stringify(checkpoint));
|
|
1995
|
+
assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf8'), JSON.stringify(validLockOwner()));
|
|
1996
|
+
}
|
|
1997
|
+
finally {
|
|
1998
|
+
await rm(cwd, { recursive: true, force: true });
|
|
1999
|
+
}
|
|
2000
|
+
});
|
|
2001
|
+
for (const fault of ['EBUSY', 'ENOTEMPTY'])
|
|
2002
|
+
it(`completes a checkpoint resume retry after one-shot atomic directory move failure ${fault}`, async () => {
|
|
2003
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-atomic-retry-'));
|
|
2004
|
+
try {
|
|
2005
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2006
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
2007
|
+
let fail = true;
|
|
2008
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2009
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => { if (from === context.lockPath && fail) {
|
|
2010
|
+
fail = false;
|
|
2011
|
+
throw codedError(fault);
|
|
2012
|
+
} if (existsSync(to))
|
|
2013
|
+
return 'not-moved'; await rename(from, to); return 'moved'; } }, async () => { assert.equal((await recoverSessionPointerLock(cwd)).recovered, false); assert.equal(existsSync(checkpoint), true); assert.equal(existsSync(context.lockPath), true); assert.equal((await recoverSessionPointerLock(cwd)).recovered, true); });
|
|
2014
|
+
}
|
|
2015
|
+
finally {
|
|
2016
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
it('keeps a foreign atomic destination and the source lock when no directory move occurs', async () => {
|
|
2020
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-atomic-destination-'));
|
|
2021
|
+
try {
|
|
2022
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2023
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2024
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
2025
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async (_from, to) => {
|
|
2026
|
+
await mkdir(to);
|
|
2027
|
+
await writeFile(join(to, 'foreign'), 'foreign');
|
|
2028
|
+
return 'not-moved';
|
|
2029
|
+
} }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
2030
|
+
assert.equal(await readFile(join(parked, 'foreign'), 'utf8'), 'foreign');
|
|
2031
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
2032
|
+
}
|
|
2033
|
+
finally {
|
|
2034
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
2037
|
+
it('preserves a foreign directory swapped into the atomic move and rollback residue', async () => {
|
|
2038
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-atomic-source-swap-'));
|
|
2039
|
+
try {
|
|
2040
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2041
|
+
const original = `${context.lockPath}.original`;
|
|
2042
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
2043
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2044
|
+
await withPointerDependencies({
|
|
2045
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2046
|
+
probePid: () => 'dead',
|
|
2047
|
+
atomicRenameNoReplace: withProbeFallback(context.lockPath, async (from, to) => {
|
|
2048
|
+
if (from === context.lockPath && to === parked) {
|
|
2049
|
+
await rename(from, original);
|
|
2050
|
+
await mkdir(from);
|
|
2051
|
+
await writeFile(join(from, 'foreign'), 'foreign');
|
|
2052
|
+
await rename(from, to);
|
|
2053
|
+
return 'moved';
|
|
2054
|
+
}
|
|
2055
|
+
if (from === parked && to === context.lockPath)
|
|
2056
|
+
return 'not-moved';
|
|
2057
|
+
return await defaultTestAtomicRenameNoReplace(from, to);
|
|
2058
|
+
}),
|
|
2059
|
+
}, async () => {
|
|
2060
|
+
assert.equal((await recoverSessionPointerLock(cwd)).recovered, false);
|
|
2061
|
+
});
|
|
2062
|
+
assert.equal(existsSync(join(original, 'owner.json')), true);
|
|
2063
|
+
assert.equal(await readFile(join(parked, 'foreign'), 'utf8'), 'foreign');
|
|
2064
|
+
}
|
|
2065
|
+
finally {
|
|
2066
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
it('does not overwrite a rollback destination that appears before the no-clobber rollback link', async () => {
|
|
2070
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-rollback-destination-race-'));
|
|
2071
|
+
try {
|
|
2072
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2073
|
+
const parked = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
2074
|
+
const foreign = 'foreign rollback destination';
|
|
2075
|
+
let rollback = false;
|
|
2076
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2077
|
+
await withPointerDependencies({
|
|
2078
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2079
|
+
probePid: () => 'dead',
|
|
2080
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
2081
|
+
if (from === context.lockPath) {
|
|
2082
|
+
await rename(from, to);
|
|
2083
|
+
await mkdir(context.lockPath);
|
|
2084
|
+
await writeFile(join(context.lockPath, 'foreign'), foreign);
|
|
2085
|
+
return 'moved';
|
|
2086
|
+
}
|
|
2087
|
+
if (from === parked && to === context.lockPath) {
|
|
2088
|
+
rollback = true;
|
|
2089
|
+
return 'not-moved';
|
|
2090
|
+
}
|
|
2091
|
+
if (existsSync(to))
|
|
2092
|
+
return 'not-moved';
|
|
2093
|
+
await rename(from, to);
|
|
2094
|
+
return 'moved';
|
|
2095
|
+
},
|
|
2096
|
+
}, async () => {
|
|
2097
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2098
|
+
assert.equal(recovered.recovered, false);
|
|
2099
|
+
assert.equal(recovered.action, 'none');
|
|
2100
|
+
assert.match(recovered.reason, /captured object|token-bound|source pathname/i);
|
|
2101
|
+
});
|
|
2102
|
+
assert.equal(rollback, true);
|
|
2103
|
+
assert.equal(await readFile(join(context.lockPath, 'foreign'), 'utf-8'), foreign);
|
|
2104
|
+
assert.equal(existsSync(join(parked, 'owner.json')), true);
|
|
2105
|
+
}
|
|
2106
|
+
finally {
|
|
2107
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
it('preserves a checkpoint replacement and foreign completed receipt collision', async () => {
|
|
2111
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-completion-collision-'));
|
|
2112
|
+
try {
|
|
2113
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2114
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2115
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
2116
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => {
|
|
2117
|
+
if (from === checkpoint) {
|
|
2118
|
+
await writeFile(to, 'foreign receipt');
|
|
2119
|
+
return 'not-moved';
|
|
2120
|
+
}
|
|
2121
|
+
await rename(from, to);
|
|
2122
|
+
return 'moved';
|
|
2123
|
+
} }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
2124
|
+
assert.equal(existsSync(checkpoint), true);
|
|
2125
|
+
assert.equal(await readFile(checkpoint.replace(/\.json$/, '.completed'), 'utf8'), 'foreign receipt');
|
|
2126
|
+
}
|
|
2127
|
+
finally {
|
|
2128
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
it('fails closed before moving a lock directory with a foreign extra entry', async () => {
|
|
2132
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-foreign-entry-'));
|
|
2133
|
+
try {
|
|
2134
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2135
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2136
|
+
await writeFile(join(context.lockPath, 'foreign'), 'foreign');
|
|
2137
|
+
let moved = false;
|
|
2138
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN, probePid: () => 'dead', atomicRenameNoReplace: async () => { moved = true; return 'moved'; } }, async () => assert.equal((await recoverSessionPointerLock(cwd)).recovered, false));
|
|
2139
|
+
assert.equal(moved, false);
|
|
2140
|
+
assert.equal(await readFile(join(context.lockPath, 'foreign'), 'utf8'), 'foreign');
|
|
2141
|
+
}
|
|
2142
|
+
finally {
|
|
2143
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
it('resumes legacy base-v1 recovery and v2 source and parked partial recovery', async () => {
|
|
2147
|
+
for (const state of ['v1-source', 'v2-source', 'v2-parked']) {
|
|
2148
|
+
const cwd = await mkdtemp(join(tmpdir(), `omx-session-recovery-legacy-${state}-`));
|
|
2149
|
+
try {
|
|
2150
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2151
|
+
const ownerPath = join(context.lockPath, 'owner.json');
|
|
2152
|
+
const checkpoint = `${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`;
|
|
2153
|
+
const lockPark = `${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`;
|
|
2154
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2155
|
+
const owner = await lstat(ownerPath);
|
|
2156
|
+
const lock = await lstat(context.lockPath);
|
|
2157
|
+
const base = { version: state === 'v1-source' ? 1 : 2, sourcePath: ownerPath, identity: { dev: owner.dev, ino: owner.ino }, phase: 'evidence-pending' };
|
|
2158
|
+
if (state === 'v2-parked') {
|
|
2159
|
+
await link(ownerPath, join(context.lockPath, `owner.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.recovery`));
|
|
2160
|
+
await link(ownerPath, `${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`);
|
|
2161
|
+
await rename(context.lockPath, lockPark);
|
|
2162
|
+
}
|
|
2163
|
+
await writeFile(checkpoint, JSON.stringify(state === 'v1-source' ? base : { ...base, lockIdentity: { dev: lock.dev, ino: lock.ino }, lockParkPath: lockPark, phase: state === 'v2-parked' ? 'evidence-quarantined' : 'evidence-pending' }));
|
|
2164
|
+
await withPointerDependencies({ token: () => { throw new Error('legacy resume must not mint'); }, probePid: () => 'dead', atomicRenameNoReplace: async (from, to) => { if (existsSync(to))
|
|
2165
|
+
return 'not-moved'; await rename(from, to); return 'moved'; } }, async () => {
|
|
2166
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2167
|
+
assert.equal(recovered.recovered, true, recovered.reason);
|
|
2168
|
+
});
|
|
2169
|
+
assert.equal(existsSync(checkpoint.replace(/\.json$/, '.completed')), true);
|
|
2170
|
+
assert.equal(existsSync(lockPark), true);
|
|
2171
|
+
}
|
|
2172
|
+
finally {
|
|
2173
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
});
|
|
2177
|
+
it('preserves recovery evidence when atomic directory quarantine is unsupported', async () => {
|
|
2178
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-recovery-unsupported-'));
|
|
2179
|
+
try {
|
|
2180
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2181
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2182
|
+
await withPointerDependencies({
|
|
2183
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2184
|
+
probePid: () => 'dead',
|
|
2185
|
+
atomicRenameNoReplace: async () => 'unsupported',
|
|
2186
|
+
}, async () => {
|
|
2187
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2188
|
+
assert.equal(recovered.recovered, false);
|
|
2189
|
+
assert.match(recovered.reason, /unsupported/i);
|
|
2190
|
+
});
|
|
2191
|
+
// Capability is probed before checkpoint/quarantine mutations.
|
|
2192
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
2193
|
+
assert.equal(await readFile(join(context.lockPath, 'owner.json'), 'utf8'), JSON.stringify(validLockOwner()));
|
|
2194
|
+
assert.equal(existsSync(`${context.lockPath}.quarantine.${TEST_TOKEN}.${SUCCESSOR_TOKEN}`), false);
|
|
2195
|
+
assert.equal(existsSync(`${context.lockPath}.recovery.${TEST_TOKEN}.${SUCCESSOR_TOKEN}.json`), false);
|
|
2196
|
+
}
|
|
2197
|
+
finally {
|
|
2198
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2199
|
+
}
|
|
2200
|
+
});
|
|
2201
|
+
it('distinguishes a paused live pre-rename owner from the same owner after SIGKILL', async (t) => {
|
|
2202
|
+
if (process.platform !== 'linux') {
|
|
2203
|
+
t.skip('Linux process start identity is required for deterministic PID reuse protection.');
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-sigkill-'));
|
|
2207
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });
|
|
2208
|
+
try {
|
|
2209
|
+
assert.ok(child.pid);
|
|
2210
|
+
await once(child, 'spawn');
|
|
2211
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2212
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2213
|
+
await writeFile(join(context.lockPath, `owner.${TEST_TOKEN}.tmp`), JSON.stringify(validLockOwner({
|
|
2214
|
+
pid: child.pid,
|
|
2215
|
+
pid_start_ticks: await linuxProcessStartTicks(child.pid),
|
|
2216
|
+
})), 'utf-8');
|
|
2217
|
+
const paused = await recoverSessionPointerLock(cwd);
|
|
2218
|
+
assert.equal(paused.status, 'live');
|
|
2219
|
+
assert.equal(paused.recovered, false);
|
|
2220
|
+
assert.equal(paused.action, 'none');
|
|
2221
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
2222
|
+
child.kill('SIGKILL');
|
|
2223
|
+
const [exitCode, signal] = await once(child, 'exit');
|
|
2224
|
+
assert.equal(exitCode, null);
|
|
2225
|
+
assert.equal(signal, 'SIGKILL');
|
|
2226
|
+
let orphaned;
|
|
2227
|
+
await withPointerDependencies({
|
|
2228
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
2229
|
+
await rename(from, to);
|
|
2230
|
+
return 'moved';
|
|
2231
|
+
},
|
|
2232
|
+
}, async () => { orphaned = await recoverSessionPointerLock(cwd); });
|
|
2233
|
+
assert.equal(orphaned.status, 'dead');
|
|
2234
|
+
assert.equal(orphaned.recovered, true);
|
|
2235
|
+
assert.equal(orphaned.action, 'quarantined');
|
|
2236
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2237
|
+
}
|
|
2238
|
+
finally {
|
|
2239
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
2240
|
+
child.kill('SIGKILL');
|
|
2241
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2242
|
+
}
|
|
2243
|
+
});
|
|
2244
|
+
it('fails closed for live pre-rename, PID reuse, malformed, and ambiguous lock evidence', async () => {
|
|
2245
|
+
const cases = [
|
|
2246
|
+
{ name: 'live-temp', files: [[`owner.${TEST_TOKEN}.tmp`, JSON.stringify(validLockOwner())]], probePid: 'alive', observation: matchingObservation() },
|
|
2247
|
+
{ name: 'reused', files: [['owner.json', JSON.stringify(validLockOwner())]], probePid: 'alive', observation: { kind: 'identity', identity: { platform: 'linux', birth: '2' } } },
|
|
2248
|
+
{ name: 'malformed', files: [['owner.json', '{']], probePid: 'dead' },
|
|
2249
|
+
{ name: 'ambiguous', files: [['owner.json', JSON.stringify(validLockOwner())], [`owner.${SUCCESSOR_TOKEN}.tmp`, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN }))]], probePid: 'dead' },
|
|
2250
|
+
];
|
|
2251
|
+
for (const testCase of cases) {
|
|
2252
|
+
const cwd = await mkdtemp(join(tmpdir(), `omx-session-lock-recovery-${testCase.name}-`));
|
|
2253
|
+
try {
|
|
2254
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2255
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2256
|
+
await Promise.all(testCase.files.map(async ([name, contents]) => await writeFile(join(context.lockPath, name), contents, 'utf-8')));
|
|
2257
|
+
await withPointerDependencies({
|
|
2258
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2259
|
+
probePid: () => testCase.probePid,
|
|
2260
|
+
observeProcess: () => testCase.observation ?? matchingObservation(),
|
|
2261
|
+
}, async () => {
|
|
2262
|
+
const before = await readdir(context.lockPath);
|
|
2263
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2264
|
+
assert.equal(recovered.recovered, false);
|
|
2265
|
+
assert.equal(recovered.action, 'none');
|
|
2266
|
+
assert.equal(recovered.safeToRecover, false);
|
|
2267
|
+
assert.deepEqual(await readdir(context.lockPath), before);
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
finally {
|
|
2271
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
});
|
|
2275
|
+
it('claims stale evidence before quarantine so a successor cannot be mistaken for it', async () => {
|
|
2276
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-race-'));
|
|
2277
|
+
try {
|
|
2278
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2279
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2280
|
+
await writeFile(join(context.lockPath, `owner.${TEST_TOKEN}.tmp`), JSON.stringify(validLockOwner()), 'utf-8');
|
|
2281
|
+
const links = [];
|
|
2282
|
+
let successorBlocked = false;
|
|
2283
|
+
await withPointerDependencies({
|
|
2284
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2285
|
+
probePid: () => 'dead',
|
|
2286
|
+
fs: {
|
|
2287
|
+
link: async (from, to) => {
|
|
2288
|
+
links.push([from, to]);
|
|
2289
|
+
await link(from, to);
|
|
2290
|
+
},
|
|
2291
|
+
},
|
|
2292
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
2293
|
+
if (from === context.lockPath) {
|
|
2294
|
+
await assert.rejects(mkdir(context.lockPath), { code: 'EEXIST' });
|
|
2295
|
+
successorBlocked = true;
|
|
2296
|
+
}
|
|
2297
|
+
if (existsSync(to))
|
|
2298
|
+
return 'not-moved';
|
|
2299
|
+
await rename(from, to);
|
|
2300
|
+
return 'moved';
|
|
2301
|
+
},
|
|
2302
|
+
}, async () => {
|
|
2303
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2304
|
+
assert.equal(recovered.recovered, true);
|
|
2305
|
+
});
|
|
2306
|
+
assert.equal(successorBlocked, true);
|
|
2307
|
+
assert.equal(links.length, 1);
|
|
2308
|
+
assert.match(links[0][0], /owner\.transaction_token_123456\.tmp$/);
|
|
2309
|
+
assert.equal(links[0][1].includes('.quarantine.'), true);
|
|
2310
|
+
assert.equal(existsSync(`${context.lockPath}.parked-lock-${SUCCESSOR_TOKEN}`), true);
|
|
2311
|
+
}
|
|
2312
|
+
finally {
|
|
2313
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2314
|
+
}
|
|
2315
|
+
});
|
|
2316
|
+
it('does not claim a successor lock when the inspected orphan is displaced before the exact temp rename', async () => {
|
|
2317
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-successor-race-'));
|
|
2318
|
+
try {
|
|
2319
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2320
|
+
const staleTemp = join(context.lockPath, `owner.${TEST_TOKEN}.tmp`);
|
|
2321
|
+
const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
|
|
2322
|
+
const displacedPath = `${context.lockPath}.displaced`;
|
|
2323
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2324
|
+
await writeFile(staleTemp, JSON.stringify(validLockOwner()), 'utf-8');
|
|
2325
|
+
let displaced = false;
|
|
2326
|
+
await withPointerDependencies({
|
|
2327
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2328
|
+
probePid: () => 'dead',
|
|
2329
|
+
fs: {
|
|
2330
|
+
link: async (from, to) => {
|
|
2331
|
+
if (!displaced && from === staleTemp) {
|
|
2332
|
+
displaced = true;
|
|
2333
|
+
await rename(context.lockPath, displacedPath);
|
|
2334
|
+
await mkdir(context.lockPath);
|
|
2335
|
+
await writeFile(successorTemp, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })), 'utf-8');
|
|
2336
|
+
}
|
|
2337
|
+
await link(from, to);
|
|
2338
|
+
},
|
|
2339
|
+
},
|
|
2340
|
+
}, async () => {
|
|
2341
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2342
|
+
assert.equal(recovered.recovered, false);
|
|
2343
|
+
assert.equal(recovered.action, 'none');
|
|
2344
|
+
assert.match(recovered.reason, /Recovery checkpoint.*ENOENT/i);
|
|
2345
|
+
});
|
|
2346
|
+
assert.equal(displaced, true);
|
|
2347
|
+
assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
|
|
2348
|
+
assert.equal(await readFile(successorTemp, 'utf-8'), JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })));
|
|
2349
|
+
}
|
|
2350
|
+
finally {
|
|
2351
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2352
|
+
}
|
|
2353
|
+
});
|
|
2354
|
+
it('does not report recovery after a successor live lock appears during parked-lock removal', async () => {
|
|
2355
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-recovery-post-claim-race-'));
|
|
2356
|
+
try {
|
|
2357
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2358
|
+
const staleTemp = join(context.lockPath, `owner.${TEST_TOKEN}.tmp`);
|
|
2359
|
+
const successorTemp = join(context.lockPath, `owner.${SUCCESSOR_TOKEN}.tmp`);
|
|
2360
|
+
await mkdir(context.lockPath, { recursive: true });
|
|
2361
|
+
await writeFile(staleTemp, JSON.stringify(validLockOwner()), 'utf-8');
|
|
2362
|
+
let displaced = false;
|
|
2363
|
+
await withPointerDependencies({
|
|
2364
|
+
token: () => SUCCESSOR_TOKEN,
|
|
2365
|
+
probePid: () => 'dead',
|
|
2366
|
+
atomicRenameNoReplace: async (from, to) => {
|
|
2367
|
+
if (existsSync(to))
|
|
2368
|
+
return 'not-moved';
|
|
2369
|
+
await rename(from, to);
|
|
2370
|
+
if (!displaced && from === context.lockPath) {
|
|
2371
|
+
displaced = true;
|
|
2372
|
+
await mkdir(context.lockPath);
|
|
2373
|
+
await writeFile(successorTemp, JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })), 'utf-8');
|
|
2374
|
+
}
|
|
2375
|
+
return 'moved';
|
|
2376
|
+
},
|
|
2377
|
+
}, async () => {
|
|
2378
|
+
const recovered = await recoverSessionPointerLock(cwd);
|
|
2379
|
+
assert.equal(recovered.recovered, false);
|
|
2380
|
+
assert.equal(recovered.action, 'none');
|
|
2381
|
+
assert.match(recovered.reason, /did not leave the captured object/i);
|
|
2382
|
+
});
|
|
2383
|
+
assert.equal(displaced, true);
|
|
2384
|
+
assert.deepEqual(await readdir(context.lockPath), [`owner.${SUCCESSOR_TOKEN}.tmp`]);
|
|
2385
|
+
assert.equal(await readFile(successorTemp, 'utf-8'), JSON.stringify(validLockOwner({ token: SUCCESSOR_TOKEN })));
|
|
2386
|
+
}
|
|
2387
|
+
finally {
|
|
2388
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2389
|
+
}
|
|
2390
|
+
});
|
|
2391
|
+
it('retries a positively matching live owner with the bounded 25/50/100 schedule before timing out', async () => {
|
|
2392
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-lock-timeout-'));
|
|
2393
|
+
try {
|
|
2394
|
+
await writeLockOwner(cwd, validLockOwner());
|
|
2395
|
+
const delays = [];
|
|
2396
|
+
let now = 0;
|
|
2397
|
+
await withPointerDependencies({
|
|
2398
|
+
nowMs: () => now,
|
|
2399
|
+
sleep: async (ms) => { delays.push(ms); now += ms; },
|
|
2400
|
+
token: () => TEST_TOKEN,
|
|
2401
|
+
probePid: () => 'alive',
|
|
2402
|
+
observeProcess: () => matchingObservation(),
|
|
2403
|
+
}, async () => {
|
|
2404
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-timeout', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2405
|
+
&& error.code === 'session_pointer_lock_timeout'
|
|
2406
|
+
&& error.lockOwnerStatus === 'live');
|
|
2407
|
+
});
|
|
2408
|
+
assert.deepEqual(delays.slice(0, 3), [25, 50, 100]);
|
|
2409
|
+
assert.equal(delays.at(-1), 25);
|
|
2410
|
+
assert.equal(existsSync(resolveSessionPointerContext(cwd).lockPath), true);
|
|
2411
|
+
}
|
|
2412
|
+
finally {
|
|
2413
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2414
|
+
}
|
|
2415
|
+
});
|
|
2416
|
+
it('rolls back only its unpublished owner artifacts and reports rollback ambiguity', async () => {
|
|
2417
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-publish-'));
|
|
2418
|
+
try {
|
|
2419
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2420
|
+
await withPointerDependencies({
|
|
2421
|
+
token: () => TEST_TOKEN,
|
|
2422
|
+
fs: {
|
|
2423
|
+
writeFile: async (path, data, options) => {
|
|
2424
|
+
if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
|
|
2425
|
+
throw new Error('owner write failure');
|
|
2426
|
+
await writeFile(path, data, options);
|
|
2427
|
+
},
|
|
2428
|
+
},
|
|
2429
|
+
}, async () => {
|
|
2430
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-owner-publish', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2431
|
+
&& error.code === 'session_pointer_io_failure'
|
|
2432
|
+
&& error.operation === 'lock-owner-publish');
|
|
2433
|
+
});
|
|
2434
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2435
|
+
await withPointerDependencies({
|
|
2436
|
+
token: () => TEST_TOKEN,
|
|
2437
|
+
fs: {
|
|
2438
|
+
writeFile: async (path, data, options) => {
|
|
2439
|
+
if (path.endsWith(`owner.${TEST_TOKEN}.tmp`))
|
|
2440
|
+
throw new Error('owner write failure');
|
|
2441
|
+
await writeFile(path, data, options);
|
|
2442
|
+
},
|
|
2443
|
+
rmdir: async (path) => {
|
|
2444
|
+
if (path === context.lockPath)
|
|
2445
|
+
throw new Error('keep lock evidence');
|
|
2446
|
+
await rm(path, { recursive: false });
|
|
2447
|
+
},
|
|
2448
|
+
},
|
|
2449
|
+
}, async () => {
|
|
2450
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-owner-residue', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2451
|
+
&& error.code === 'session_pointer_lock_recovery_required'
|
|
2452
|
+
&& error.primaryOperation === 'lock-owner-publish'
|
|
2453
|
+
&& error.secondaryFailures?.[0]?.phase === 'remove-unpublished-lock');
|
|
2454
|
+
});
|
|
2455
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
2456
|
+
}
|
|
2457
|
+
finally {
|
|
2458
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
it('publishes a legacy-like Linux state when process identity capture fails', async (t) => {
|
|
2462
|
+
if (process.platform !== 'linux') {
|
|
2463
|
+
t.skip('Linux identity publication semantics are under test.');
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-identity-capture-failure-'));
|
|
2467
|
+
try {
|
|
2468
|
+
await withPointerDependencies({
|
|
2469
|
+
runtimePlatform: 'linux',
|
|
2470
|
+
observeProcess: () => ({ kind: 'error' }),
|
|
2471
|
+
}, async () => {
|
|
2472
|
+
const state = await writeSessionStart(cwd, 'sess-identity-capture-failure', { platform: 'linux' });
|
|
2473
|
+
assert.equal(state.platform, 'linux');
|
|
2474
|
+
assert.equal(Object.hasOwn(state, 'identity_schema_version'), false);
|
|
2475
|
+
assert.equal(Object.hasOwn(state, 'process_identity'), false);
|
|
2476
|
+
assert.equal(Object.hasOwn(state, 'pid_start_ticks'), false);
|
|
2477
|
+
const persisted = await readSessionState(cwd);
|
|
2478
|
+
assert.ok(persisted);
|
|
2479
|
+
assert.equal(Object.hasOwn(persisted, 'identity_schema_version'), false);
|
|
2480
|
+
assert.equal(Object.hasOwn(persisted, 'process_identity'), false);
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
finally {
|
|
2484
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2485
|
+
}
|
|
2486
|
+
});
|
|
2487
|
+
it('publishes the owner and pointer on Windows when regular-file fsync reports EPERM', async () => {
|
|
2488
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-windows-eperm-'));
|
|
2489
|
+
let syncCalls = 0;
|
|
2490
|
+
const regularFileSync = async (platform) => {
|
|
2491
|
+
assert.equal(platform, 'win32');
|
|
2492
|
+
syncCalls += 1;
|
|
2493
|
+
throw codedError('EPERM');
|
|
2494
|
+
};
|
|
2495
|
+
try {
|
|
2496
|
+
const state = await writeSessionStart(cwd, 'sess-windows-eperm', {
|
|
2497
|
+
platform: 'win32',
|
|
2498
|
+
regularFileSync,
|
|
2499
|
+
});
|
|
2500
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2501
|
+
assert.equal(state.session_id, 'sess-windows-eperm');
|
|
2502
|
+
assert.equal(syncCalls, 2, 'owner publication and pointer publication must both attempt fsync');
|
|
2503
|
+
assert.equal(existsSync(join(context.lockPath, 'owner.json')), false, 'lock owner is removed after commit');
|
|
2504
|
+
assert.equal(JSON.parse(await readFile(context.sessionPath, 'utf8')).session_id, 'sess-windows-eperm');
|
|
2505
|
+
}
|
|
2506
|
+
finally {
|
|
2507
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2508
|
+
}
|
|
2509
|
+
});
|
|
2510
|
+
it('emits one post-release warning for a degraded session start and stays silent for synced or failed transactions', async () => {
|
|
2511
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-durability-warning-'));
|
|
2512
|
+
const originalWrite = process.stderr.write;
|
|
2513
|
+
const warnings = [];
|
|
2514
|
+
let warningCwd = cwd;
|
|
2515
|
+
process.stderr.write = ((value) => {
|
|
2516
|
+
warnings.push({ value, lockExists: existsSync(resolveSessionPointerContext(warningCwd).lockPath) });
|
|
2517
|
+
return true;
|
|
2518
|
+
});
|
|
2519
|
+
const regularFileSync = async () => { throw codedError('EPERM'); };
|
|
2520
|
+
try {
|
|
2521
|
+
await writeSessionStart(cwd, 'sess-degraded-start', { platform: 'win32', regularFileSync });
|
|
2522
|
+
assert.deepEqual(warnings, [{
|
|
2523
|
+
value: '[omx] warning: Windows EPERM regular-file fsync unsupported in session pointer start/reconcile; operation succeeded with degraded durability.\n',
|
|
2524
|
+
lockExists: false,
|
|
2525
|
+
}]);
|
|
2526
|
+
warnings.length = 0;
|
|
2527
|
+
const syncedCwd = join(cwd, 'synced');
|
|
2528
|
+
await mkdir(syncedCwd);
|
|
2529
|
+
warningCwd = syncedCwd;
|
|
2530
|
+
await writeSessionStart(syncedCwd, 'sess-synced-start', {
|
|
2531
|
+
platform: 'win32',
|
|
2532
|
+
regularFileSync: async () => { },
|
|
2533
|
+
});
|
|
2534
|
+
assert.deepEqual(warnings, []);
|
|
2535
|
+
const failedCwd = join(cwd, 'failed');
|
|
2536
|
+
await mkdir(failedCwd);
|
|
2537
|
+
warningCwd = failedCwd;
|
|
2538
|
+
await withPointerDependencies({
|
|
2539
|
+
fs: {
|
|
2540
|
+
rename: async (from, to) => {
|
|
2541
|
+
if (to === resolveSessionPointerContext(failedCwd).sessionPath)
|
|
2542
|
+
throw new Error('rename failure');
|
|
2543
|
+
await rename(from, to);
|
|
2544
|
+
},
|
|
2545
|
+
},
|
|
2546
|
+
}, async () => {
|
|
2547
|
+
await assert.rejects(writeSessionStart(failedCwd, 'sess-failed-start', { platform: 'win32', regularFileSync }));
|
|
2548
|
+
});
|
|
2549
|
+
assert.deepEqual(warnings, []);
|
|
2550
|
+
}
|
|
2551
|
+
finally {
|
|
2552
|
+
process.stderr.write = originalWrite;
|
|
2553
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
it('types state-directory, pointer-read, owner-sync, owner-rename, and invalid-token failures before commit', async () => {
|
|
2557
|
+
const failures = [
|
|
2558
|
+
{
|
|
2559
|
+
name: 'state-dir',
|
|
2560
|
+
operation: 'state-dir-create',
|
|
2561
|
+
dependencies: (context) => ({
|
|
2562
|
+
fs: {
|
|
2563
|
+
mkdir: async (path, options) => {
|
|
2564
|
+
if (path === context.baseStateDir)
|
|
2565
|
+
throw new Error('state directory failure');
|
|
2566
|
+
await mkdir(path, options);
|
|
2567
|
+
},
|
|
2568
|
+
},
|
|
2569
|
+
}),
|
|
2570
|
+
},
|
|
2571
|
+
{
|
|
2572
|
+
name: 'pointer-read',
|
|
2573
|
+
operation: 'pointer-read',
|
|
2574
|
+
dependencies: (context) => ({
|
|
2575
|
+
token: () => TEST_TOKEN,
|
|
2576
|
+
fs: {
|
|
2577
|
+
readFile: async (path, encoding) => {
|
|
2578
|
+
if (path === context.sessionPath)
|
|
2579
|
+
throw new Error('pointer read failure');
|
|
2580
|
+
return await readFile(path, encoding);
|
|
2581
|
+
},
|
|
2582
|
+
},
|
|
2583
|
+
}),
|
|
2584
|
+
},
|
|
2585
|
+
{
|
|
2586
|
+
name: 'owner-sync',
|
|
2587
|
+
operation: 'lock-owner-publish',
|
|
2588
|
+
dependencies: (context) => ({
|
|
2589
|
+
token: () => TEST_TOKEN,
|
|
2590
|
+
fs: {
|
|
2591
|
+
openAndSync: async (path) => {
|
|
2592
|
+
if (path === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
|
|
2593
|
+
throw new Error('owner sync failure');
|
|
2594
|
+
return 'synced';
|
|
2595
|
+
},
|
|
2596
|
+
},
|
|
2597
|
+
}),
|
|
2598
|
+
},
|
|
2599
|
+
{
|
|
2600
|
+
name: 'owner-rename',
|
|
2601
|
+
operation: 'lock-owner-publish',
|
|
2602
|
+
dependencies: (context) => ({
|
|
2603
|
+
token: () => TEST_TOKEN,
|
|
2604
|
+
fs: {
|
|
2605
|
+
rename: async (from, to) => {
|
|
2606
|
+
if (from === join(context.lockPath, `owner.${TEST_TOKEN}.tmp`))
|
|
2607
|
+
throw new Error('owner rename failure');
|
|
2608
|
+
await rename(from, to);
|
|
2609
|
+
},
|
|
2610
|
+
},
|
|
2611
|
+
}),
|
|
2612
|
+
},
|
|
2613
|
+
];
|
|
2614
|
+
for (const failure of failures) {
|
|
2615
|
+
const cwd = await mkdtemp(join(tmpdir(), `omx-session-${failure.name}-`));
|
|
2616
|
+
try {
|
|
2617
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2618
|
+
await withPointerDependencies(failure.dependencies(context), async () => {
|
|
2619
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-precommit', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2620
|
+
&& error.code === 'session_pointer_io_failure'
|
|
2621
|
+
&& error.operation === failure.operation);
|
|
2622
|
+
});
|
|
2623
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2624
|
+
}
|
|
2625
|
+
finally {
|
|
2626
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-invalid-token-'));
|
|
2630
|
+
try {
|
|
2631
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2632
|
+
await withPointerDependencies({ token: () => 'bad' }, async () => {
|
|
2633
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-invalid-token', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2634
|
+
&& error.code === 'session_pointer_io_failure'
|
|
2635
|
+
&& error.operation === 'lock-owner-publish');
|
|
2636
|
+
});
|
|
2637
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2638
|
+
assert.equal(existsSync(`${context.sessionPath}.tmp-bad`), false);
|
|
2639
|
+
}
|
|
2640
|
+
finally {
|
|
2641
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2642
|
+
}
|
|
2643
|
+
});
|
|
2644
|
+
it('types pointer write, sync, and rename failures and removes only the owned temporary file', async () => {
|
|
2645
|
+
const phases = [
|
|
2646
|
+
{ operation: 'pointer-temp-write', fail: 'write' },
|
|
2647
|
+
{ operation: 'pointer-fsync', fail: 'sync' },
|
|
2648
|
+
{ operation: 'pointer-rename', fail: 'rename' },
|
|
2649
|
+
];
|
|
2650
|
+
for (const phase of phases) {
|
|
2651
|
+
const cwd = await mkdtemp(join(tmpdir(), `omx-session-${phase.fail}-`));
|
|
2652
|
+
try {
|
|
2653
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2654
|
+
const tempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
|
|
2655
|
+
await withPointerDependencies({
|
|
2656
|
+
token: () => TEST_TOKEN,
|
|
2657
|
+
fs: {
|
|
2658
|
+
writeFile: async (path, data, options) => {
|
|
2659
|
+
await writeFile(path, data, options);
|
|
2660
|
+
if (phase.fail === 'write' && path === tempPath)
|
|
2661
|
+
throw new Error('pointer write failure');
|
|
2662
|
+
},
|
|
2663
|
+
openAndSync: async (path) => {
|
|
2664
|
+
if (phase.fail === 'sync' && path === tempPath)
|
|
2665
|
+
throw new Error('pointer sync failure');
|
|
2666
|
+
return 'synced';
|
|
2667
|
+
},
|
|
2668
|
+
rename: async (from, to) => {
|
|
2669
|
+
if (phase.fail === 'rename' && from === tempPath && to === context.sessionPath) {
|
|
2670
|
+
throw new Error('pointer rename failure');
|
|
2671
|
+
}
|
|
2672
|
+
await rename(from, to);
|
|
2673
|
+
},
|
|
2674
|
+
},
|
|
2675
|
+
}, async () => {
|
|
2676
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-failure', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2677
|
+
&& error.code === 'session_pointer_io_failure'
|
|
2678
|
+
&& error.operation === phase.operation);
|
|
2679
|
+
});
|
|
2680
|
+
assert.equal(existsSync(tempPath), false);
|
|
2681
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2682
|
+
}
|
|
2683
|
+
finally {
|
|
2684
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
});
|
|
2688
|
+
it('preserves owned pointer residue and ordered cleanup evidence when cleanup or release cannot complete', async () => {
|
|
2689
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-residue-'));
|
|
2690
|
+
try {
|
|
2691
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2692
|
+
const tempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
|
|
2693
|
+
await withPointerDependencies({
|
|
2694
|
+
token: () => TEST_TOKEN,
|
|
2695
|
+
fs: {
|
|
2696
|
+
writeFile: async (path, data, options) => {
|
|
2697
|
+
await writeFile(path, data, options);
|
|
2698
|
+
if (path === tempPath)
|
|
2699
|
+
throw new Error('pointer write failure');
|
|
2700
|
+
},
|
|
2701
|
+
unlink: async (path) => {
|
|
2702
|
+
if (path === tempPath)
|
|
2703
|
+
throw new Error('pointer temp cleanup failure');
|
|
2704
|
+
await rm(path, { force: false });
|
|
2705
|
+
},
|
|
2706
|
+
rmdir: async (path) => {
|
|
2707
|
+
if (path.endsWith(`.release-${TEST_TOKEN}`))
|
|
2708
|
+
throw new Error('release cleanup failure');
|
|
2709
|
+
await rm(path, { recursive: false });
|
|
2710
|
+
},
|
|
2711
|
+
},
|
|
2712
|
+
}, async () => {
|
|
2713
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-residue', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2714
|
+
&& error.code === 'session_pointer_lock_recovery_required'
|
|
2715
|
+
&& error.primaryOperation === 'pointer-temp-write'
|
|
2716
|
+
&& error.secondaryFailures?.map((failure) => failure.phase).join(',') === 'remove-pointer-temp,remove-release-dir'
|
|
2717
|
+
&& error.secondaryFailures?.[0]?.evidencePath === tempPath);
|
|
2718
|
+
});
|
|
2719
|
+
assert.equal(existsSync(tempPath), true);
|
|
2720
|
+
assert.equal(existsSync(`${context.lockPath}.release-${TEST_TOKEN}`), true);
|
|
2721
|
+
}
|
|
2722
|
+
finally {
|
|
2723
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
it('leaves foreign pointer temps inert and a successor commits after explicit lock recovery', async () => {
|
|
2727
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-pointer-successor-'));
|
|
2728
|
+
try {
|
|
2729
|
+
const context = resolveSessionPointerContext(cwd);
|
|
2730
|
+
const ownTempPath = `${context.sessionPath}.tmp-${TEST_TOKEN}`;
|
|
2731
|
+
const foreignTempPath = `${context.sessionPath}.tmp-${FOREIGN_TOKEN}`;
|
|
2732
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
2733
|
+
await writeFile(foreignTempPath, 'foreign transaction evidence', 'utf-8');
|
|
2734
|
+
await withPointerDependencies({
|
|
2735
|
+
token: () => TEST_TOKEN,
|
|
2736
|
+
fs: {
|
|
2737
|
+
writeFile: async (path, data, options) => {
|
|
2738
|
+
await writeFile(path, data, options);
|
|
2739
|
+
if (path === ownTempPath)
|
|
2740
|
+
throw new Error('pointer write failure');
|
|
2741
|
+
},
|
|
2742
|
+
unlink: async (path) => {
|
|
2743
|
+
if (path === ownTempPath)
|
|
2744
|
+
throw new Error('keep own residue');
|
|
2745
|
+
await rm(path, { force: false });
|
|
2746
|
+
},
|
|
2747
|
+
rename: async (from, to) => {
|
|
2748
|
+
if (from === context.lockPath)
|
|
2749
|
+
throw new Error('preserve canonical lock');
|
|
2750
|
+
await rename(from, to);
|
|
2751
|
+
},
|
|
2752
|
+
},
|
|
2753
|
+
}, async () => {
|
|
2754
|
+
await assert.rejects(writeSessionStart(cwd, 'sess-first', { platform: 'win32' }), isSessionPointerLaunchAbort);
|
|
2755
|
+
});
|
|
2756
|
+
assert.equal(existsSync(foreignTempPath), true);
|
|
2757
|
+
assert.equal(existsSync(context.lockPath), true);
|
|
2758
|
+
// This is documented stopped-session operator recovery, deliberately not
|
|
2759
|
+
// product transaction behavior.
|
|
2760
|
+
await rm(context.lockPath, { recursive: true, force: true });
|
|
2761
|
+
await withPointerDependencies({ token: () => SUCCESSOR_TOKEN }, async () => {
|
|
2762
|
+
await writeSessionStart(cwd, 'sess-successor', { platform: 'win32' });
|
|
2763
|
+
});
|
|
2764
|
+
assert.equal((await readSessionState(cwd))?.session_id, 'sess-successor');
|
|
2765
|
+
assert.equal(existsSync(ownTempPath), true);
|
|
2766
|
+
assert.equal(existsSync(foreignTempPath), true);
|
|
2767
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
2768
|
+
}
|
|
2769
|
+
finally {
|
|
2770
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2771
|
+
}
|
|
2772
|
+
});
|
|
2773
|
+
it('binds an owner alias only on a verified same-native/absent transition and never during replacement', async () => {
|
|
2774
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-alias-'));
|
|
2775
|
+
try {
|
|
2776
|
+
await withPointerDependencies({
|
|
2777
|
+
runtimePlatform: 'win32',
|
|
2778
|
+
observeProcess: () => matchingWin32Observation(),
|
|
2779
|
+
}, async () => {
|
|
2780
|
+
const nativeOnly = await reconcileNativeSessionStart(cwd, 'native-first', {
|
|
2781
|
+
platform: 'win32',
|
|
2782
|
+
ownerOmxSessionId: 'omx-owner',
|
|
2783
|
+
});
|
|
2784
|
+
assert.equal(nativeOnly.owner_omx_session_id, undefined);
|
|
2785
|
+
await withOwnerEnvironment('omx-owner', async () => {
|
|
2786
|
+
const bound = await reconcileNativeSessionStart(cwd, 'native-first', {
|
|
2787
|
+
platform: 'win32',
|
|
2788
|
+
ownerAliasVerified: true,
|
|
2789
|
+
});
|
|
2790
|
+
assert.equal(bound.session_id, 'native-first');
|
|
2791
|
+
assert.equal(bound.owner_omx_session_id, 'omx-owner');
|
|
2792
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'native-second', {
|
|
2793
|
+
platform: 'win32',
|
|
2794
|
+
ownerAliasVerified: true,
|
|
2795
|
+
}), isOwnerConflict);
|
|
2796
|
+
const persisted = await readSessionState(cwd);
|
|
2797
|
+
assert.equal(persisted?.session_id, 'native-first');
|
|
2798
|
+
assert.equal(persisted?.owner_omx_session_id, 'omx-owner');
|
|
2799
|
+
});
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
finally {
|
|
2803
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2804
|
+
}
|
|
2805
|
+
});
|
|
2806
|
+
it('keeps native session owner sidecars isolated and rejects live cross-process reuse', async () => {
|
|
2807
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-sidecar-'));
|
|
2808
|
+
try {
|
|
2809
|
+
await withPointerDependencies({
|
|
2810
|
+
runtimePlatform: 'win32',
|
|
2811
|
+
observeProcess: () => matchingWin32Observation(),
|
|
2812
|
+
probePid: () => 'alive',
|
|
2813
|
+
}, async () => {
|
|
2814
|
+
const first = await writeNativeSessionOwner(cwd, 'native-owner-a', { pid: 11, platform: 'win32' });
|
|
2815
|
+
const second = await writeNativeSessionOwner(cwd, 'native-owner-b', { pid: 22, platform: 'win32' });
|
|
2816
|
+
assert.equal(first.pid, 11);
|
|
2817
|
+
assert.equal(second.pid, 22);
|
|
2818
|
+
const firstPath = join(cwd, '.omx', 'state', 'sessions', 'native-owner-a', 'session-owner.json');
|
|
2819
|
+
const secondPath = join(cwd, '.omx', 'state', 'sessions', 'native-owner-b', 'session-owner.json');
|
|
2820
|
+
assert.equal(JSON.parse(await readFile(firstPath, 'utf-8')).pid, 11);
|
|
2821
|
+
assert.equal(JSON.parse(await readFile(secondPath, 'utf-8')).pid, 22);
|
|
2822
|
+
await writeNativeSessionOwner(cwd, 'native-owner-current', {
|
|
2823
|
+
pid: process.pid,
|
|
2824
|
+
platform: 'win32',
|
|
2825
|
+
});
|
|
2826
|
+
assert.equal((await readNativeSessionOwner(cwd, 'native-owner-current'))?.pid, process.pid);
|
|
2827
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-a', { pid: 22, platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2828
|
+
&& error.code === 'session_pointer_owner_conflict');
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
finally {
|
|
2832
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2833
|
+
}
|
|
2834
|
+
});
|
|
2835
|
+
it('rejects a symlinked native owner state root', async () => {
|
|
2836
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-root-symlink-'));
|
|
2837
|
+
try {
|
|
2838
|
+
const externalState = join(cwd, 'external-state');
|
|
2839
|
+
const ownerDir = join(externalState, 'sessions', 'native-owner-root-link');
|
|
2840
|
+
await mkdir(ownerDir, { recursive: true });
|
|
2841
|
+
await writeFile(join(ownerDir, 'session-owner.json'), JSON.stringify({
|
|
2842
|
+
session_id: 'native-owner-root-link',
|
|
2843
|
+
native_session_id: 'native-owner-root-link',
|
|
2844
|
+
cwd,
|
|
2845
|
+
platform: process.platform,
|
|
2846
|
+
}));
|
|
2847
|
+
await mkdir(join(cwd, '.omx'), { recursive: true });
|
|
2848
|
+
await symlink(externalState, join(cwd, '.omx', 'state'));
|
|
2849
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-root-link')).status, 'malformed');
|
|
2850
|
+
}
|
|
2851
|
+
finally {
|
|
2852
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2853
|
+
}
|
|
2854
|
+
});
|
|
2855
|
+
it('rejects symlinked native owner directories and sidecars', async () => {
|
|
2856
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-symlink-'));
|
|
2857
|
+
try {
|
|
2858
|
+
const stateDir = join(cwd, '.omx', 'state');
|
|
2859
|
+
const sessionsDir = join(stateDir, 'sessions');
|
|
2860
|
+
const externalDir = join(cwd, 'external-owner');
|
|
2861
|
+
await mkdir(sessionsDir, { recursive: true });
|
|
2862
|
+
await mkdir(externalDir, { recursive: true });
|
|
2863
|
+
await writeFile(join(externalDir, 'session-owner.json'), JSON.stringify({
|
|
2864
|
+
session_id: 'native-owner-dir-link',
|
|
2865
|
+
native_session_id: 'native-owner-dir-link',
|
|
2866
|
+
cwd,
|
|
2867
|
+
platform: process.platform,
|
|
2868
|
+
}));
|
|
2869
|
+
await symlink(externalDir, join(sessionsDir, 'native-owner-dir-link'));
|
|
2870
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-dir-link')).status, 'malformed');
|
|
2871
|
+
const fileLinkDir = join(sessionsDir, 'native-owner-file-link');
|
|
2872
|
+
await mkdir(fileLinkDir, { recursive: true });
|
|
2873
|
+
const externalFile = join(cwd, 'external-session-owner.json');
|
|
2874
|
+
await writeFile(externalFile, JSON.stringify({
|
|
2875
|
+
session_id: 'native-owner-file-link',
|
|
2876
|
+
native_session_id: 'native-owner-file-link',
|
|
2877
|
+
cwd,
|
|
2878
|
+
platform: process.platform,
|
|
2879
|
+
}));
|
|
2880
|
+
await symlink(externalFile, join(fileLinkDir, 'session-owner.json'));
|
|
2881
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-file-link')).status, 'malformed');
|
|
2882
|
+
const swappedOwnerId = 'native-owner-swapped-dir';
|
|
2883
|
+
const swappedOwnerDir = join(sessionsDir, swappedOwnerId);
|
|
2884
|
+
await mkdir(swappedOwnerDir, { recursive: true });
|
|
2885
|
+
await writeFile(join(swappedOwnerDir, 'session-owner.json'), JSON.stringify({
|
|
2886
|
+
session_id: swappedOwnerId,
|
|
2887
|
+
native_session_id: swappedOwnerId,
|
|
2888
|
+
cwd,
|
|
2889
|
+
platform: process.platform,
|
|
2890
|
+
}));
|
|
2891
|
+
let swappedOwnerLstatCalls = 0;
|
|
2892
|
+
await withPointerDependencies({
|
|
2893
|
+
fs: {
|
|
2894
|
+
lstat: async (path) => {
|
|
2895
|
+
const stat = await lstat(path);
|
|
2896
|
+
const simulateSwap = path === swappedOwnerDir && ++swappedOwnerLstatCalls === 2;
|
|
2897
|
+
return {
|
|
2898
|
+
dev: Number(stat.dev) + (simulateSwap ? 1 : 0),
|
|
2899
|
+
ino: Number(stat.ino),
|
|
2900
|
+
isDirectory: () => stat.isDirectory(),
|
|
2901
|
+
isFile: () => stat.isFile(),
|
|
2902
|
+
isSymbolicLink: () => stat.isSymbolicLink(),
|
|
2903
|
+
};
|
|
2904
|
+
},
|
|
2905
|
+
},
|
|
2906
|
+
}, async () => {
|
|
2907
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, swappedOwnerId)).status, 'malformed');
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
finally {
|
|
2911
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2912
|
+
}
|
|
2913
|
+
});
|
|
2914
|
+
it('rejects owner sidecars recorded for another platform', async () => {
|
|
2915
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-platform-'));
|
|
2916
|
+
try {
|
|
2917
|
+
const ownerDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-platform');
|
|
2918
|
+
await mkdir(ownerDir, { recursive: true });
|
|
2919
|
+
const ownerPath = join(ownerDir, 'session-owner.json');
|
|
2920
|
+
const forgedPlatform = process.platform === 'win32'
|
|
2921
|
+
? 'darwin'
|
|
2922
|
+
: 'win32';
|
|
2923
|
+
await writeFile(ownerPath, JSON.stringify({
|
|
2924
|
+
session_id: 'native-owner-platform',
|
|
2925
|
+
native_session_id: 'native-owner-platform',
|
|
2926
|
+
started_at: new Date().toISOString(),
|
|
2927
|
+
cwd,
|
|
2928
|
+
pid: process.pid,
|
|
2929
|
+
platform: forgedPlatform,
|
|
2930
|
+
}), 'utf-8');
|
|
2931
|
+
const before = await readFile(ownerPath, 'utf-8');
|
|
2932
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-platform')).status, 'identity-indeterminate');
|
|
2933
|
+
assert.equal(await readNativeSessionOwner(cwd, 'native-owner-platform'), null);
|
|
2934
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-platform', {
|
|
2935
|
+
pid: process.pid,
|
|
2936
|
+
}), (error) => isSessionPointerLaunchAbort(error)
|
|
2937
|
+
&& error.code === 'session_pointer_unusable');
|
|
2938
|
+
assert.equal(await readFile(ownerPath, 'utf-8'), before);
|
|
2939
|
+
}
|
|
2940
|
+
finally {
|
|
2941
|
+
await rm(cwd, { recursive: true, force: true });
|
|
2942
|
+
}
|
|
2943
|
+
});
|
|
2944
|
+
it('replaces only stale-dead owner evidence and preserves malformed evidence', async () => {
|
|
2945
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-recovery-'));
|
|
2946
|
+
try {
|
|
2947
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-absent')).status, 'absent');
|
|
2948
|
+
await withPointerDependencies({
|
|
2949
|
+
probePid: (pid) => pid === 11 ? 'dead' : 'alive',
|
|
2950
|
+
observeProcess: () => ({ kind: 'identity', identity: { platform: 'linux', birth: '1' } }),
|
|
2951
|
+
}, async () => {
|
|
2952
|
+
await writeNativeSessionOwner(cwd, 'native-owner-recovery', { pid: 11, platform: 'linux' });
|
|
2953
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-recovery')).status, 'stale-dead');
|
|
2954
|
+
const recovered = await writeNativeSessionOwner(cwd, 'native-owner-recovery', { pid: 22, platform: 'linux' });
|
|
2955
|
+
assert.equal(recovered.pid, 22);
|
|
2956
|
+
const malformedDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-malformed');
|
|
2957
|
+
await mkdir(malformedDir, { recursive: true });
|
|
2958
|
+
const malformedPath = join(malformedDir, 'session-owner.json');
|
|
2959
|
+
await writeFile(malformedPath, '{ malformed', 'utf-8');
|
|
2960
|
+
assert.equal(await readNativeSessionOwner(cwd, 'native-owner-malformed'), null);
|
|
2961
|
+
assert.equal((await readNativeSessionOwnerEvidence(cwd, 'native-owner-malformed')).status, 'malformed');
|
|
2962
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-malformed', { pid: 22, platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2963
|
+
&& error.code === 'session_pointer_unusable'
|
|
2964
|
+
&& error.pointerStatus === 'malformed');
|
|
2965
|
+
const forgedDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-forged');
|
|
2966
|
+
await mkdir(forgedDir, { recursive: true });
|
|
2967
|
+
const forgedPath = join(forgedDir, 'session-owner.json');
|
|
2968
|
+
await writeFile(forgedPath, JSON.stringify({
|
|
2969
|
+
session_id: 'native-owner-other',
|
|
2970
|
+
native_session_id: 'native-owner-other',
|
|
2971
|
+
started_at: new Date().toISOString(),
|
|
2972
|
+
cwd,
|
|
2973
|
+
pid: 22,
|
|
2974
|
+
platform: 'linux',
|
|
2975
|
+
pid_start_ticks: 1,
|
|
2976
|
+
}), 'utf-8');
|
|
2977
|
+
const forgedBefore = await readFile(forgedPath, 'utf-8');
|
|
2978
|
+
assert.equal(await readNativeSessionOwner(cwd, 'native-owner-forged'), null);
|
|
2979
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-forged', { pid: 22, platform: 'linux' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2980
|
+
&& error.code === 'session_pointer_owner_conflict');
|
|
2981
|
+
assert.equal(await readFile(forgedPath, 'utf-8'), forgedBefore);
|
|
2982
|
+
const missingCwdDir = join(cwd, '.omx', 'state', 'sessions', 'native-owner-missing-cwd');
|
|
2983
|
+
await mkdir(missingCwdDir, { recursive: true });
|
|
2984
|
+
const missingCwdPath = join(missingCwdDir, 'session-owner.json');
|
|
2985
|
+
await writeFile(missingCwdPath, JSON.stringify({
|
|
2986
|
+
session_id: 'native-owner-missing-cwd',
|
|
2987
|
+
native_session_id: 'native-owner-missing-cwd',
|
|
2988
|
+
started_at: new Date().toISOString(),
|
|
2989
|
+
pid: 22,
|
|
2990
|
+
platform: 'linux',
|
|
2991
|
+
pid_start_ticks: 1,
|
|
2992
|
+
}), 'utf-8');
|
|
2993
|
+
const missingCwdBefore = await readFile(missingCwdPath, 'utf-8');
|
|
2994
|
+
assert.equal(await readNativeSessionOwner(cwd, 'native-owner-missing-cwd'), null);
|
|
2995
|
+
await assert.rejects(writeNativeSessionOwner(cwd, 'native-owner-missing-cwd', { pid: 22, platform: 'linux' }), (error) => isSessionPointerLaunchAbort(error)
|
|
2996
|
+
&& error.code === 'session_pointer_owner_conflict');
|
|
2997
|
+
assert.equal(await readFile(missingCwdPath, 'utf-8'), missingCwdBefore);
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
finally {
|
|
3001
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3002
|
+
}
|
|
3003
|
+
});
|
|
3004
|
+
it('rejects cross-process native reconciliation while preserving the live selected pointer', async () => {
|
|
3005
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-cross-process-selected-'));
|
|
3006
|
+
try {
|
|
3007
|
+
await withPointerDependencies({
|
|
3008
|
+
runtimePlatform: 'win32',
|
|
3009
|
+
observeProcess: () => matchingWin32Observation(),
|
|
3010
|
+
probePid: () => 'alive',
|
|
3011
|
+
}, async () => {
|
|
3012
|
+
await writeSessionStart(cwd, 'native-selected-a', {
|
|
3013
|
+
nativeSessionId: 'native-selected-a',
|
|
3014
|
+
pid: 11,
|
|
3015
|
+
platform: 'win32',
|
|
3016
|
+
});
|
|
3017
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3018
|
+
const before = await readFile(context.sessionPath, 'utf-8');
|
|
3019
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'native-selected-b', { pid: 22, platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
3020
|
+
&& error.code === 'session_pointer_owner_conflict');
|
|
3021
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), before);
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
finally {
|
|
3025
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3026
|
+
}
|
|
3027
|
+
});
|
|
3028
|
+
it('preserves pointer evidence when history cannot be appended and rejects unusable end states before history or HUD cleanup', async () => {
|
|
3029
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-end-preserve-'));
|
|
3030
|
+
try {
|
|
3031
|
+
await withPointerDependencies({
|
|
3032
|
+
runtimePlatform: 'win32',
|
|
3033
|
+
observeProcess: () => matchingWin32Observation(),
|
|
3034
|
+
}, async () => {
|
|
3035
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3036
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-history', { platform: 'win32' });
|
|
3037
|
+
assert.equal(established.kind, 'committed-released');
|
|
3038
|
+
if (established.kind !== 'committed-released')
|
|
3039
|
+
return;
|
|
3040
|
+
await mkdir(join(cwd, '.omx', 'logs'), { recursive: true });
|
|
3041
|
+
await rm(join(cwd, '.omx', 'logs'), { recursive: true, force: true });
|
|
3042
|
+
await writeFile(join(cwd, '.omx', 'logs'), 'not-a-directory', 'utf-8');
|
|
3043
|
+
await assert.rejects(finalizeBoundOnce(established.binding, 'history-failure'));
|
|
3044
|
+
assert.equal((await readSessionState(cwd))?.session_id, 'sess-history');
|
|
3045
|
+
await rm(join(cwd, '.omx', 'logs'), { force: true });
|
|
3046
|
+
await writeFile(context.sessionPath, '{ malformed', 'utf-8');
|
|
3047
|
+
await assert.rejects(writeSessionEnd(cwd, 'sess-history', { binding: established.binding }), (error) => isSessionPointerLaunchAbort(error)
|
|
3048
|
+
&& error.code === 'session_pointer_unusable'
|
|
3049
|
+
&& error.pointerStatus === 'malformed');
|
|
3050
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), '{ malformed');
|
|
3051
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
finally {
|
|
3055
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3056
|
+
}
|
|
3057
|
+
});
|
|
3058
|
+
});
|
|
3059
|
+
describe('bound launch authority', () => {
|
|
3060
|
+
it('returns one private binding and only capability-free metadata updates', async () => {
|
|
3061
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-authority-'));
|
|
3062
|
+
try {
|
|
3063
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-authority');
|
|
3064
|
+
assert.equal(established.kind, 'committed-released');
|
|
3065
|
+
if (established.kind !== 'committed-released')
|
|
3066
|
+
return;
|
|
3067
|
+
assert.match(established.binding.launchLineageToken, /^[A-Za-z0-9_-]{16,128}$/);
|
|
3068
|
+
assert.equal('handle' in established.binding, false);
|
|
3069
|
+
const metadata = await updateDetachedSessionMetadata(established.binding, {
|
|
3070
|
+
tmuxSessionName: 'omx-authority', tmuxPaneId: '%42',
|
|
3071
|
+
});
|
|
3072
|
+
assert.equal(metadata.kind, 'committed-released');
|
|
3073
|
+
assert.equal((await readSessionState(cwd))?.launch_lineage_token, established.binding.launchLineageToken);
|
|
3074
|
+
const firstFinalization = finalizeBoundOnce(established.binding, 'test');
|
|
3075
|
+
const secondFinalization = finalizeBoundOnce(established.binding, 'duplicate');
|
|
3076
|
+
assert.equal(firstFinalization, secondFinalization);
|
|
3077
|
+
const finalized = await firstFinalization;
|
|
3078
|
+
assert.equal(finalized.finalized, true);
|
|
3079
|
+
assert.equal(finalized.cleanup.comparison?.status, 'matched');
|
|
3080
|
+
assert.equal(existsSync(resolveSessionPointerContext(cwd).sessionPath), false);
|
|
3081
|
+
assert.equal((await closeLaunchSessionBindingOnce(established.binding)).status, 'closed');
|
|
3082
|
+
assert.equal((await closeLaunchSessionBindingOnce(established.binding)).status, 'closed');
|
|
3083
|
+
}
|
|
3084
|
+
finally {
|
|
3085
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3086
|
+
}
|
|
3087
|
+
});
|
|
3088
|
+
it('preserves process_identity across detached metadata updates', async (t) => {
|
|
3089
|
+
if (process.platform !== 'linux') {
|
|
3090
|
+
t.skip('Linux v2 process identity publication semantics are under test.');
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-identity-preserve-'));
|
|
3094
|
+
let binding;
|
|
3095
|
+
try {
|
|
3096
|
+
await withPointerDependencies({
|
|
3097
|
+
runtimePlatform: 'linux',
|
|
3098
|
+
observeProcess: () => matchingObservation('linux'),
|
|
3099
|
+
}, async () => {
|
|
3100
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-identity-preserve', { platform: 'linux' });
|
|
3101
|
+
assert.equal(established.kind, 'committed-released');
|
|
3102
|
+
if (established.kind !== 'committed-released')
|
|
3103
|
+
return;
|
|
3104
|
+
const establishedBinding = established.binding;
|
|
3105
|
+
binding = establishedBinding;
|
|
3106
|
+
const before = await readSessionState(cwd);
|
|
3107
|
+
assert.ok(before?.process_identity);
|
|
3108
|
+
const metadata = await updateDetachedSessionMetadata(establishedBinding, { tmuxPaneId: '%identity-preserved' });
|
|
3109
|
+
assert.equal(metadata.kind, 'committed-released');
|
|
3110
|
+
const after = await readSessionState(cwd);
|
|
3111
|
+
assert.deepEqual(after?.process_identity, before?.process_identity);
|
|
3112
|
+
assert.equal(after?.identity_schema_version, 2);
|
|
3113
|
+
assert.equal(after?.tmux_pane_id, '%identity-preserved');
|
|
3114
|
+
assert.equal((await finalizeBoundOnce(establishedBinding, 'identity-preserved')).finalized, true);
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
finally {
|
|
3118
|
+
if (binding)
|
|
3119
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3120
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3121
|
+
}
|
|
3122
|
+
});
|
|
3123
|
+
it('anchors capability acquisition and finalization to context.baseStateDir rather than cwd', async () => {
|
|
3124
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-selected-root-'));
|
|
3125
|
+
let binding;
|
|
3126
|
+
try {
|
|
3127
|
+
const baseStateDir = join(cwd, 'selected', 'state');
|
|
3128
|
+
const context = {
|
|
3129
|
+
cwd,
|
|
3130
|
+
baseStateDir,
|
|
3131
|
+
rootSource: 'cwd-default',
|
|
3132
|
+
sessionPath: join(baseStateDir, 'session.json'),
|
|
3133
|
+
lockPath: join(baseStateDir, 'session.json.lock'),
|
|
3134
|
+
};
|
|
3135
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-selected-root', { context });
|
|
3136
|
+
assert.equal(established.kind, 'committed-released');
|
|
3137
|
+
if (established.kind !== 'committed-released')
|
|
3138
|
+
return;
|
|
3139
|
+
binding = established.binding;
|
|
3140
|
+
assert.equal(binding.context.baseStateDir, baseStateDir);
|
|
3141
|
+
assert.equal(existsSync(context.sessionPath), true);
|
|
3142
|
+
assert.equal(existsSync(join(cwd, '.omx', 'state', 'session.json')), false);
|
|
3143
|
+
assert.equal((await finalizeBoundOnce(binding, 'selected-root')).finalized, true);
|
|
3144
|
+
assert.equal(existsSync(context.sessionPath), false);
|
|
3145
|
+
}
|
|
3146
|
+
finally {
|
|
3147
|
+
if (binding)
|
|
3148
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3149
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3150
|
+
}
|
|
3151
|
+
});
|
|
3152
|
+
it('rejects forged and changed-incarnation detached metadata before lifecycle mutation', async () => {
|
|
3153
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-metadata-hostile-'));
|
|
3154
|
+
let binding;
|
|
3155
|
+
try {
|
|
3156
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-metadata-hostile');
|
|
3157
|
+
assert.equal(established.kind, 'committed-released');
|
|
3158
|
+
if (established.kind !== 'committed-released')
|
|
3159
|
+
return;
|
|
3160
|
+
binding = established.binding;
|
|
3161
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3162
|
+
const original = await readFile(context.sessionPath, 'utf-8');
|
|
3163
|
+
let mkdirCalls = 0;
|
|
3164
|
+
await withPointerDependencies({
|
|
3165
|
+
fs: { mkdir: async (path, options) => { mkdirCalls += 1; await mkdir(path, options); } },
|
|
3166
|
+
}, async () => {
|
|
3167
|
+
const forged = await updateDetachedSessionMetadata({ ...binding }, { tmuxPaneId: '%forged' });
|
|
3168
|
+
assert.equal(forged.kind, 'precommit-aborted');
|
|
3169
|
+
});
|
|
3170
|
+
assert.equal(mkdirCalls, 0);
|
|
3171
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), original);
|
|
3172
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
3173
|
+
const parsed = JSON.parse(original);
|
|
3174
|
+
const state = { ...parsed, session_id: binding.canonicalSessionId };
|
|
3175
|
+
const changed = JSON.stringify({ ...state, started_at: '1999-01-01T00:00:00.000Z' }, null, 2);
|
|
3176
|
+
await writeFile(context.sessionPath, changed, 'utf-8');
|
|
3177
|
+
const denied = await updateDetachedSessionMetadata(binding, { tmuxPaneId: '%changed' });
|
|
3178
|
+
assert.equal(denied.kind, 'precommit-aborted');
|
|
3179
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), changed);
|
|
3180
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
3181
|
+
}
|
|
3182
|
+
finally {
|
|
3183
|
+
if (binding)
|
|
3184
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3185
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3186
|
+
}
|
|
3187
|
+
});
|
|
3188
|
+
it('rechecks supported directory identity under the metadata lock before pointer mutation', async () => {
|
|
3189
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-metadata-race-'));
|
|
3190
|
+
const retainedCwd = `${cwd}-retained`;
|
|
3191
|
+
let binding;
|
|
3192
|
+
try {
|
|
3193
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-metadata-race');
|
|
3194
|
+
assert.equal(established.kind, 'committed-released');
|
|
3195
|
+
if (established.kind !== 'committed-released')
|
|
3196
|
+
return;
|
|
3197
|
+
binding = established.binding;
|
|
3198
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3199
|
+
const original = await readFile(context.sessionPath, 'utf-8');
|
|
3200
|
+
let replaced = false;
|
|
3201
|
+
let result;
|
|
3202
|
+
await withPointerDependencies({
|
|
3203
|
+
fs: {
|
|
3204
|
+
mkdir: async (path, options) => {
|
|
3205
|
+
if (!replaced && String(path) === context.baseStateDir) {
|
|
3206
|
+
replaced = true;
|
|
3207
|
+
await rename(cwd, retainedCwd);
|
|
3208
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3209
|
+
await writeFile(context.sessionPath, original, 'utf-8');
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
await mkdir(path, options);
|
|
3213
|
+
},
|
|
3214
|
+
},
|
|
3215
|
+
}, async () => { result = await updateDetachedSessionMetadata(binding, { tmuxPaneId: '%race' }); });
|
|
3216
|
+
assert.equal(result?.kind, 'precommit-aborted');
|
|
3217
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), original);
|
|
3218
|
+
assert.equal(await readFile(join(retainedCwd, '.omx', 'state', 'session.json'), 'utf-8'), original);
|
|
3219
|
+
assert.equal(existsSync(context.lockPath), false);
|
|
3220
|
+
}
|
|
3221
|
+
finally {
|
|
3222
|
+
if (binding)
|
|
3223
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3224
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3225
|
+
await rm(retainedCwd, { recursive: true, force: true });
|
|
3226
|
+
}
|
|
3227
|
+
});
|
|
3228
|
+
it('permits an ordinary absent launch when directory identity is unsupported', async () => {
|
|
3229
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-unsupported-'));
|
|
3230
|
+
let binding;
|
|
3231
|
+
try {
|
|
3232
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-unsupported', { platform: 'win32' });
|
|
3233
|
+
assert.equal(established.kind, 'committed-released');
|
|
3234
|
+
if (established.kind !== 'committed-released')
|
|
3235
|
+
return;
|
|
3236
|
+
binding = established.binding;
|
|
3237
|
+
assert.deepEqual(binding.directoryIdentity, { kind: 'unsupported', reason: 'platform' });
|
|
3238
|
+
assert.equal((await readSessionState(cwd))?.session_id, 'sess-binding-unsupported');
|
|
3239
|
+
}
|
|
3240
|
+
finally {
|
|
3241
|
+
if (binding)
|
|
3242
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3243
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3244
|
+
}
|
|
3245
|
+
});
|
|
3246
|
+
it('establishment preflight is absent-only under the selected pointer lock and preserves every existing pointer byte', async () => {
|
|
3247
|
+
const root = await mkdtemp(join(tmpdir(), 'omx-session-binding-preflight-'));
|
|
3248
|
+
try {
|
|
3249
|
+
const cases = [
|
|
3250
|
+
{ name: 'usable', raw: JSON.stringify(makeState({ cwd: join(root, 'usable'), pid: process.pid, platform: 'linux', pid_start_ticks: 1 })) },
|
|
3251
|
+
{ name: 'stale-dead', raw: JSON.stringify(makeState({ cwd: join(root, 'stale-dead'), pid: 424242, platform: 'linux', pid_start_ticks: 1 })), probePid: 'dead' },
|
|
3252
|
+
{ name: 'malformed', raw: '{ malformed pointer' },
|
|
3253
|
+
{ name: 'foreign', raw: JSON.stringify(makeState({ cwd: join(root, 'foreign', 'other'), platform: 'win32' })) },
|
|
3254
|
+
{ name: 'indeterminate', raw: JSON.stringify(makeState({ cwd: join(root, 'indeterminate'), pid: 424242, platform: 'linux', pid_start_ticks: 1 })), probePid: 'indeterminate' },
|
|
3255
|
+
];
|
|
3256
|
+
for (const testCase of cases) {
|
|
3257
|
+
const cwd = join(root, testCase.name);
|
|
3258
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3259
|
+
const order = [];
|
|
3260
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3261
|
+
await writeFile(context.sessionPath, testCase.raw, 'utf-8');
|
|
3262
|
+
await withPointerDependencies({
|
|
3263
|
+
probePid: () => testCase.probePid ?? 'alive',
|
|
3264
|
+
observeProcess: () => matchingObservation(),
|
|
1124
3265
|
fs: {
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
},
|
|
3266
|
+
mkdir: async (path, options) => { order.push(`mkdir:${path}`); await mkdir(path, options); },
|
|
3267
|
+
readFile: async (path, encoding) => { order.push(`read:${path}`); return await readFile(path, encoding); },
|
|
3268
|
+
rename: async (from, to) => { order.push(`rename:${from}:${to}`); await rename(from, to); },
|
|
3269
|
+
unlink: async (path) => { order.push(`unlink:${path}`); await rm(path, { force: false }); },
|
|
1130
3270
|
},
|
|
1131
|
-
})
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
for (const failure of failures) {
|
|
1135
|
-
const cwd = await mkdtemp(join(tmpdir(), `omx-session-${failure.name}-`));
|
|
1136
|
-
try {
|
|
1137
|
-
const context = resolveSessionPointerContext(cwd);
|
|
1138
|
-
await withPointerDependencies(failure.dependencies(context), async () => {
|
|
1139
|
-
await assert.rejects(writeSessionStart(cwd, 'sess-precommit', { platform: 'win32' }), (error) => isSessionPointerLaunchAbort(error)
|
|
1140
|
-
&& error.code === 'session_pointer_io_failure'
|
|
1141
|
-
&& error.operation === failure.operation);
|
|
3271
|
+
}, async () => {
|
|
3272
|
+
const established = await establishLaunchSessionBinding(cwd, `sess-preflight-${testCase.name}`);
|
|
3273
|
+
assert.equal(established.kind, 'precommit-aborted', testCase.name);
|
|
1142
3274
|
});
|
|
1143
|
-
assert.equal(
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
3275
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), testCase.raw, testCase.name);
|
|
3276
|
+
assert.ok(order.indexOf(`mkdir:${context.lockPath}`) < order.indexOf(`read:${context.sessionPath}`), testCase.name);
|
|
3277
|
+
assert.equal(order.filter((entry) => entry === `rename:${context.sessionPath}`).length, 0, testCase.name);
|
|
3278
|
+
assert.equal(order.filter((entry) => entry === `unlink:${context.sessionPath}`).length, 0, testCase.name);
|
|
3279
|
+
assert.equal(existsSync(context.lockPath), false, testCase.name);
|
|
1147
3280
|
}
|
|
1148
3281
|
}
|
|
1149
|
-
|
|
3282
|
+
finally {
|
|
3283
|
+
await rm(root, { recursive: true, force: true });
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
it('denies an absent bound pointer without creating synthetic terminal history', async () => {
|
|
3287
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-absent-finalization-'));
|
|
3288
|
+
let binding;
|
|
1150
3289
|
try {
|
|
3290
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-absent-finalization');
|
|
3291
|
+
assert.equal(established.kind, 'committed-released');
|
|
3292
|
+
if (established.kind !== 'committed-released')
|
|
3293
|
+
return;
|
|
3294
|
+
binding = established.binding;
|
|
1151
3295
|
const context = resolveSessionPointerContext(cwd);
|
|
1152
|
-
await
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
});
|
|
3296
|
+
await rm(context.sessionPath);
|
|
3297
|
+
const finalized = await finalizeBoundOnce(binding, 'absent');
|
|
3298
|
+
assert.equal(finalized.finalized, false);
|
|
3299
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
3300
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`)), true);
|
|
3301
|
+
}
|
|
3302
|
+
finally {
|
|
3303
|
+
if (binding)
|
|
3304
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3305
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3306
|
+
}
|
|
3307
|
+
});
|
|
3308
|
+
it('rejects unbound usable finalization before changing any lifecycle bytes', async () => {
|
|
3309
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-unbound-'));
|
|
3310
|
+
let binding;
|
|
3311
|
+
try {
|
|
3312
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-unbound');
|
|
3313
|
+
assert.equal(established.kind, 'committed-released');
|
|
3314
|
+
if (established.kind !== 'committed-released')
|
|
3315
|
+
return;
|
|
3316
|
+
binding = established.binding;
|
|
3317
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3318
|
+
const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
|
|
3319
|
+
const dailyPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
3320
|
+
const hudPath = join(cwd, '.omx', 'state', 'hud-state.json');
|
|
3321
|
+
const metricsPath = join(cwd, '.omx', 'metrics.json');
|
|
3322
|
+
await writeFile(historyPath, 'history-sentinel\n', 'utf-8');
|
|
3323
|
+
await writeFile(hudPath, 'hud-sentinel\n', 'utf-8');
|
|
3324
|
+
await writeFile(metricsPath, 'metrics-sentinel\n', 'utf-8');
|
|
3325
|
+
const before = {
|
|
3326
|
+
pointer: await readFile(context.sessionPath, 'utf-8'),
|
|
3327
|
+
history: await readFile(historyPath, 'utf-8'),
|
|
3328
|
+
daily: await readFile(dailyPath, 'utf-8'),
|
|
3329
|
+
hud: await readFile(hudPath, 'utf-8'),
|
|
3330
|
+
metrics: await readFile(metricsPath, 'utf-8'),
|
|
3331
|
+
};
|
|
3332
|
+
await assert.rejects(() => writeSessionEnd(cwd, 'sess-binding-unbound'), isSessionPointerLaunchAbort);
|
|
3333
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), before.pointer);
|
|
3334
|
+
assert.equal(await readFile(historyPath, 'utf-8'), before.history);
|
|
3335
|
+
assert.equal(await readFile(dailyPath, 'utf-8'), before.daily);
|
|
3336
|
+
assert.equal(await readFile(hudPath, 'utf-8'), before.hud);
|
|
3337
|
+
assert.equal(await readFile(metricsPath, 'utf-8'), before.metrics);
|
|
1157
3338
|
assert.equal(existsSync(context.lockPath), false);
|
|
1158
|
-
assert.equal(existsSync(`${context.sessionPath}.tmp-bad`), false);
|
|
1159
3339
|
}
|
|
1160
3340
|
finally {
|
|
3341
|
+
if (binding)
|
|
3342
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
1161
3343
|
await rm(cwd, { recursive: true, force: true });
|
|
1162
3344
|
}
|
|
1163
3345
|
});
|
|
1164
|
-
it('
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
3346
|
+
it('preserves a compatible tokenless pointer and refuses to establish cleanup authority from it', async () => {
|
|
3347
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-tokenless-'));
|
|
3348
|
+
try {
|
|
3349
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3350
|
+
await reconcileNativeSessionStart(cwd, 'native-compatible-tokenless');
|
|
3351
|
+
const beforeUpdate = await readFile(context.sessionPath, 'utf-8');
|
|
3352
|
+
await assert.rejects(() => writeSessionStart(cwd, 'native-compatible-tokenless', { tmuxPaneId: '%42' }), isSessionPointerLaunchAbort);
|
|
3353
|
+
assert.equal((await readSessionState(cwd))?.launch_lineage_token, undefined);
|
|
3354
|
+
const afterUpdate = await readFile(context.sessionPath, 'utf-8');
|
|
3355
|
+
assert.equal(afterUpdate, beforeUpdate);
|
|
3356
|
+
const established = await establishLaunchSessionBinding(cwd, 'native-compatible-tokenless');
|
|
3357
|
+
assert.equal(established.kind, 'precommit-aborted');
|
|
3358
|
+
if (established.kind !== 'precommit-aborted')
|
|
3359
|
+
return;
|
|
3360
|
+
assert.equal(established.abort.committed, false);
|
|
3361
|
+
assert.equal(established.abort.code, 'session_pointer_owner_conflict');
|
|
3362
|
+
assert.equal((await readSessionState(cwd))?.launch_lineage_token, undefined);
|
|
3363
|
+
assert.equal(afterUpdate.includes('launch_lineage_token'), false);
|
|
3364
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), afterUpdate);
|
|
3365
|
+
}
|
|
3366
|
+
finally {
|
|
3367
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3368
|
+
}
|
|
3369
|
+
});
|
|
3370
|
+
it('preserves a compatible malformed lineage token without granting cleanup authority', async () => {
|
|
3371
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-malformed-token-'));
|
|
3372
|
+
try {
|
|
3373
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3374
|
+
const tokenless = await reconcileNativeSessionStart(cwd, 'native-compatible-malformed-token');
|
|
3375
|
+
await writeFile(context.sessionPath, JSON.stringify({ ...tokenless, launch_lineage_token: 'bad' }), 'utf-8');
|
|
3376
|
+
const beforeUpdate = await readFile(context.sessionPath, 'utf-8');
|
|
3377
|
+
await assert.rejects(() => writeSessionStart(cwd, 'native-compatible-malformed-token', { tmuxPaneId: '%42' }), isSessionPointerLaunchAbort);
|
|
3378
|
+
const afterUpdate = await readFile(context.sessionPath, 'utf-8');
|
|
3379
|
+
assert.equal(afterUpdate, beforeUpdate);
|
|
3380
|
+
assert.match(afterUpdate, /"launch_lineage_token":"bad"|"launch_lineage_token": "bad"/);
|
|
3381
|
+
assert.equal((await reconcileNativeSessionStart(cwd, 'native-compatible-malformed-token')).launch_lineage_token, 'bad');
|
|
3382
|
+
const beforeEstablishment = await readFile(context.sessionPath, 'utf-8');
|
|
3383
|
+
const established = await establishLaunchSessionBinding(cwd, 'native-compatible-malformed-token');
|
|
3384
|
+
assert.equal(established.kind, 'precommit-aborted');
|
|
3385
|
+
if (established.kind !== 'precommit-aborted')
|
|
3386
|
+
return;
|
|
3387
|
+
assert.equal(established.abort.committed, false);
|
|
3388
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), beforeEstablishment);
|
|
3389
|
+
}
|
|
3390
|
+
finally {
|
|
3391
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3392
|
+
}
|
|
3393
|
+
});
|
|
3394
|
+
it('preserves malformed pointer bytes when binding establishment aborts before commit', async () => {
|
|
3395
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-malformed-'));
|
|
3396
|
+
try {
|
|
3397
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3398
|
+
const malformed = '{ malformed pointer';
|
|
3399
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3400
|
+
await writeFile(context.sessionPath, malformed, 'utf-8');
|
|
3401
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-malformed');
|
|
3402
|
+
assert.equal(established.kind, 'precommit-aborted');
|
|
3403
|
+
if (established.kind !== 'precommit-aborted')
|
|
3404
|
+
return;
|
|
3405
|
+
assert.equal(established.abort.committed, false);
|
|
3406
|
+
assert.equal(established.abort.code, 'session_pointer_unusable');
|
|
3407
|
+
assert.equal(established.abort.pointerStatus, 'malformed');
|
|
3408
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), malformed);
|
|
3409
|
+
}
|
|
3410
|
+
finally {
|
|
3411
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3412
|
+
}
|
|
3413
|
+
});
|
|
3414
|
+
it('finalizes only its exact stale-dead native pointer after history is retained', async () => {
|
|
3415
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-stale-dead-'));
|
|
3416
|
+
try {
|
|
3417
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-stale-dead');
|
|
3418
|
+
assert.equal(established.kind, 'committed-released');
|
|
3419
|
+
if (established.kind !== 'committed-released')
|
|
3420
|
+
return;
|
|
3421
|
+
await reconcileNativeSessionStart(cwd, 'native-binding-stale-dead', { pid: 424242 });
|
|
3422
|
+
await withPointerDependencies({ probePid: () => 'dead' }, async () => {
|
|
3423
|
+
const finalized = await finalizeBoundOnce(established.binding, 'test');
|
|
3424
|
+
assert.equal(finalized.finalized, true);
|
|
3425
|
+
});
|
|
3426
|
+
await closeLaunchSessionBindingOnce(established.binding);
|
|
3427
|
+
assert.equal(existsSync(resolveSessionPointerContext(cwd).sessionPath), false);
|
|
3428
|
+
const history = await readFile(join(cwd, '.omx', 'logs', 'session-history.jsonl'), 'utf-8');
|
|
3429
|
+
assert.match(history, /native-binding-stale-dead/);
|
|
3430
|
+
}
|
|
3431
|
+
finally {
|
|
3432
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3433
|
+
}
|
|
3434
|
+
});
|
|
3435
|
+
it('preserves a stale-dead pointer whose bound lineage token does not match', async () => {
|
|
3436
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-stale-mismatch-'));
|
|
3437
|
+
let binding;
|
|
3438
|
+
try {
|
|
3439
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-stale-mismatch');
|
|
3440
|
+
assert.equal(established.kind, 'committed-released');
|
|
3441
|
+
if (established.kind !== 'committed-released')
|
|
3442
|
+
return;
|
|
3443
|
+
binding = established.binding;
|
|
3444
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3445
|
+
const state = await reconcileNativeSessionStart(cwd, 'native-binding-stale-mismatch', { pid: 424242 });
|
|
3446
|
+
await writeFile(context.sessionPath, JSON.stringify({ ...state, launch_lineage_token: FOREIGN_TOKEN }), 'utf-8');
|
|
3447
|
+
await withPointerDependencies({ probePid: () => 'dead' }, async () => {
|
|
3448
|
+
const denied = await finalizeBoundOnce(binding, 'test');
|
|
3449
|
+
assert.equal(denied.finalized, false);
|
|
3450
|
+
});
|
|
3451
|
+
assert.equal(existsSync(context.sessionPath), true);
|
|
3452
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
3453
|
+
}
|
|
3454
|
+
finally {
|
|
3455
|
+
if (binding)
|
|
3456
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3457
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3458
|
+
}
|
|
3459
|
+
});
|
|
3460
|
+
it('finalizes an exact bound current process when identity observation is unavailable', async () => {
|
|
3461
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-indeterminate-current-'));
|
|
3462
|
+
let binding;
|
|
3463
|
+
try {
|
|
3464
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-indeterminate-current');
|
|
3465
|
+
assert.equal(established.kind, 'committed-released');
|
|
3466
|
+
if (established.kind !== 'committed-released')
|
|
3467
|
+
return;
|
|
3468
|
+
binding = established.binding;
|
|
3469
|
+
await withPointerDependencies({
|
|
3470
|
+
probePid: () => 'indeterminate',
|
|
3471
|
+
observeProcess: () => ({ kind: 'error' }),
|
|
3472
|
+
}, async () => {
|
|
3473
|
+
await writeSessionEnd(cwd, binding.canonicalSessionId, { binding });
|
|
3474
|
+
});
|
|
3475
|
+
assert.equal(await readSessionState(cwd), null);
|
|
3476
|
+
}
|
|
3477
|
+
finally {
|
|
3478
|
+
if (binding)
|
|
3479
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3480
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3481
|
+
}
|
|
3482
|
+
});
|
|
3483
|
+
it('preserves usable and stale bound pointers without the exact valid lineage token before any mutation', async () => {
|
|
3484
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-token-gate-'));
|
|
3485
|
+
let binding;
|
|
3486
|
+
try {
|
|
3487
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-token-gate');
|
|
3488
|
+
assert.equal(established.kind, 'committed-released');
|
|
3489
|
+
if (established.kind !== 'committed-released')
|
|
3490
|
+
return;
|
|
3491
|
+
binding = established.binding;
|
|
3492
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3493
|
+
const state = await readSessionState(cwd);
|
|
3494
|
+
assert.ok(state);
|
|
3495
|
+
const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
|
|
3496
|
+
const dailyPath = join(cwd, '.omx', 'logs', `omx-${todayIsoDate()}.jsonl`);
|
|
3497
|
+
const hudPath = join(cwd, '.omx', 'state', 'hud-state.json');
|
|
3498
|
+
const metricsPath = join(cwd, '.omx', 'metrics.json');
|
|
3499
|
+
await writeFile(historyPath, 'history-sentinel\n', 'utf-8');
|
|
3500
|
+
await writeFile(hudPath, 'hud-sentinel\n', 'utf-8');
|
|
3501
|
+
await writeFile(metricsPath, 'metrics-sentinel\n', 'utf-8');
|
|
3502
|
+
const lifecycleBytes = {
|
|
3503
|
+
history: await readFile(historyPath, 'utf-8'),
|
|
3504
|
+
daily: await readFile(dailyPath, 'utf-8'),
|
|
3505
|
+
hud: await readFile(hudPath, 'utf-8'),
|
|
3506
|
+
metrics: await readFile(metricsPath, 'utf-8'),
|
|
3507
|
+
};
|
|
3508
|
+
const cases = [
|
|
3509
|
+
{ name: 'tokenless usable', state: (() => { const { launch_lineage_token: _, ...tokenless } = state; return tokenless; })() },
|
|
3510
|
+
{ name: 'malformed usable', state: { ...state, launch_lineage_token: 'bad' } },
|
|
3511
|
+
{ name: 'mismatched usable', state: { ...state, launch_lineage_token: FOREIGN_TOKEN } },
|
|
3512
|
+
{ name: 'replaced usable', state: { ...state, launch_lineage_token: SUCCESSOR_TOKEN } },
|
|
3513
|
+
{ name: 'same-token changed incarnation', state: { ...state, started_at: '1999-01-01T00:00:00.000Z' } },
|
|
3514
|
+
{ name: 'unknown identity', state: { ...state, pid: 424242 }, probePid: 'indeterminate' },
|
|
3515
|
+
{
|
|
3516
|
+
name: 'tokenless stale native',
|
|
3517
|
+
state: (() => {
|
|
3518
|
+
const { launch_lineage_token: _, ...tokenless } = state;
|
|
3519
|
+
return { ...tokenless, native_session_id: 'native-tokenless-stale', pid: 424242 };
|
|
3520
|
+
})(),
|
|
3521
|
+
probePid: 'dead',
|
|
3522
|
+
},
|
|
3523
|
+
];
|
|
3524
|
+
for (const testCase of cases) {
|
|
3525
|
+
const raw = JSON.stringify(testCase.state);
|
|
3526
|
+
let mkdirCalls = 0;
|
|
3527
|
+
let unlinkCalls = 0;
|
|
3528
|
+
await writeFile(context.sessionPath, raw, 'utf-8');
|
|
1175
3529
|
await withPointerDependencies({
|
|
1176
|
-
|
|
3530
|
+
probePid: () => testCase.probePid ?? 'alive',
|
|
1177
3531
|
fs: {
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
throw new Error('pointer write failure');
|
|
1182
|
-
},
|
|
1183
|
-
openAndSync: async (path) => {
|
|
1184
|
-
if (phase.fail === 'sync' && path === tempPath)
|
|
1185
|
-
throw new Error('pointer sync failure');
|
|
1186
|
-
return 'synced';
|
|
1187
|
-
},
|
|
1188
|
-
rename: async (from, to) => {
|
|
1189
|
-
if (phase.fail === 'rename' && from === tempPath && to === context.sessionPath) {
|
|
1190
|
-
throw new Error('pointer rename failure');
|
|
1191
|
-
}
|
|
1192
|
-
await rename(from, to);
|
|
1193
|
-
},
|
|
3532
|
+
mkdir: async (path, options) => { mkdirCalls += 1; await mkdir(path, options); },
|
|
3533
|
+
unlink: async (path) => { if (path === context.sessionPath)
|
|
3534
|
+
unlinkCalls += 1; await rm(path, { force: false }); },
|
|
1194
3535
|
},
|
|
1195
3536
|
}, async () => {
|
|
1196
|
-
await assert.rejects(
|
|
1197
|
-
&& error.code === 'session_pointer_io_failure'
|
|
1198
|
-
&& error.operation === phase.operation);
|
|
3537
|
+
await assert.rejects(writeSessionEnd(cwd, binding.canonicalSessionId, { binding }), isSessionPointerLaunchAbort);
|
|
1199
3538
|
});
|
|
1200
|
-
assert.equal(
|
|
1201
|
-
assert.
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
await
|
|
3539
|
+
assert.equal(await readFile(context.sessionPath, 'utf-8'), raw, testCase.name);
|
|
3540
|
+
assert.ok(mkdirCalls <= 1, testCase.name);
|
|
3541
|
+
assert.equal(unlinkCalls, 0, testCase.name);
|
|
3542
|
+
assert.equal(existsSync(context.lockPath), false, testCase.name);
|
|
3543
|
+
assert.equal(await readFile(historyPath, 'utf-8'), lifecycleBytes.history, testCase.name);
|
|
3544
|
+
assert.equal(await readFile(dailyPath, 'utf-8'), lifecycleBytes.daily, testCase.name);
|
|
3545
|
+
assert.equal(await readFile(hudPath, 'utf-8'), lifecycleBytes.hud, testCase.name);
|
|
3546
|
+
assert.equal(await readFile(metricsPath, 'utf-8'), lifecycleBytes.metrics, testCase.name);
|
|
1205
3547
|
}
|
|
1206
3548
|
}
|
|
3549
|
+
finally {
|
|
3550
|
+
if (binding)
|
|
3551
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3552
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3553
|
+
}
|
|
1207
3554
|
});
|
|
1208
|
-
it('
|
|
1209
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
3555
|
+
it('denies selected-root replacement under lock before pointer publication', async () => {
|
|
3556
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-establish-root-race-'));
|
|
3557
|
+
const displacedRoot = join(cwd, 'displaced-state');
|
|
1210
3558
|
try {
|
|
1211
3559
|
const context = resolveSessionPointerContext(cwd);
|
|
1212
|
-
|
|
3560
|
+
let replaced = false;
|
|
3561
|
+
let established;
|
|
1213
3562
|
await withPointerDependencies({
|
|
1214
|
-
token: () => TEST_TOKEN,
|
|
1215
3563
|
fs: {
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
throw new Error('pointer temp cleanup failure');
|
|
1224
|
-
await rm(path, { force: false });
|
|
1225
|
-
},
|
|
1226
|
-
rmdir: async (path) => {
|
|
1227
|
-
if (path.endsWith(`.release-${TEST_TOKEN}`))
|
|
1228
|
-
throw new Error('release cleanup failure');
|
|
1229
|
-
await rm(path, { recursive: false });
|
|
3564
|
+
openAndSync: async (path) => {
|
|
3565
|
+
if (!replaced && path.startsWith(`${context.sessionPath}.tmp-`)) {
|
|
3566
|
+
replaced = true;
|
|
3567
|
+
await rename(context.baseStateDir, displacedRoot);
|
|
3568
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3569
|
+
}
|
|
3570
|
+
return 'synced';
|
|
1230
3571
|
},
|
|
1231
3572
|
},
|
|
1232
|
-
}, async () => {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
&& error.secondaryFailures?.[0]?.evidencePath === tempPath);
|
|
1238
|
-
});
|
|
1239
|
-
assert.equal(existsSync(tempPath), true);
|
|
1240
|
-
assert.equal(existsSync(`${context.lockPath}.release-${TEST_TOKEN}`), true);
|
|
3573
|
+
}, async () => { established = await establishLaunchSessionBinding(cwd, 'sess-establish-root-race'); });
|
|
3574
|
+
assert.ok(established);
|
|
3575
|
+
assert.notEqual(established.kind, 'committed-released');
|
|
3576
|
+
assert.equal(existsSync(context.sessionPath), false);
|
|
3577
|
+
assert.equal(existsSync(join(displacedRoot, 'session.json')), false);
|
|
1241
3578
|
}
|
|
1242
3579
|
finally {
|
|
1243
3580
|
await rm(cwd, { recursive: true, force: true });
|
|
1244
3581
|
}
|
|
1245
3582
|
});
|
|
1246
|
-
it('
|
|
1247
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
3583
|
+
it('returns committed blocked evidence when selected root changes after pointer rename', async () => {
|
|
3584
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-establish-post-rename-race-'));
|
|
3585
|
+
const displacedRoot = join(cwd, 'displaced-after-rename');
|
|
1248
3586
|
try {
|
|
1249
3587
|
const context = resolveSessionPointerContext(cwd);
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
await mkdir(context.baseStateDir, { recursive: true });
|
|
1253
|
-
await writeFile(foreignTempPath, 'foreign transaction evidence', 'utf-8');
|
|
3588
|
+
let replaced = false;
|
|
3589
|
+
let established;
|
|
1254
3590
|
await withPointerDependencies({
|
|
1255
|
-
token: () => TEST_TOKEN,
|
|
1256
3591
|
fs: {
|
|
1257
|
-
writeFile: async (path, data, options) => {
|
|
1258
|
-
await writeFile(path, data, options);
|
|
1259
|
-
if (path === ownTempPath)
|
|
1260
|
-
throw new Error('pointer write failure');
|
|
1261
|
-
},
|
|
1262
|
-
unlink: async (path) => {
|
|
1263
|
-
if (path === ownTempPath)
|
|
1264
|
-
throw new Error('keep own residue');
|
|
1265
|
-
await rm(path, { force: false });
|
|
1266
|
-
},
|
|
1267
3592
|
rename: async (from, to) => {
|
|
1268
|
-
if (from === context.lockPath)
|
|
1269
|
-
throw new Error('preserve canonical lock');
|
|
1270
3593
|
await rename(from, to);
|
|
3594
|
+
if (!replaced && to === context.sessionPath) {
|
|
3595
|
+
replaced = true;
|
|
3596
|
+
await rename(context.baseStateDir, displacedRoot);
|
|
3597
|
+
await mkdir(context.baseStateDir, { recursive: true });
|
|
3598
|
+
}
|
|
1271
3599
|
},
|
|
1272
3600
|
},
|
|
1273
|
-
}, async () => {
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
assert.equal(existsSync(
|
|
1277
|
-
assert.equal(existsSync(
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
await rm(
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
3601
|
+
}, async () => { established = await establishLaunchSessionBinding(cwd, 'sess-establish-post-rename-race'); });
|
|
3602
|
+
assert.ok(established);
|
|
3603
|
+
assert.equal(established.kind, 'committed-release-failed');
|
|
3604
|
+
assert.equal(existsSync(context.sessionPath), false);
|
|
3605
|
+
assert.equal(existsSync(join(displacedRoot, 'session.json')), true);
|
|
3606
|
+
}
|
|
3607
|
+
finally {
|
|
3608
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3609
|
+
}
|
|
3610
|
+
});
|
|
3611
|
+
it('denies same-path replacement before creating state, lock, history, or HUD artifacts', async () => {
|
|
3612
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-replaced-path-'));
|
|
3613
|
+
const retainedCwd = `${cwd}-retained`;
|
|
3614
|
+
let binding;
|
|
3615
|
+
try {
|
|
3616
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-binding-replaced-path');
|
|
3617
|
+
assert.equal(established.kind, 'committed-released');
|
|
3618
|
+
if (established.kind !== 'committed-released')
|
|
3619
|
+
return;
|
|
3620
|
+
binding = established.binding;
|
|
3621
|
+
await rename(cwd, retainedCwd);
|
|
3622
|
+
await mkdir(cwd);
|
|
3623
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3624
|
+
const finalized = await finalizeBoundOnce(binding, 'test');
|
|
3625
|
+
assert.equal(finalized.finalized, false);
|
|
3626
|
+
assert.equal(finalized.cleanup.comparison?.status, 'denied');
|
|
3627
|
+
assert.equal(existsSync(context.baseStateDir), false);
|
|
1287
3628
|
assert.equal(existsSync(context.lockPath), false);
|
|
3629
|
+
assert.equal(existsSync(join(cwd, '.omx', 'logs', 'session-history.jsonl')), false);
|
|
3630
|
+
assert.equal(existsSync(join(cwd, '.omx', 'state', 'hud-state.json')), false);
|
|
1288
3631
|
}
|
|
1289
3632
|
finally {
|
|
3633
|
+
if (binding)
|
|
3634
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
1290
3635
|
await rm(cwd, { recursive: true, force: true });
|
|
3636
|
+
await rm(retainedCwd, { recursive: true, force: true });
|
|
1291
3637
|
}
|
|
1292
3638
|
});
|
|
1293
|
-
it('
|
|
1294
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
3639
|
+
it('keeps native absent reconciliation tokenless without backfill', async () => {
|
|
3640
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-tokenless-'));
|
|
1295
3641
|
try {
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
await
|
|
1302
|
-
|
|
3642
|
+
assert.equal((await reconcileNativeSessionStart(cwd, 'native-tokenless')).launch_lineage_token, undefined);
|
|
3643
|
+
assert.equal((await reconcileNativeSessionStart(cwd, 'native-tokenless')).launch_lineage_token, undefined);
|
|
3644
|
+
assert.equal((await readSessionState(cwd))?.launch_lineage_token, undefined);
|
|
3645
|
+
}
|
|
3646
|
+
finally {
|
|
3647
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3648
|
+
}
|
|
3649
|
+
});
|
|
3650
|
+
it('allows authorized unsupported-capability stale-dead finalization without weakening bound lineage checks', async () => {
|
|
3651
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-binding-unsupported-lifecycle-'));
|
|
3652
|
+
let ordinary;
|
|
3653
|
+
let stale;
|
|
3654
|
+
try {
|
|
3655
|
+
await withPointerDependencies({
|
|
3656
|
+
runtimePlatform: 'win32',
|
|
3657
|
+
observeProcess: () => matchingWin32Observation(),
|
|
3658
|
+
}, async () => {
|
|
3659
|
+
const ordinaryEstablished = await establishLaunchSessionBinding(cwd, 'sess-unsupported-ordinary', { platform: 'win32' });
|
|
3660
|
+
assert.equal(ordinaryEstablished.kind, 'committed-released');
|
|
3661
|
+
if (ordinaryEstablished.kind !== 'committed-released')
|
|
3662
|
+
return;
|
|
3663
|
+
ordinary = ordinaryEstablished.binding;
|
|
3664
|
+
assert.deepEqual(ordinary.directoryIdentity, { kind: 'unsupported', reason: 'platform' });
|
|
3665
|
+
const ordinaryFinalized = await finalizeBoundOnce(ordinary, 'ordinary-unsupported');
|
|
3666
|
+
assert.equal(ordinaryFinalized.finalized, true);
|
|
3667
|
+
assert.equal(existsSync(resolveSessionPointerContext(cwd).sessionPath), false);
|
|
3668
|
+
assert.equal((await closeLaunchSessionBindingOnce(ordinary)).status, 'closed');
|
|
3669
|
+
const staleEstablished = await establishLaunchSessionBinding(cwd, 'sess-unsupported-stale', {
|
|
3670
|
+
nativeSessionId: 'native-unsupported-stale',
|
|
1303
3671
|
platform: 'win32',
|
|
1304
|
-
ownerAliasVerified: true,
|
|
1305
3672
|
});
|
|
1306
|
-
assert.equal(
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
3673
|
+
assert.equal(staleEstablished.kind, 'committed-released');
|
|
3674
|
+
if (staleEstablished.kind !== 'committed-released')
|
|
3675
|
+
return;
|
|
3676
|
+
stale = staleEstablished.binding;
|
|
3677
|
+
await reconcileNativeSessionStart(cwd, 'native-unsupported-stale', { pid: 424242, platform: 'win32' });
|
|
3678
|
+
const context = resolveSessionPointerContext(cwd);
|
|
3679
|
+
const historyPath = join(cwd, '.omx', 'logs', 'session-history.jsonl');
|
|
3680
|
+
await withPointerDependencies({
|
|
3681
|
+
runtimePlatform: 'win32',
|
|
3682
|
+
observeProcess: () => matchingWin32Observation(),
|
|
3683
|
+
probePid: () => 'dead',
|
|
3684
|
+
}, async () => {
|
|
3685
|
+
const finalized = await finalizeBoundOnce(stale, 'stale-unsupported');
|
|
3686
|
+
assert.equal(finalized.finalized, true);
|
|
3687
|
+
assert.equal(finalized.cleanup.comparison?.status, 'matched');
|
|
3688
|
+
assert.match(finalized.cleanup.comparison?.reason ?? '', /unsupported-directory-capability:platform/);
|
|
1311
3689
|
});
|
|
1312
|
-
assert.equal(
|
|
1313
|
-
|
|
3690
|
+
assert.equal(existsSync(context.sessionPath), false);
|
|
3691
|
+
const history = await readFile(historyPath, 'utf-8');
|
|
3692
|
+
assert.match(history, /native-unsupported-stale/);
|
|
1314
3693
|
});
|
|
1315
3694
|
}
|
|
1316
3695
|
finally {
|
|
3696
|
+
if (ordinary)
|
|
3697
|
+
await closeLaunchSessionBindingOnce(ordinary).catch(() => { });
|
|
3698
|
+
if (stale)
|
|
3699
|
+
await closeLaunchSessionBindingOnce(stale).catch(() => { });
|
|
1317
3700
|
await rm(cwd, { recursive: true, force: true });
|
|
1318
3701
|
}
|
|
1319
3702
|
});
|
|
1320
|
-
it('preserves
|
|
1321
|
-
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-
|
|
3703
|
+
it('preserves native lineage and tmux metadata when rejecting a live native-ID replacement', async () => {
|
|
3704
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-native-lineage-metadata-'));
|
|
3705
|
+
let binding;
|
|
3706
|
+
try {
|
|
3707
|
+
const established = await establishLaunchSessionBinding(cwd, 'sess-native-lineage-metadata', {
|
|
3708
|
+
nativeSessionId: 'native-before',
|
|
3709
|
+
tmuxSessionName: 'omx-native-lineage',
|
|
3710
|
+
tmuxPaneId: '%88',
|
|
3711
|
+
});
|
|
3712
|
+
assert.equal(established.kind, 'committed-released');
|
|
3713
|
+
if (established.kind !== 'committed-released')
|
|
3714
|
+
return;
|
|
3715
|
+
binding = established.binding;
|
|
3716
|
+
await assert.rejects(reconcileNativeSessionStart(cwd, 'native-after', { pid: process.pid }), isOwnerConflict);
|
|
3717
|
+
const persisted = await readSessionState(cwd);
|
|
3718
|
+
assert.equal(persisted?.session_id, binding.canonicalSessionId);
|
|
3719
|
+
assert.equal(persisted?.native_session_id, 'native-before');
|
|
3720
|
+
assert.equal(persisted?.previous_native_session_id, undefined);
|
|
3721
|
+
assert.equal(persisted?.launch_lineage_token, binding.launchLineageToken);
|
|
3722
|
+
assert.equal(persisted?.tmux_session_name, 'omx-native-lineage');
|
|
3723
|
+
assert.equal(persisted?.tmux_pane_id, '%88');
|
|
3724
|
+
}
|
|
3725
|
+
finally {
|
|
3726
|
+
if (binding)
|
|
3727
|
+
await closeLaunchSessionBindingOnce(binding).catch(() => { });
|
|
3728
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3729
|
+
}
|
|
3730
|
+
});
|
|
3731
|
+
it('reports owner unlink residue and does not rmdir it', async () => {
|
|
3732
|
+
const cwd = await mkdtemp(join(tmpdir(), 'omx-session-owner-unlink-'));
|
|
1322
3733
|
try {
|
|
1323
3734
|
const context = resolveSessionPointerContext(cwd);
|
|
1324
|
-
|
|
1325
|
-
await
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
3735
|
+
let rmdirCalls = 0;
|
|
3736
|
+
await withPointerDependencies({
|
|
3737
|
+
token: () => TEST_TOKEN,
|
|
3738
|
+
fs: {
|
|
3739
|
+
unlink: async (path) => {
|
|
3740
|
+
if (path.endsWith(`.release-${TEST_TOKEN}/owner.json`))
|
|
3741
|
+
throw new Error('owner unlink failure');
|
|
3742
|
+
await rm(path, { force: false });
|
|
3743
|
+
},
|
|
3744
|
+
rmdir: async (path) => { rmdirCalls += 1; await rm(path, { recursive: false }); },
|
|
3745
|
+
},
|
|
3746
|
+
}, async () => {
|
|
3747
|
+
const result = await establishLaunchSessionBinding(cwd, 'sess-owner-unlink');
|
|
3748
|
+
assert.equal(result.kind, 'committed-release-failed');
|
|
3749
|
+
if (result.kind === 'committed-release-failed') {
|
|
3750
|
+
assert.equal(result.secondaryFailures[0]?.phase, 'remove-release-owner');
|
|
3751
|
+
assert.equal(result.lockDisposition, 'released-with-residue');
|
|
3752
|
+
}
|
|
3753
|
+
});
|
|
3754
|
+
assert.equal(rmdirCalls, 0);
|
|
3755
|
+
assert.equal(existsSync(`${context.lockPath}.release-${TEST_TOKEN}`), true);
|
|
1337
3756
|
}
|
|
1338
3757
|
finally {
|
|
1339
3758
|
await rm(cwd, { recursive: true, force: true });
|