oh-my-codex 0.21.0 → 0.21.2
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.toml +1 -1
- package/README.md +4 -2
- package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.d.ts +2 -0
- package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.d.ts.map +1 -0
- package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.js +592 -0
- package/dist/cli/__tests__/detached-launch-diagnostics-3578.test.js.map +1 -0
- package/dist/cli/__tests__/detached-leader-teardown.test.js +270 -33
- package/dist/cli/__tests__/detached-leader-teardown.test.js.map +1 -1
- package/dist/cli/__tests__/doctor-warning-copy.test.js +133 -5
- package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +543 -2
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/issue-windows-cancel-identity.test.d.ts +2 -0
- package/dist/cli/__tests__/issue-windows-cancel-identity.test.d.ts.map +1 -0
- package/dist/cli/__tests__/issue-windows-cancel-identity.test.js +17 -0
- package/dist/cli/__tests__/issue-windows-cancel-identity.test.js.map +1 -0
- package/dist/cli/__tests__/launch-fallback.test.js +15 -3
- package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
- package/dist/cli/__tests__/package-bin-contract.test.js +20 -0
- package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
- package/dist/cli/__tests__/plugin-cache-stale-lock.test.d.ts +2 -0
- package/dist/cli/__tests__/plugin-cache-stale-lock.test.d.ts.map +1 -0
- package/dist/cli/__tests__/plugin-cache-stale-lock.test.js +76 -0
- package/dist/cli/__tests__/plugin-cache-stale-lock.test.js.map +1 -0
- package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.d.ts +2 -0
- package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.d.ts.map +1 -0
- package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.js +1662 -0
- package/dist/cli/__tests__/plugin-cache-symlink-provenance-3552.test.js.map +1 -0
- package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.d.ts +2 -0
- package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.d.ts.map +1 -0
- package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.js +522 -0
- package/dist/cli/__tests__/plugin-launcher-provenance-3558.test.js.map +1 -0
- package/dist/cli/__tests__/ralplan-advisory.test.d.ts +2 -0
- package/dist/cli/__tests__/ralplan-advisory.test.d.ts.map +1 -0
- package/dist/cli/__tests__/ralplan-advisory.test.js +301 -0
- package/dist/cli/__tests__/ralplan-advisory.test.js.map +1 -0
- package/dist/cli/__tests__/ralplan.test.js +39 -0
- package/dist/cli/__tests__/ralplan.test.js.map +1 -1
- package/dist/cli/__tests__/resume.test.js +81 -6
- package/dist/cli/__tests__/resume.test.js.map +1 -1
- package/dist/cli/__tests__/session-scoped-runtime.test.js +423 -4
- package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
- package/dist/cli/__tests__/setup-install-mode.test.js +60 -3
- package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
- package/dist/cli/__tests__/setup-refresh.test.js +9 -0
- package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
- package/dist/cli/__tests__/sparkshell-cli.test.js +4 -0
- package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
- package/dist/cli/__tests__/ultragoal.test.js +71 -0
- package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js +30 -4
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +57 -8
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +113 -7
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +1850 -185
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/native-assets.d.ts +2 -1
- package/dist/cli/native-assets.d.ts.map +1 -1
- package/dist/cli/native-assets.js +7 -7
- package/dist/cli/native-assets.js.map +1 -1
- package/dist/cli/plugin-marketplace.d.ts +89 -4
- package/dist/cli/plugin-marketplace.d.ts.map +1 -1
- package/dist/cli/plugin-marketplace.js +2135 -118
- package/dist/cli/plugin-marketplace.js.map +1 -1
- package/dist/cli/ralplan.d.ts +2 -1
- package/dist/cli/ralplan.d.ts.map +1 -1
- package/dist/cli/ralplan.js +149 -9
- package/dist/cli/ralplan.js.map +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +7 -1
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/sparkshell.d.ts.map +1 -1
- package/dist/cli/sparkshell.js +9 -10
- package/dist/cli/sparkshell.js.map +1 -1
- package/dist/cli/update-worker.js +9 -0
- package/dist/cli/update-worker.js.map +1 -1
- package/dist/cli/update.d.ts +1 -0
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +9 -0
- package/dist/cli/update.js.map +1 -1
- package/dist/config/__tests__/generator-idempotent.test.js +118 -0
- package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
- package/dist/config/generator.d.ts.map +1 -1
- package/dist/config/generator.js +74 -4
- package/dist/config/generator.js.map +1 -1
- package/dist/hooks/__tests__/keyword-detector.test.js +74 -0
- package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
- package/dist/hooks/__tests__/ralplan-advisory-keyword.test.d.ts +2 -0
- package/dist/hooks/__tests__/ralplan-advisory-keyword.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/ralplan-advisory-keyword.test.js +250 -0
- package/dist/hooks/__tests__/ralplan-advisory-keyword.test.js.map +1 -0
- package/dist/hooks/__tests__/session.test.js +10 -1
- package/dist/hooks/__tests__/session.test.js.map +1 -1
- package/dist/hooks/keyword-detector.d.ts +8 -1
- package/dist/hooks/keyword-detector.d.ts.map +1 -1
- package/dist/hooks/keyword-detector.js +130 -9
- package/dist/hooks/keyword-detector.js.map +1 -1
- package/dist/hooks/session.d.ts +5 -0
- package/dist/hooks/session.d.ts.map +1 -1
- package/dist/hooks/session.js +17 -3
- package/dist/hooks/session.js.map +1 -1
- package/dist/hud/__tests__/index.test.js +16 -1
- package/dist/hud/__tests__/index.test.js.map +1 -1
- package/dist/hud/__tests__/reconcile.test.js +134 -7
- package/dist/hud/__tests__/reconcile.test.js.map +1 -1
- package/dist/hud/__tests__/render.test.js +57 -0
- package/dist/hud/__tests__/render.test.js.map +1 -1
- package/dist/hud/__tests__/session-attached.test.d.ts +2 -0
- package/dist/hud/__tests__/session-attached.test.d.ts.map +1 -0
- package/dist/hud/__tests__/session-attached.test.js +82 -0
- package/dist/hud/__tests__/session-attached.test.js.map +1 -0
- package/dist/hud/__tests__/state.test.js +94 -2
- package/dist/hud/__tests__/state.test.js.map +1 -1
- package/dist/hud/__tests__/tmux-split-realtmux.test.js +169 -2
- package/dist/hud/__tests__/tmux-split-realtmux.test.js.map +1 -1
- package/dist/hud/__tests__/tmux.test.js +104 -22
- package/dist/hud/__tests__/tmux.test.js.map +1 -1
- package/dist/hud/__tests__/types.test.js +9 -0
- package/dist/hud/__tests__/types.test.js.map +1 -1
- package/dist/hud/__tests__/watch-detached.test.d.ts +2 -0
- package/dist/hud/__tests__/watch-detached.test.d.ts.map +1 -0
- package/dist/hud/__tests__/watch-detached.test.js +383 -0
- package/dist/hud/__tests__/watch-detached.test.js.map +1 -0
- package/dist/hud/__tests__/watch-realtmux.test.d.ts +2 -0
- package/dist/hud/__tests__/watch-realtmux.test.d.ts.map +1 -0
- package/dist/hud/__tests__/watch-realtmux.test.js +322 -0
- package/dist/hud/__tests__/watch-realtmux.test.js.map +1 -0
- package/dist/hud/index.d.ts +3 -0
- package/dist/hud/index.d.ts.map +1 -1
- package/dist/hud/index.js +98 -20
- package/dist/hud/index.js.map +1 -1
- package/dist/hud/reconcile.d.ts.map +1 -1
- package/dist/hud/reconcile.js +68 -24
- package/dist/hud/reconcile.js.map +1 -1
- package/dist/hud/render.d.ts.map +1 -1
- package/dist/hud/render.js +20 -0
- package/dist/hud/render.js.map +1 -1
- package/dist/hud/session-attached.d.ts +15 -0
- package/dist/hud/session-attached.d.ts.map +1 -0
- package/dist/hud/session-attached.js +47 -0
- package/dist/hud/session-attached.js.map +1 -0
- package/dist/hud/state.d.ts +9 -1
- package/dist/hud/state.d.ts.map +1 -1
- package/dist/hud/state.js +140 -3
- package/dist/hud/state.js.map +1 -1
- package/dist/hud/tmux.d.ts +4 -0
- package/dist/hud/tmux.d.ts.map +1 -1
- package/dist/hud/tmux.js +86 -23
- package/dist/hud/tmux.js.map +1 -1
- package/dist/hud/types.d.ts +20 -0
- package/dist/hud/types.d.ts.map +1 -1
- package/dist/hud/types.js +3 -0
- package/dist/hud/types.js.map +1 -1
- package/dist/modes/base.d.ts +15 -2
- package/dist/modes/base.d.ts.map +1 -1
- package/dist/modes/base.js +105 -16
- package/dist/modes/base.js.map +1 -1
- package/dist/question/autopilot-wait.js +2 -2
- package/dist/question/autopilot-wait.js.map +1 -1
- package/dist/question/deep-interview.js +2 -2
- package/dist/question/deep-interview.js.map +1 -1
- package/dist/ralplan/__tests__/advisory-activation.test.d.ts +2 -0
- package/dist/ralplan/__tests__/advisory-activation.test.d.ts.map +1 -0
- package/dist/ralplan/__tests__/advisory-activation.test.js +700 -0
- package/dist/ralplan/__tests__/advisory-activation.test.js.map +1 -0
- package/dist/ralplan/__tests__/advisory-detached-transition.test.d.ts +2 -0
- package/dist/ralplan/__tests__/advisory-detached-transition.test.d.ts.map +1 -0
- package/dist/ralplan/__tests__/advisory-detached-transition.test.js +88 -0
- package/dist/ralplan/__tests__/advisory-detached-transition.test.js.map +1 -0
- package/dist/ralplan/__tests__/advisory.test.d.ts +2 -0
- package/dist/ralplan/__tests__/advisory.test.d.ts.map +1 -0
- package/dist/ralplan/__tests__/advisory.test.js +1087 -0
- package/dist/ralplan/__tests__/advisory.test.js.map +1 -0
- package/dist/ralplan/__tests__/runtime.test.js +416 -7
- package/dist/ralplan/__tests__/runtime.test.js.map +1 -1
- package/dist/ralplan/advisory-activation-verifier.d.ts +19 -0
- package/dist/ralplan/advisory-activation-verifier.d.ts.map +1 -0
- package/dist/ralplan/advisory-activation-verifier.js +77 -0
- package/dist/ralplan/advisory-activation-verifier.js.map +1 -0
- package/dist/ralplan/advisory-activation.d.ts +34 -0
- package/dist/ralplan/advisory-activation.d.ts.map +1 -0
- package/dist/ralplan/advisory-activation.js +205 -0
- package/dist/ralplan/advisory-activation.js.map +1 -0
- package/dist/ralplan/advisory-contract.d.ts +103 -0
- package/dist/ralplan/advisory-contract.d.ts.map +1 -0
- package/dist/ralplan/advisory-contract.js +83 -0
- package/dist/ralplan/advisory-contract.js.map +1 -0
- package/dist/ralplan/advisory-detached-transition.d.ts +11 -0
- package/dist/ralplan/advisory-detached-transition.d.ts.map +1 -0
- package/dist/ralplan/advisory-detached-transition.js +21 -0
- package/dist/ralplan/advisory-detached-transition.js.map +1 -0
- package/dist/ralplan/advisory-evidence.d.ts +91 -0
- package/dist/ralplan/advisory-evidence.d.ts.map +1 -0
- package/dist/ralplan/advisory-evidence.js +393 -0
- package/dist/ralplan/advisory-evidence.js.map +1 -0
- package/dist/ralplan/advisory-lifecycle-validation.d.ts +11 -0
- package/dist/ralplan/advisory-lifecycle-validation.d.ts.map +1 -0
- package/dist/ralplan/advisory-lifecycle-validation.js +72 -0
- package/dist/ralplan/advisory-lifecycle-validation.js.map +1 -0
- package/dist/ralplan/advisory-postconditions.d.ts +3 -0
- package/dist/ralplan/advisory-postconditions.d.ts.map +1 -0
- package/dist/ralplan/advisory-postconditions.js +33 -0
- package/dist/ralplan/advisory-postconditions.js.map +1 -0
- package/dist/ralplan/advisory-recovery-journal.d.ts +56 -0
- package/dist/ralplan/advisory-recovery-journal.d.ts.map +1 -0
- package/dist/ralplan/advisory-recovery-journal.js +136 -0
- package/dist/ralplan/advisory-recovery-journal.js.map +1 -0
- package/dist/ralplan/advisory-storage.d.ts +24 -0
- package/dist/ralplan/advisory-storage.d.ts.map +1 -0
- package/dist/ralplan/advisory-storage.js +106 -0
- package/dist/ralplan/advisory-storage.js.map +1 -0
- package/dist/ralplan/advisory.d.ts +99 -0
- package/dist/ralplan/advisory.d.ts.map +1 -0
- package/dist/ralplan/advisory.js +914 -0
- package/dist/ralplan/advisory.js.map +1 -0
- package/dist/ralplan/documented-leader-preflight.d.ts +8 -0
- package/dist/ralplan/documented-leader-preflight.d.ts.map +1 -1
- package/dist/ralplan/documented-leader-preflight.js +60 -2
- package/dist/ralplan/documented-leader-preflight.js.map +1 -1
- package/dist/ralplan/runtime-advisory-lifecycle.d.ts +74 -0
- package/dist/ralplan/runtime-advisory-lifecycle.d.ts.map +1 -0
- package/dist/ralplan/runtime-advisory-lifecycle.js +352 -0
- package/dist/ralplan/runtime-advisory-lifecycle.js.map +1 -0
- package/dist/ralplan/runtime-contract.d.ts +126 -0
- package/dist/ralplan/runtime-contract.d.ts.map +1 -0
- package/dist/ralplan/runtime-contract.js +2 -0
- package/dist/ralplan/runtime-contract.js.map +1 -0
- package/dist/ralplan/runtime.d.ts +6 -109
- package/dist/ralplan/runtime.d.ts.map +1 -1
- package/dist/ralplan/runtime.js +174 -56
- package/dist/ralplan/runtime.js.map +1 -1
- package/dist/scripts/__tests__/postinstall.test.js +26 -0
- package/dist/scripts/__tests__/postinstall.test.js.map +1 -1
- package/dist/scripts/__tests__/ralplan-advisory-hook.test.d.ts +2 -0
- package/dist/scripts/__tests__/ralplan-advisory-hook.test.d.ts.map +1 -0
- package/dist/scripts/__tests__/ralplan-advisory-hook.test.js +299 -0
- package/dist/scripts/__tests__/ralplan-advisory-hook.test.js.map +1 -0
- package/dist/scripts/__tests__/smoke-packed-install.test.js +102 -8
- package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
- package/dist/scripts/codex-native-hook.d.ts +20 -0
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +226 -15
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/postinstall.d.ts +7 -0
- package/dist/scripts/postinstall.d.ts.map +1 -1
- package/dist/scripts/postinstall.js +40 -0
- package/dist/scripts/postinstall.js.map +1 -1
- package/dist/scripts/smoke-packed-install.d.ts +21 -5
- package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
- package/dist/scripts/smoke-packed-install.js +157 -57
- package/dist/scripts/smoke-packed-install.js.map +1 -1
- package/dist/state/__tests__/mode-binding-lease.test.d.ts +2 -0
- package/dist/state/__tests__/mode-binding-lease.test.d.ts.map +1 -0
- package/dist/state/__tests__/mode-binding-lease.test.js +498 -0
- package/dist/state/__tests__/mode-binding-lease.test.js.map +1 -0
- package/dist/state/__tests__/operations.test.js +1 -0
- package/dist/state/__tests__/operations.test.js.map +1 -1
- package/dist/state/__tests__/pinned-atomic-file.test.d.ts +2 -0
- package/dist/state/__tests__/pinned-atomic-file.test.d.ts.map +1 -0
- package/dist/state/__tests__/pinned-atomic-file.test.js +438 -0
- package/dist/state/__tests__/pinned-atomic-file.test.js.map +1 -0
- package/dist/state/__tests__/skill-active-exact-session-transaction.test.d.ts +2 -0
- package/dist/state/__tests__/skill-active-exact-session-transaction.test.d.ts.map +1 -0
- package/dist/state/__tests__/skill-active-exact-session-transaction.test.js +179 -0
- package/dist/state/__tests__/skill-active-exact-session-transaction.test.js.map +1 -0
- package/dist/state/__tests__/skill-active.test.js +29 -1
- package/dist/state/__tests__/skill-active.test.js.map +1 -1
- package/dist/state/mode-binding-lease-helper.d.ts +2 -0
- package/dist/state/mode-binding-lease-helper.d.ts.map +1 -0
- package/dist/state/mode-binding-lease-helper.js +623 -0
- package/dist/state/mode-binding-lease-helper.js.map +1 -0
- package/dist/state/mode-binding-lease.d.ts +25 -0
- package/dist/state/mode-binding-lease.d.ts.map +1 -0
- package/dist/state/mode-binding-lease.js +380 -0
- package/dist/state/mode-binding-lease.js.map +1 -0
- package/dist/state/operations.d.ts +29 -1
- package/dist/state/operations.d.ts.map +1 -1
- package/dist/state/operations.js +203 -122
- package/dist/state/operations.js.map +1 -1
- package/dist/state/pinned-atomic-file-darwin-client.d.ts +34 -0
- package/dist/state/pinned-atomic-file-darwin-client.d.ts.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-client.js +174 -0
- package/dist/state/pinned-atomic-file-darwin-client.js.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-helper.d.ts +2 -0
- package/dist/state/pinned-atomic-file-darwin-helper.d.ts.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-helper.js +275 -0
- package/dist/state/pinned-atomic-file-darwin-helper.js.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-janitor.d.ts +2 -0
- package/dist/state/pinned-atomic-file-darwin-janitor.d.ts.map +1 -0
- package/dist/state/pinned-atomic-file-darwin-janitor.js +272 -0
- package/dist/state/pinned-atomic-file-darwin-janitor.js.map +1 -0
- package/dist/state/pinned-atomic-file.d.ts +29 -0
- package/dist/state/pinned-atomic-file.d.ts.map +1 -0
- package/dist/state/pinned-atomic-file.js +536 -0
- package/dist/state/pinned-atomic-file.js.map +1 -0
- package/dist/state/process-identity.d.ts +30 -0
- package/dist/state/process-identity.d.ts.map +1 -0
- package/dist/state/process-identity.js +99 -0
- package/dist/state/process-identity.js.map +1 -0
- 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 +82 -5
- package/dist/state/skill-active.js.map +1 -1
- package/dist/state/workflow-transition-reconcile.js +1 -1
- package/dist/state/workflow-transition-reconcile.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +109 -0
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +10 -1
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/utils/__tests__/file-durability.test.js +20 -0
- package/dist/utils/__tests__/file-durability.test.js.map +1 -1
- package/dist/utils/file-durability.d.ts +1 -1
- package/dist/utils/file-durability.d.ts.map +1 -1
- package/dist/utils/file-durability.js.map +1 -1
- package/dist/verification/__tests__/ci-rust-gates.test.js +12 -4
- package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
- package/dist/verification/__tests__/ci-trusted-publish.test.d.ts +2 -0
- package/dist/verification/__tests__/ci-trusted-publish.test.d.ts.map +1 -0
- package/dist/verification/__tests__/ci-trusted-publish.test.js +205 -0
- package/dist/verification/__tests__/ci-trusted-publish.test.js.map +1 -0
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js +10 -5
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
- package/dist/verification/__tests__/publish-npm-manual-retired.test.d.ts +2 -0
- package/dist/verification/__tests__/publish-npm-manual-retired.test.d.ts.map +1 -0
- package/dist/verification/__tests__/publish-npm-manual-retired.test.js +22 -0
- package/dist/verification/__tests__/publish-npm-manual-retired.test.js.map +1 -0
- package/package.json +1 -1
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/oh-my-codex/skills/doctor/SKILL.md +1 -1
- package/plugins/oh-my-codex/skills/omx-setup/SKILL.md +1 -1
- package/plugins/oh-my-codex/skills/ralplan/SKILL.md +10 -1
- package/plugins/oh-my-codex/skills/team/SKILL.md +2 -0
- package/skills/doctor/SKILL.md +1 -1
- package/skills/omx-setup/SKILL.md +1 -1
- package/skills/ralplan/SKILL.md +10 -1
- package/skills/team/SKILL.md +2 -0
- package/src/scripts/__tests__/postinstall.test.ts +28 -0
- package/src/scripts/__tests__/ralplan-advisory-hook.test.ts +320 -0
- package/src/scripts/__tests__/smoke-packed-install.test.ts +120 -7
- package/src/scripts/codex-native-hook.ts +220 -10
- package/src/scripts/postinstall.ts +46 -0
- package/src/scripts/smoke-packed-install.ts +175 -70
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { existsSync } from "fs";
|
|
2
|
-
import {
|
|
1
|
+
import { constants as fsConstants, existsSync } from "fs";
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
|
+
import { link, lstat, mkdir, open, readdir, readFile, realpath, rename, rm } from "fs/promises";
|
|
3
4
|
import { isAbsolute, join, relative, resolve, sep } from "path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
5
|
import { OMX_FIRST_PARTY_MCP_SERVER_NAMES } from "../config/omx-first-party-mcp.js";
|
|
6
6
|
import { teamModeEnabled } from "../config/team-mode.js";
|
|
7
|
+
import { emitDegradedDurabilityWarning, recordDirectorySyncOutcome, recordRegularFileSyncOutcome, syncDirectory, syncRegularFile, } from "../utils/file-durability.js";
|
|
7
8
|
export const OMX_LOCAL_MARKETPLACE_NAME = "oh-my-codex-local";
|
|
8
9
|
export const OMX_PLUGIN_NAME = "oh-my-codex";
|
|
9
10
|
export const OMX_LOCAL_PLUGIN_CONFIG_KEY = `${OMX_PLUGIN_NAME}@${OMX_LOCAL_MARKETPLACE_NAME}`;
|
|
10
11
|
const OMX_PLUGIN_HOOK_LAUNCHER_FILE = "omx-command.json";
|
|
12
|
+
const OMX_PLUGIN_MANAGED_MARKER = ".omx-managed";
|
|
13
|
+
const OMX_PLUGIN_CACHE_STAGING_PREFIX = ".omx-plugin-";
|
|
11
14
|
const TEAM_MODE_PLUGIN_SKILL_NAMES = new Set(["team", "worker"]);
|
|
12
15
|
export async function resolvePackagedOmxMarketplace(packageRoot) {
|
|
13
16
|
const marketplacePath = join(packageRoot, ".agents", "plugins", "marketplace.json");
|
|
@@ -51,6 +54,652 @@ async function readPluginManifest(manifestPath) {
|
|
|
51
54
|
return null;
|
|
52
55
|
}
|
|
53
56
|
}
|
|
57
|
+
function isSafeCacheVersion(version) {
|
|
58
|
+
return version.length > 0
|
|
59
|
+
&& version !== "."
|
|
60
|
+
&& version !== ".."
|
|
61
|
+
&& !version.includes("/")
|
|
62
|
+
&& !version.includes("\\")
|
|
63
|
+
&& !version.includes("\0");
|
|
64
|
+
}
|
|
65
|
+
function isPluginCacheStagingEntryName(name) {
|
|
66
|
+
return name === "snapshot" || name.startsWith(OMX_PLUGIN_CACHE_STAGING_PREFIX) || name.includes(".reclaim-");
|
|
67
|
+
}
|
|
68
|
+
function directoryFdPath(fd) {
|
|
69
|
+
if (process.platform === "linux")
|
|
70
|
+
return `/proc/self/fd/${fd}`;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function isDirectoryDescriptorPath(path) {
|
|
74
|
+
return /^\/proc\/self\/fd\/\d+$/.test(path);
|
|
75
|
+
}
|
|
76
|
+
function directoryOpenFlags(path, directoryFlags, noFollowFlags) {
|
|
77
|
+
return fsConstants.O_RDONLY | directoryFlags | (isDirectoryDescriptorPath(path) ? 0 : noFollowFlags);
|
|
78
|
+
}
|
|
79
|
+
let pluginCacheMutationTestHooks;
|
|
80
|
+
/** @internal Test seam for deterministic cache mutation interposition coverage. */
|
|
81
|
+
export function setPluginCacheMutationHooksForTest(hooks) {
|
|
82
|
+
const previous = pluginCacheMutationTestHooks;
|
|
83
|
+
pluginCacheMutationTestHooks = hooks;
|
|
84
|
+
return () => {
|
|
85
|
+
pluginCacheMutationTestHooks = previous;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function directoryScanOperationPath(handle, path) {
|
|
89
|
+
return directoryFdPath(handle.fd) ?? resolve(path);
|
|
90
|
+
}
|
|
91
|
+
function directoryOperationPath(handle, path) {
|
|
92
|
+
// #3552 blockers 1+2: Linux mutates through a descriptor-relative
|
|
93
|
+
// /proc/self/fd path. Node exposes no *at() primitives on other
|
|
94
|
+
// platforms, so every mutation elsewhere goes through the visible path
|
|
95
|
+
// that this handle's open() already validated without following
|
|
96
|
+
// symlinks/reparse points, restricted to non-destructive shapes:
|
|
97
|
+
// exclusive mkdir / O_CREAT|O_EXCL|O_NOFOLLOW creates (never O_TRUNC on
|
|
98
|
+
// a foreign inode), fd-bound writes, quarantine+inode-gated rm, and
|
|
99
|
+
// rename only into this process's own verified claim, with
|
|
100
|
+
// assertDirectoryRef identity barriers immediately before and after
|
|
101
|
+
// every mutation so a replaced parent is detected before any result is
|
|
102
|
+
// trusted.
|
|
103
|
+
if (process.platform === "linux")
|
|
104
|
+
return `/proc/self/fd/${handle.fd}`;
|
|
105
|
+
return resolve(path);
|
|
106
|
+
}
|
|
107
|
+
function unsupportedDirectoryOperationError() {
|
|
108
|
+
return Object.assign(new Error("platform cannot provide descriptor-relative directory operations"), { code: "ENOTSUP" });
|
|
109
|
+
}
|
|
110
|
+
function childMutationPath(parent, name) {
|
|
111
|
+
// name is one or more "/"-joined simple components; callers validate the
|
|
112
|
+
// shape before reaching here, and every component must be a plain name.
|
|
113
|
+
const components = name.split("/");
|
|
114
|
+
if (components.some((component) => !component || component === "." || component === ".." || component.includes("\\"))) {
|
|
115
|
+
throw new Error(`invalid descriptor-relative child name: ${name}`);
|
|
116
|
+
}
|
|
117
|
+
if (!parent.mutationPath)
|
|
118
|
+
throw unsupportedDirectoryOperationError();
|
|
119
|
+
return join(parent.mutationPath, ...components);
|
|
120
|
+
}
|
|
121
|
+
function childOperationPath(parent, name) {
|
|
122
|
+
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
123
|
+
throw new Error(`invalid descriptor-relative child name: ${name}`);
|
|
124
|
+
}
|
|
125
|
+
return join(parent.operationPath, name);
|
|
126
|
+
}
|
|
127
|
+
async function assertDirectoryRef(parent, operation) {
|
|
128
|
+
const descriptorStats = await parent.handle.stat();
|
|
129
|
+
const visibleStats = await lstat(parent.path);
|
|
130
|
+
if (!descriptorStats.isDirectory() ||
|
|
131
|
+
!visibleStats.isDirectory() ||
|
|
132
|
+
visibleStats.isSymbolicLink() ||
|
|
133
|
+
descriptorStats.dev !== visibleStats.dev ||
|
|
134
|
+
descriptorStats.ino !== visibleStats.ino) {
|
|
135
|
+
throw new Error(`descriptor-bound ${operation} parent was replaced: ${parent.path}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function sameFileIdentity(left, right) {
|
|
139
|
+
return typeof left.dev === "number" && typeof left.ino === "number"
|
|
140
|
+
&& typeof right.dev === "number" && typeof right.ino === "number"
|
|
141
|
+
&& left.dev === right.dev && left.ino === right.ino;
|
|
142
|
+
}
|
|
143
|
+
async function openDirectoryRef(path) {
|
|
144
|
+
const noFollowFlags = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
145
|
+
const directoryFlags = typeof fsConstants.O_DIRECTORY === "number" ? fsConstants.O_DIRECTORY : 0;
|
|
146
|
+
if (process.platform !== "win32" && (noFollowFlags === 0 || directoryFlags === 0)) {
|
|
147
|
+
throw new Error("directory anchoring flags are unavailable");
|
|
148
|
+
}
|
|
149
|
+
const visiblePath = resolve(path);
|
|
150
|
+
const windowsRealpathBefore = process.platform === "win32" ? await realpath(visiblePath) : null;
|
|
151
|
+
const visibleBefore = await lstat(visiblePath);
|
|
152
|
+
if (visibleBefore.isSymbolicLink() || !visibleBefore.isDirectory())
|
|
153
|
+
throw new Error(`reparse-safe directory anchor rejected symbolic link or non-directory namespace component: ${visiblePath}`);
|
|
154
|
+
const handle = await open(visiblePath, directoryOpenFlags(visiblePath, directoryFlags, noFollowFlags));
|
|
155
|
+
const scanOperationPath = directoryScanOperationPath(handle, visiblePath);
|
|
156
|
+
const mutationPath = directoryOperationPath(handle, visiblePath);
|
|
157
|
+
const ref = { handle, path: visiblePath, operationPath: scanOperationPath, scanOperationPath, mutationPath };
|
|
158
|
+
try {
|
|
159
|
+
await assertDirectoryRef(ref, "open");
|
|
160
|
+
if (windowsRealpathBefore && (await realpath(visiblePath)).toLowerCase() !== windowsRealpathBefore.toLowerCase()) {
|
|
161
|
+
throw new Error(`reparse-safe directory anchor changed during open: ${visiblePath}`);
|
|
162
|
+
}
|
|
163
|
+
return ref;
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
await handle.close();
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function openDirectoryChild(parent, name) {
|
|
171
|
+
const noFollowFlags = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
172
|
+
const directoryFlags = typeof fsConstants.O_DIRECTORY === "number" ? fsConstants.O_DIRECTORY : 0;
|
|
173
|
+
if (process.platform !== "win32" && (noFollowFlags === 0 || directoryFlags === 0)) {
|
|
174
|
+
throw new Error("directory anchoring flags are unavailable");
|
|
175
|
+
}
|
|
176
|
+
const operationPath = childOperationPath(parent, name);
|
|
177
|
+
const visiblePath = join(parent.path, name);
|
|
178
|
+
await assertDirectoryRef(parent, "open");
|
|
179
|
+
const windowsRealpathBefore = process.platform === "win32" ? await realpath(visiblePath) : null;
|
|
180
|
+
const visibleBefore = await lstat(visiblePath);
|
|
181
|
+
if (visibleBefore.isSymbolicLink() || !visibleBefore.isDirectory())
|
|
182
|
+
throw new Error(`reparse-safe directory anchor rejected symbolic link or non-directory namespace component: ${visiblePath}`);
|
|
183
|
+
const handle = await open(operationPath, directoryOpenFlags(operationPath, directoryFlags, noFollowFlags));
|
|
184
|
+
const childScan = directoryScanOperationPath(handle, visiblePath);
|
|
185
|
+
const childMutation = directoryOperationPath(handle, visiblePath);
|
|
186
|
+
const child = { handle, path: visiblePath, operationPath: childScan, scanOperationPath: childScan, mutationPath: childMutation };
|
|
187
|
+
try {
|
|
188
|
+
await assertDirectoryRef(parent, "open");
|
|
189
|
+
await assertDirectoryRef(child, "open");
|
|
190
|
+
if (windowsRealpathBefore && (await realpath(visiblePath)).toLowerCase() !== windowsRealpathBefore.toLowerCase()) {
|
|
191
|
+
throw new Error(`reparse-safe directory anchor changed during open: ${visiblePath}`);
|
|
192
|
+
}
|
|
193
|
+
return child;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
await handle.close();
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function mkdirDirectoryChild(parent, name) {
|
|
201
|
+
const mutationPath = childMutationPath(parent, name);
|
|
202
|
+
await assertDirectoryRef(parent, "mkdir");
|
|
203
|
+
try {
|
|
204
|
+
await mkdir(mutationPath);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error.code !== "EEXIST")
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
await assertDirectoryRef(parent, "mkdir");
|
|
211
|
+
}
|
|
212
|
+
async function mkdirDirectoryChildExclusive(parent, name) {
|
|
213
|
+
const mutationPath = childMutationPath(parent, name);
|
|
214
|
+
await assertDirectoryRef(parent, "exclusive mkdir");
|
|
215
|
+
await pluginCacheMutationTestHooks?.beforeMkdirExclusive?.(parent.path, name);
|
|
216
|
+
await mkdir(mutationPath);
|
|
217
|
+
await assertDirectoryRef(parent, "exclusive mkdir");
|
|
218
|
+
}
|
|
219
|
+
async function copyPackagedTree(source, destination, tracker) {
|
|
220
|
+
await assertDirectoryRef(source, "packaged source copy");
|
|
221
|
+
const entries = await readdir(source.operationPath, { withFileTypes: true });
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (entry.isSymbolicLink()) {
|
|
224
|
+
throw new Error(`refusing to copy a symlinked packaged plugin entry: ${join(source.path, entry.name)}`);
|
|
225
|
+
}
|
|
226
|
+
if (entry.isDirectory()) {
|
|
227
|
+
await mkdirDirectoryChildExclusive(destination, entry.name);
|
|
228
|
+
const sourceChild = await openDirectoryChild(source, entry.name);
|
|
229
|
+
const child = await openDirectoryChild(destination, entry.name);
|
|
230
|
+
try {
|
|
231
|
+
await copyPackagedTree(sourceChild, child, tracker);
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
await sourceChild.handle.close();
|
|
235
|
+
await child.handle.close();
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!entry.isFile()) {
|
|
240
|
+
throw new Error(`refusing to copy a non-regular packaged plugin entry: ${join(source.path, entry.name)}`);
|
|
241
|
+
}
|
|
242
|
+
const sourceHandle = await openRegularFileChild(source, entry.name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
243
|
+
try {
|
|
244
|
+
const before = await sourceHandle.stat();
|
|
245
|
+
if (!before.isFile() || before.isSymbolicLink()) {
|
|
246
|
+
throw new Error(`packaged plugin entry changed while copying: ${join(source.path, entry.name)}`);
|
|
247
|
+
}
|
|
248
|
+
const content = await sourceHandle.readFile();
|
|
249
|
+
const verifyHandle = await openRegularFileChild(source, entry.name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
250
|
+
try {
|
|
251
|
+
const verifyStats = await verifyHandle.stat();
|
|
252
|
+
const verifyContent = await verifyHandle.readFile();
|
|
253
|
+
if (verifyStats.dev !== before.dev ||
|
|
254
|
+
verifyStats.ino !== before.ino ||
|
|
255
|
+
verifyStats.size !== before.size ||
|
|
256
|
+
!content.equals(verifyContent)) {
|
|
257
|
+
throw new Error(`packaged plugin entry changed while copying: ${join(source.path, entry.name)}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
await verifyHandle.close();
|
|
262
|
+
}
|
|
263
|
+
const destinationHandle = await openRegularFileChild(destination, entry.name, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, before.mode & 0o7777);
|
|
264
|
+
try {
|
|
265
|
+
await destinationHandle.writeFile(content);
|
|
266
|
+
await destinationHandle.chmod(before.mode & 0o7777);
|
|
267
|
+
const outcome = await syncRegularFile(destinationHandle);
|
|
268
|
+
if (tracker)
|
|
269
|
+
recordRegularFileSyncOutcome(tracker, outcome);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await destinationHandle.close();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
await sourceHandle.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
await assertDirectoryRef(source, "packaged source copy");
|
|
280
|
+
}
|
|
281
|
+
async function copyStagedTreeNoReplace(source, destination, tracker) {
|
|
282
|
+
await assertDirectoryRef(source, "staged source copy");
|
|
283
|
+
await assertDirectoryRef(destination, "staged destination copy");
|
|
284
|
+
const entries = await readdir(source.operationPath, { withFileTypes: true });
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
if (entry.name === ".omx-incomplete")
|
|
287
|
+
continue;
|
|
288
|
+
if (entry.isSymbolicLink()) {
|
|
289
|
+
throw new Error(`refusing to copy a symlinked staged cache entry: ${join(source.path, entry.name)}`);
|
|
290
|
+
}
|
|
291
|
+
if (entry.isDirectory()) {
|
|
292
|
+
await mkdirDirectoryChildExclusive(destination, entry.name);
|
|
293
|
+
const sourceChild = await openDirectoryChild(source, entry.name);
|
|
294
|
+
const destinationChild = await openDirectoryChild(destination, entry.name);
|
|
295
|
+
try {
|
|
296
|
+
await copyStagedTreeNoReplace(sourceChild, destinationChild, tracker);
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
await sourceChild.handle.close();
|
|
300
|
+
await destinationChild.handle.close();
|
|
301
|
+
}
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (!entry.isFile()) {
|
|
305
|
+
throw new Error(`refusing to copy a non-regular staged cache entry: ${join(source.path, entry.name)}`);
|
|
306
|
+
}
|
|
307
|
+
const sourceHandle = await openRegularFileChild(source, entry.name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
308
|
+
try {
|
|
309
|
+
const before = await sourceHandle.stat();
|
|
310
|
+
if (!before.isFile() || before.isSymbolicLink()) {
|
|
311
|
+
throw new Error(`staged cache entry changed while copying: ${join(source.path, entry.name)}`);
|
|
312
|
+
}
|
|
313
|
+
const content = await sourceHandle.readFile();
|
|
314
|
+
await pluginCacheMutationTestHooks?.afterStagedFileRead?.(join(source.path, entry.name), before, content);
|
|
315
|
+
const verifyHandle = await openRegularFileChild(source, entry.name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
316
|
+
try {
|
|
317
|
+
const verifyStats = await verifyHandle.stat();
|
|
318
|
+
const verifyContent = await verifyHandle.readFile();
|
|
319
|
+
if (!sameFileIdentity(before, verifyStats)
|
|
320
|
+
|| verifyStats.size !== before.size
|
|
321
|
+
|| !content.equals(verifyContent)) {
|
|
322
|
+
throw new Error(`staged cache entry changed while copying: ${join(source.path, entry.name)}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
await verifyHandle.close();
|
|
327
|
+
}
|
|
328
|
+
const destinationHandle = await openRegularFileChild(destination, entry.name, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, before.mode & 0o7777);
|
|
329
|
+
try {
|
|
330
|
+
await destinationHandle.writeFile(content);
|
|
331
|
+
await destinationHandle.chmod(before.mode & 0o7777);
|
|
332
|
+
const outcome = await syncRegularFile(destinationHandle);
|
|
333
|
+
if (tracker)
|
|
334
|
+
recordRegularFileSyncOutcome(tracker, outcome);
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
await destinationHandle.close();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
await sourceHandle.close();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
await assertDirectoryRef(source, "staged source copy");
|
|
345
|
+
await assertDirectoryRef(destination, "staged destination copy");
|
|
346
|
+
}
|
|
347
|
+
async function openRegularFileChild(parent, name, flags, mode) {
|
|
348
|
+
const mutationPath = childMutationPath(parent, name);
|
|
349
|
+
await assertDirectoryRef(parent, "file open");
|
|
350
|
+
if ((flags & fsConstants.O_EXCL) !== 0) {
|
|
351
|
+
await pluginCacheMutationTestHooks?.beforeExclusiveCreate?.(parent.path, name);
|
|
352
|
+
}
|
|
353
|
+
const handle = await open(mutationPath, flags, mode);
|
|
354
|
+
try {
|
|
355
|
+
await assertDirectoryRef(parent, "file open");
|
|
356
|
+
return handle;
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
await handle.close();
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async function syncRegularFileChild(parent, name, tracker) {
|
|
364
|
+
const noFollowFlags = fsConstants.O_NOFOLLOW;
|
|
365
|
+
if (process.platform !== "win32" && typeof noFollowFlags !== "number")
|
|
366
|
+
throw new Error("O_NOFOLLOW is unavailable");
|
|
367
|
+
const handle = await openRegularFileChild(parent, name, fsConstants.O_RDONLY | (noFollowFlags ?? 0));
|
|
368
|
+
try {
|
|
369
|
+
const outcome = await syncRegularFile(handle);
|
|
370
|
+
if (tracker)
|
|
371
|
+
recordRegularFileSyncOutcome(tracker, outcome);
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
await handle.close();
|
|
375
|
+
}
|
|
376
|
+
await assertDirectoryRef(parent, "file sync");
|
|
377
|
+
}
|
|
378
|
+
async function createExclusiveFileChild(parent, name, content, tracker) {
|
|
379
|
+
const noFollowFlags = fsConstants.O_NOFOLLOW;
|
|
380
|
+
if (process.platform !== "win32" && typeof noFollowFlags !== "number")
|
|
381
|
+
throw new Error("O_NOFOLLOW is unavailable");
|
|
382
|
+
const handle = await openRegularFileChild(parent, name, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (noFollowFlags ?? 0), 0o600);
|
|
383
|
+
try {
|
|
384
|
+
await handle.writeFile(content);
|
|
385
|
+
const outcome = await syncRegularFile(handle);
|
|
386
|
+
if (tracker)
|
|
387
|
+
recordRegularFileSyncOutcome(tracker, outcome);
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
await handle.close();
|
|
391
|
+
}
|
|
392
|
+
await assertDirectoryRef(parent, "exclusive file create");
|
|
393
|
+
}
|
|
394
|
+
async function removeChild(parent, name, options = {}) {
|
|
395
|
+
const mutationPath = childMutationPath(parent, name);
|
|
396
|
+
await assertDirectoryRef(parent, "remove");
|
|
397
|
+
let before;
|
|
398
|
+
try {
|
|
399
|
+
before = await lstat(mutationPath);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
if (!options.force || error.code !== "ENOENT")
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
if (before) {
|
|
406
|
+
if (options.recursive && options.preserveRecursive && before.isDirectory()) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
// A final identity fence immediately before rm is the portable fallback
|
|
410
|
+
// for platforms without unlinkat(2). If an interposer changes the name
|
|
411
|
+
// after the quarantine lstat, preserve the replacement and report bounded
|
|
412
|
+
// cleanup instead of recursively deleting an unknown inode.
|
|
413
|
+
await pluginCacheMutationTestHooks?.beforeRemove?.(mutationPath, before);
|
|
414
|
+
const current = await lstat(mutationPath).catch((error) => {
|
|
415
|
+
if (error.code === "ENOENT")
|
|
416
|
+
return null;
|
|
417
|
+
throw error;
|
|
418
|
+
});
|
|
419
|
+
if (!current || !sameFileIdentity(before, current)) {
|
|
420
|
+
throw new Error(`identity-bound removal target changed before deletion: ${mutationPath}`);
|
|
421
|
+
}
|
|
422
|
+
// Keep an explicit barrier immediately before the destructive syscall. The
|
|
423
|
+
// production path has no portable unlinkat(2) primitive; the final re-lstat
|
|
424
|
+
// after this barrier is the fail-closed fallback that preserves a raced
|
|
425
|
+
// replacement instead of trusting a stale pathname check.
|
|
426
|
+
await pluginCacheMutationTestHooks?.beforeRemoveSyscall?.(mutationPath, current);
|
|
427
|
+
const final = await lstat(mutationPath).catch((error) => {
|
|
428
|
+
if (error.code === "ENOENT")
|
|
429
|
+
return null;
|
|
430
|
+
throw error;
|
|
431
|
+
});
|
|
432
|
+
if (!final || !sameFileIdentity(before, final)) {
|
|
433
|
+
throw new Error(`identity-bound removal target changed before removal syscall: ${mutationPath}`);
|
|
434
|
+
}
|
|
435
|
+
if (options.beforeDestructiveRemove && !await options.beforeDestructiveRemove())
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
await rm(mutationPath, options);
|
|
439
|
+
if (before) {
|
|
440
|
+
try {
|
|
441
|
+
await lstat(mutationPath);
|
|
442
|
+
throw new Error(`removed cache entry reappeared during removal: ${mutationPath}`);
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
if (error.code !== "ENOENT")
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
await assertDirectoryRef(parent, "remove");
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
async function renameChild(sourceParent, sourceName, destinationParent, destinationName) {
|
|
453
|
+
// sourceName may be a single component or a "/"-joined relative path of
|
|
454
|
+
// simple components (publication moves staged snapshot entries), so
|
|
455
|
+
// validate each component instead of rejecting separators outright.
|
|
456
|
+
const sourceComponents = sourceName.split("/");
|
|
457
|
+
if (sourceComponents.some((component) => !component || component === "." || component === ".." || component.includes("\\"))) {
|
|
458
|
+
throw new Error(`invalid descriptor-relative child name: ${sourceName}`);
|
|
459
|
+
}
|
|
460
|
+
if (destinationName.includes("/") || destinationName.includes("\\") || !destinationName || destinationName === "." || destinationName === "..") {
|
|
461
|
+
throw new Error(`invalid descriptor-relative child name: ${destinationName}`);
|
|
462
|
+
}
|
|
463
|
+
const sourcePath = childMutationPath(sourceParent, sourceComponents.join("/"));
|
|
464
|
+
const destinationPath = childMutationPath(destinationParent, destinationName);
|
|
465
|
+
await assertDirectoryRef(sourceParent, "rename");
|
|
466
|
+
await assertDirectoryRef(destinationParent, "rename");
|
|
467
|
+
const destinationBefore = await lstat(destinationPath).catch((error) => {
|
|
468
|
+
if (error.code === "ENOENT")
|
|
469
|
+
return null;
|
|
470
|
+
throw error;
|
|
471
|
+
});
|
|
472
|
+
if (destinationBefore) {
|
|
473
|
+
throw Object.assign(new Error(`rename destination already exists: ${destinationPath}`), { code: "EEXIST" });
|
|
474
|
+
}
|
|
475
|
+
await pluginCacheMutationTestHooks?.beforeRename?.(sourcePath, destinationPath);
|
|
476
|
+
const destinationAfter = await lstat(destinationPath).catch((error) => {
|
|
477
|
+
if (error.code === "ENOENT")
|
|
478
|
+
return null;
|
|
479
|
+
throw error;
|
|
480
|
+
});
|
|
481
|
+
if (destinationAfter) {
|
|
482
|
+
throw Object.assign(new Error(`rename destination appeared during no-replace check: ${destinationPath}`), { code: "EEXIST" });
|
|
483
|
+
}
|
|
484
|
+
await rename(sourcePath, destinationPath);
|
|
485
|
+
await assertDirectoryRef(sourceParent, "rename");
|
|
486
|
+
await assertDirectoryRef(destinationParent, "rename");
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* #3552 blocker 3 / review 3860267789: POSIX rename() silently replaces an
|
|
490
|
+
* empty same-version directory and Node exposes no renameat2(RENAME_NOREPLACE),
|
|
491
|
+
* so pre/post inode checks around a rename can never be atomic no-replace.
|
|
492
|
+
* Publication therefore never renames onto `<version>`: the destination is
|
|
493
|
+
* claimed with an atomic exclusive mkdir (the one portable no-replace
|
|
494
|
+
* primitive). EEXIST — whether the destination is empty, foreign, incomplete,
|
|
495
|
+
* or a completed snapshot — always fails closed without touching it. The
|
|
496
|
+
* claim is self-described with an O_EXCL `.omx-incomplete` marker, the staged
|
|
497
|
+
* snapshot entries are renamed into the empty claim (destination names cannot
|
|
498
|
+
* pre-exist), and `.omx-complete` is committed last. Every trust path requires
|
|
499
|
+
* `.omx-complete` and rejects `.omx-incomplete`, so no intermediate state is
|
|
500
|
+
* trusted, and no pre-existing directory is ever replaced or removed.
|
|
501
|
+
*
|
|
502
|
+
* A claim abandoned by a crashed publisher keeps `.omx-incomplete` and no
|
|
503
|
+
* `.omx-complete`; the next publication fails closed on it (stale-launcher
|
|
504
|
+
* with the removal recovery hint) rather than deleting foreign content.
|
|
505
|
+
*/
|
|
506
|
+
async function claimPublicationDestination(destinationParent, destinationName, tracker) {
|
|
507
|
+
await mkdirDirectoryChildExclusive(destinationParent, destinationName);
|
|
508
|
+
const claimRef = await openDirectoryChild(destinationParent, destinationName);
|
|
509
|
+
try {
|
|
510
|
+
await assertDirectoryRef(claimRef, "publication claim");
|
|
511
|
+
await createExclusiveFileChild(claimRef, ".omx-incomplete", `${process.pid}\n`, tracker);
|
|
512
|
+
return claimRef;
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
await claimRef.handle.close();
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function syncDirectoryTree(directory, tracker) {
|
|
520
|
+
await assertDirectoryRef(directory, "directory sync");
|
|
521
|
+
const entries = await readdir(directory.operationPath, { withFileTypes: true });
|
|
522
|
+
for (const entry of entries) {
|
|
523
|
+
if (entry.isSymbolicLink())
|
|
524
|
+
throw new Error(`cannot sync symlinked cache entry: ${join(directory.path, entry.name)}`);
|
|
525
|
+
if (entry.isDirectory()) {
|
|
526
|
+
const child = await openDirectoryChild(directory, entry.name);
|
|
527
|
+
try {
|
|
528
|
+
await syncDirectoryTree(child, tracker);
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
await child.handle.close();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
await syncRegularFileChild(directory, entry.name, tracker);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
await assertDirectoryRef(directory, "directory sync");
|
|
539
|
+
const outcome = await syncDirectory(directory.handle);
|
|
540
|
+
if (tracker)
|
|
541
|
+
recordDirectorySyncOutcome(tracker, outcome);
|
|
542
|
+
}
|
|
543
|
+
export async function readOmxPluginCacheFileNoFollow(path, options = {}) {
|
|
544
|
+
let handle;
|
|
545
|
+
let anchorHandle;
|
|
546
|
+
const intermediateDirectories = [];
|
|
547
|
+
try {
|
|
548
|
+
const requireSingleLink = options.requireSingleLink ?? true;
|
|
549
|
+
const noFollowFlags = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
550
|
+
if (process.platform !== "win32" && noFollowFlags === 0)
|
|
551
|
+
return null;
|
|
552
|
+
let readPath = path;
|
|
553
|
+
let anchorBefore = null;
|
|
554
|
+
if (options.anchorDir) {
|
|
555
|
+
const directoryFlags = typeof fsConstants.O_DIRECTORY === "number" ? fsConstants.O_DIRECTORY : 0;
|
|
556
|
+
if (process.platform !== "win32" && directoryFlags === 0)
|
|
557
|
+
return null;
|
|
558
|
+
anchorHandle = await open(options.anchorDir, directoryOpenFlags(options.anchorDir, directoryFlags, noFollowFlags));
|
|
559
|
+
anchorBefore = await anchorHandle.stat();
|
|
560
|
+
if (!anchorBefore.isDirectory())
|
|
561
|
+
return null;
|
|
562
|
+
if (typeof anchorBefore.dev !== "number" || typeof anchorBefore.ino !== "number")
|
|
563
|
+
return null;
|
|
564
|
+
const fdPath = process.platform === "linux"
|
|
565
|
+
? directoryFdPath(anchorHandle.fd)
|
|
566
|
+
: resolve(options.anchorDir);
|
|
567
|
+
if (!fdPath)
|
|
568
|
+
return null;
|
|
569
|
+
const relativePath = relative(resolve(options.anchorDir), resolve(path));
|
|
570
|
+
if (!relativePath || relativePath.startsWith(".."))
|
|
571
|
+
return null;
|
|
572
|
+
const components = relativePath.split(sep);
|
|
573
|
+
if (components.some((component) => !component || component === "." || component === ".."))
|
|
574
|
+
return null;
|
|
575
|
+
let parentPath = fdPath;
|
|
576
|
+
for (const component of components.slice(0, -1)) {
|
|
577
|
+
const childPath = join(parentPath, component);
|
|
578
|
+
const childHandle = await open(childPath, fsConstants.O_RDONLY | directoryFlags | noFollowFlags);
|
|
579
|
+
const childStats = await childHandle.stat();
|
|
580
|
+
if (!childStats.isDirectory() || childStats.isSymbolicLink()) {
|
|
581
|
+
await childHandle.close();
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
intermediateDirectories.push({ handle: childHandle, path: childPath, stats: childStats });
|
|
585
|
+
parentPath = process.platform === "linux" ? directoryFdPath(childHandle.fd) ?? "" : childPath;
|
|
586
|
+
if (!parentPath)
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
readPath = join(parentPath, components.at(-1));
|
|
590
|
+
}
|
|
591
|
+
const parentDescriptor = intermediateDirectories.at(-1)?.handle ?? anchorHandle;
|
|
592
|
+
const parentBefore = parentDescriptor ? await parentDescriptor.stat() : await lstat(resolve(readPath, ".."));
|
|
593
|
+
if (!parentBefore.isDirectory() || parentBefore.isSymbolicLink())
|
|
594
|
+
return null;
|
|
595
|
+
if (typeof parentBefore.dev !== "number" || typeof parentBefore.ino !== "number")
|
|
596
|
+
return null;
|
|
597
|
+
const before = await lstat(readPath);
|
|
598
|
+
if (!before.isFile() || before.isSymbolicLink())
|
|
599
|
+
return null;
|
|
600
|
+
if (requireSingleLink && before.nlink !== 1)
|
|
601
|
+
return null;
|
|
602
|
+
if (typeof before.dev !== "number" || typeof before.ino !== "number")
|
|
603
|
+
return null;
|
|
604
|
+
handle = await open(readPath, fsConstants.O_RDONLY | noFollowFlags);
|
|
605
|
+
const descriptorStats = await handle.stat();
|
|
606
|
+
if (!descriptorStats.isFile())
|
|
607
|
+
return null;
|
|
608
|
+
if (requireSingleLink && descriptorStats.nlink !== 1)
|
|
609
|
+
return null;
|
|
610
|
+
if (descriptorStats.dev !== before.dev || descriptorStats.ino !== before.ino)
|
|
611
|
+
return null;
|
|
612
|
+
const bytes = await handle.readFile();
|
|
613
|
+
const after = await lstat(readPath);
|
|
614
|
+
const parentAfter = parentDescriptor ? await parentDescriptor.stat() : await lstat(resolve(readPath, ".."));
|
|
615
|
+
if (!parentAfter)
|
|
616
|
+
return null;
|
|
617
|
+
if (!after.isFile() || after.isSymbolicLink())
|
|
618
|
+
return null;
|
|
619
|
+
if (requireSingleLink && after.nlink !== 1)
|
|
620
|
+
return null;
|
|
621
|
+
if (typeof after.dev !== "number" || typeof after.ino !== "number")
|
|
622
|
+
return null;
|
|
623
|
+
if (after.dev !== before.dev || after.ino !== before.ino)
|
|
624
|
+
return null;
|
|
625
|
+
if (!parentAfter.isDirectory() || parentAfter.isSymbolicLink())
|
|
626
|
+
return null;
|
|
627
|
+
if (typeof parentAfter.dev !== "number" || typeof parentAfter.ino !== "number" || parentAfter.dev !== parentBefore.dev || parentAfter.ino !== parentBefore.ino)
|
|
628
|
+
return null;
|
|
629
|
+
for (const directory of intermediateDirectories) {
|
|
630
|
+
const currentStats = await lstat(directory.path);
|
|
631
|
+
if (!currentStats.isDirectory() || currentStats.isSymbolicLink() || typeof currentStats.dev !== "number" || typeof currentStats.ino !== "number" || currentStats.dev !== directory.stats.dev || currentStats.ino !== directory.stats.ino)
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
if (options.anchorDir && anchorBefore) {
|
|
635
|
+
const anchorAfter = isDirectoryDescriptorPath(options.anchorDir)
|
|
636
|
+
? await anchorHandle.stat()
|
|
637
|
+
: await lstat(options.anchorDir);
|
|
638
|
+
if (!anchorAfter.isDirectory() || (!isDirectoryDescriptorPath(options.anchorDir) && anchorAfter.isSymbolicLink()) || typeof anchorAfter.dev !== "number" || typeof anchorAfter.ino !== "number" || anchorAfter.dev !== anchorBefore.dev || anchorAfter.ino !== anchorBefore.ino)
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
return bytes;
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
finally {
|
|
647
|
+
if (handle)
|
|
648
|
+
await handle.close();
|
|
649
|
+
for (const directory of intermediateDirectories.reverse())
|
|
650
|
+
await directory.handle.close();
|
|
651
|
+
if (anchorHandle)
|
|
652
|
+
await anchorHandle.close();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async function readRegularFileTextNoFollow(path, options = {}) {
|
|
656
|
+
const bytes = await readOmxPluginCacheFileNoFollow(path, options);
|
|
657
|
+
return bytes?.toString("utf-8") ?? null;
|
|
658
|
+
}
|
|
659
|
+
export async function computeOmxPluginCacheClaimDigest(root) {
|
|
660
|
+
const hash = createHash("sha256");
|
|
661
|
+
const visit = async (directory, isRoot) => {
|
|
662
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
663
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
664
|
+
for (const entry of entries) {
|
|
665
|
+
if (isRoot && (entry.name === ".omx-complete" || entry.name === ".omx-live-pin"))
|
|
666
|
+
continue;
|
|
667
|
+
const path = join(directory, entry.name);
|
|
668
|
+
const stats = await lstat(path);
|
|
669
|
+
if (stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile()) || (stats.isFile() && stats.nlink !== 1)) {
|
|
670
|
+
throw new Error(`claim entry is not a singly-linked regular file or directory: ${path}`);
|
|
671
|
+
}
|
|
672
|
+
hash.update(`${entry.name}\0${stats.isDirectory() ? "d" : "f"}\0${stats.dev}\0${stats.ino}\0${stats.size}\0`);
|
|
673
|
+
if (stats.isDirectory()) {
|
|
674
|
+
await visit(path, false);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
const bytes = await readOmxPluginCacheFileNoFollow(path, { anchorDir: directory });
|
|
678
|
+
if (bytes === null)
|
|
679
|
+
throw new Error(`claim entry cannot be read: ${path}`);
|
|
680
|
+
hash.update(bytes);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
await visit(root, true);
|
|
685
|
+
return hash.digest("hex");
|
|
686
|
+
}
|
|
687
|
+
async function hasRegularPublicationMarker(cacheDir, name) {
|
|
688
|
+
const bytes = await readOmxPluginCacheFileNoFollow(join(cacheDir, name), { anchorDir: cacheDir });
|
|
689
|
+
if (bytes === null)
|
|
690
|
+
return false;
|
|
691
|
+
if (name !== ".omx-complete")
|
|
692
|
+
return true;
|
|
693
|
+
try {
|
|
694
|
+
const record = JSON.parse(bytes.toString("utf-8"));
|
|
695
|
+
if (typeof record.claimDigest !== "string")
|
|
696
|
+
return false;
|
|
697
|
+
return record.claimDigest === await computeOmxPluginCacheClaimDigest(cacheDir);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
54
703
|
async function listChildDirectoryNames(dir) {
|
|
55
704
|
try {
|
|
56
705
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -63,11 +712,29 @@ async function listChildDirectoryNames(dir) {
|
|
|
63
712
|
return null;
|
|
64
713
|
}
|
|
65
714
|
}
|
|
715
|
+
async function readRegularOmxPluginCacheManifest(cacheDir, anchorDir = cacheDir) {
|
|
716
|
+
const manifestDir = join(cacheDir, ".codex-plugin");
|
|
717
|
+
const manifestPath = join(manifestDir, "plugin.json");
|
|
718
|
+
try {
|
|
719
|
+
const manifestDirStats = await lstat(manifestDir);
|
|
720
|
+
if (!manifestDirStats.isDirectory() || manifestDirStats.isSymbolicLink())
|
|
721
|
+
return null;
|
|
722
|
+
const manifestStats = await lstat(manifestPath);
|
|
723
|
+
if (!manifestStats.isFile() || manifestStats.isSymbolicLink())
|
|
724
|
+
return null;
|
|
725
|
+
const bytes = await readOmxPluginCacheFileNoFollow(manifestPath, { anchorDir });
|
|
726
|
+
return bytes ? JSON.parse(bytes.toString("utf-8")) : null;
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
66
732
|
export async function packagedOmxPluginVersion(packagedMarketplace) {
|
|
67
733
|
const manifest = await readPluginManifest(packagedMarketplace.pluginManifestPath);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
734
|
+
if (typeof manifest?.version !== "string")
|
|
735
|
+
return null;
|
|
736
|
+
const version = manifest.version.trim();
|
|
737
|
+
return isSafeCacheVersion(version) ? version : null;
|
|
71
738
|
}
|
|
72
739
|
export async function expectedPackagedOmxSkillNames(packagedMarketplace, options = {}) {
|
|
73
740
|
const skillNames = await listChildDirectoryNames(join(packagedMarketplace.pluginRoot, "skills"));
|
|
@@ -81,45 +748,608 @@ export function omxPluginCacheBase(codexHomeDir) {
|
|
|
81
748
|
function isMissingPathError(error) {
|
|
82
749
|
return error.code === "ENOENT";
|
|
83
750
|
}
|
|
751
|
+
function isProcessAlive(pid) {
|
|
752
|
+
try {
|
|
753
|
+
process.kill(pid, 0);
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
return error.code !== "ESRCH";
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const PUBLICATION_LOCK_LEASE_MS = 120_000;
|
|
761
|
+
const PUBLICATION_LOCK_MAX_FUTURE_SKEW_MS = 300_000;
|
|
762
|
+
const PUBLICATION_LOCK_PROCESS_TOKEN = randomUUID();
|
|
763
|
+
const PUBLICATION_LOCK_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
764
|
+
function buildPublicationLockRecord(heartbeatAt = Date.now()) {
|
|
765
|
+
return `${JSON.stringify({
|
|
766
|
+
pid: process.pid,
|
|
767
|
+
createdAt: heartbeatAt,
|
|
768
|
+
heartbeatAt,
|
|
769
|
+
processToken: PUBLICATION_LOCK_PROCESS_TOKEN,
|
|
770
|
+
})}\n`;
|
|
771
|
+
}
|
|
772
|
+
function parsePublicationLockRecord(bytes) {
|
|
773
|
+
try {
|
|
774
|
+
const lines = bytes.toString("utf-8").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
775
|
+
const parsed = JSON.parse(lines.at(-1) ?? "");
|
|
776
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
777
|
+
return null;
|
|
778
|
+
return parsed;
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function publicationLockLeaseExpired(record, now = Date.now()) {
|
|
785
|
+
const heartbeat = typeof record.heartbeatAt === "number" && Number.isFinite(record.heartbeatAt)
|
|
786
|
+
? record.heartbeatAt
|
|
787
|
+
: typeof record.createdAt === "number" && Number.isFinite(record.createdAt)
|
|
788
|
+
? record.createdAt
|
|
789
|
+
: null;
|
|
790
|
+
if (heartbeat === null)
|
|
791
|
+
return true;
|
|
792
|
+
return now - heartbeat > PUBLICATION_LOCK_LEASE_MS;
|
|
793
|
+
}
|
|
794
|
+
function publicationLockFileAgeExpired(stats, now = Date.now()) {
|
|
795
|
+
return typeof stats.mtimeMs === "number" && Number.isFinite(stats.mtimeMs)
|
|
796
|
+
&& now - stats.mtimeMs > PUBLICATION_LOCK_LEASE_MS;
|
|
797
|
+
}
|
|
798
|
+
function publicationLockRecordStructurallyValid(record, now = Date.now()) {
|
|
799
|
+
const hasPid = typeof record.pid === "number" && Number.isInteger(record.pid) && record.pid > 0;
|
|
800
|
+
const hasHeartbeat = typeof record.heartbeatAt === "number" && Number.isFinite(record.heartbeatAt);
|
|
801
|
+
const hasCreatedAt = typeof record.createdAt === "number" && Number.isFinite(record.createdAt);
|
|
802
|
+
const heartbeatPlausible = !hasHeartbeat || record.heartbeatAt <= now + PUBLICATION_LOCK_MAX_FUTURE_SKEW_MS;
|
|
803
|
+
const createdPlausible = !hasCreatedAt || record.createdAt <= now + PUBLICATION_LOCK_MAX_FUTURE_SKEW_MS;
|
|
804
|
+
return hasPid && (hasHeartbeat || hasCreatedAt) && heartbeatPlausible && createdPlausible;
|
|
805
|
+
}
|
|
806
|
+
function isPublicationLockStale(record, now = Date.now()) {
|
|
807
|
+
if (typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0)
|
|
808
|
+
return false;
|
|
809
|
+
if (publicationLockLeaseExpired(record, now))
|
|
810
|
+
return true;
|
|
811
|
+
return !isProcessAlive(record.pid);
|
|
812
|
+
}
|
|
813
|
+
async function refreshPublicationLockRecord(lockPath, lockHandle, lockIdentity, tracker) {
|
|
814
|
+
let before;
|
|
815
|
+
try {
|
|
816
|
+
before = await lstat(lockPath);
|
|
817
|
+
const descriptorStats = await lockHandle.stat();
|
|
818
|
+
if (!sameFileIdentity(before, lockIdentity) || !sameFileIdentity(descriptorStats, lockIdentity))
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
const record = buildPublicationLockRecord();
|
|
826
|
+
const buf = Buffer.from(record, "utf-8");
|
|
827
|
+
// Heartbeats are append-only complete records. Readers consume the last
|
|
828
|
+
// complete line, so an in-progress write can never turn a previous valid
|
|
829
|
+
// owner record into a partially published lock state.
|
|
830
|
+
await lockHandle.write(buf);
|
|
831
|
+
const outcome = await syncRegularFile(lockHandle);
|
|
832
|
+
if (tracker)
|
|
833
|
+
recordRegularFileSyncOutcome(tracker, outcome);
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
// Heartbeat is best-effort; a slow filesystem or closed handle must not fail the
|
|
837
|
+
// already-claimed publication. The next interval will retry if the handle is still open.
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
const after = await lstat(lockPath);
|
|
841
|
+
const descriptorStats = await lockHandle.stat();
|
|
842
|
+
return sameFileIdentity(after, lockIdentity) && sameFileIdentity(descriptorStats, lockIdentity);
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function startPublicationLockHeartbeat(lockPath, lockHandle, lockIdentity, tracker) {
|
|
849
|
+
let lost = false;
|
|
850
|
+
const assertOwned = async () => {
|
|
851
|
+
if (lost)
|
|
852
|
+
throw new Error(`publication lock ownership lost at ${lockPath}`);
|
|
853
|
+
try {
|
|
854
|
+
const pathStats = await lstat(lockPath);
|
|
855
|
+
const descriptorStats = await lockHandle.stat();
|
|
856
|
+
if (!sameFileIdentity(pathStats, lockIdentity) || !sameFileIdentity(descriptorStats, lockIdentity)) {
|
|
857
|
+
lost = true;
|
|
858
|
+
throw new Error(`publication lock ownership lost at ${lockPath}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
lost = true;
|
|
863
|
+
throw error;
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
const timer = setInterval(() => {
|
|
867
|
+
void refreshPublicationLockRecord(lockPath, lockHandle, lockIdentity, tracker).then((owned) => {
|
|
868
|
+
if (!owned)
|
|
869
|
+
lost = true;
|
|
870
|
+
});
|
|
871
|
+
}, PUBLICATION_LOCK_HEARTBEAT_INTERVAL_MS);
|
|
872
|
+
return { timer, assertOwned };
|
|
873
|
+
}
|
|
874
|
+
export async function removeChildIfIdentity(cacheBaseRef, childName, childStats, options) {
|
|
875
|
+
const quarantineName = `.${childName}.reclaim-${process.pid}-${randomUUID()}`;
|
|
876
|
+
await renameChild(cacheBaseRef, childName, cacheBaseRef, quarantineName);
|
|
877
|
+
const quarantinePath = childOperationPath(cacheBaseRef, quarantineName);
|
|
878
|
+
let quarantineStats;
|
|
879
|
+
try {
|
|
880
|
+
quarantineStats = await lstat(quarantinePath);
|
|
881
|
+
}
|
|
882
|
+
catch (error) {
|
|
883
|
+
throw new Error(`identity-bound removal target disappeared during reclamation: ${error.message}`);
|
|
884
|
+
}
|
|
885
|
+
if (quarantineStats.dev !== childStats.dev || quarantineStats.ino !== childStats.ino) {
|
|
886
|
+
// Quarantined the wrong inode (replacement after validation). There is no
|
|
887
|
+
// portable atomic no-replace directory restore: rename(quarantine, name)
|
|
888
|
+
// can overwrite a successor inserted after any lstat(name). Preserve the
|
|
889
|
+
// quarantined inode instead of risking overwrite or deletion. Regular
|
|
890
|
+
// files can be linked back with no-clobber semantics, but the quarantine
|
|
891
|
+
// link is intentionally retained so cleanup never follows a raced path.
|
|
892
|
+
try {
|
|
893
|
+
if (!quarantineStats.isDirectory()) {
|
|
894
|
+
await link(quarantinePath, childOperationPath(cacheBaseRef, childName));
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
catch {
|
|
898
|
+
// Preserve replacement without overwriting a successor that appeared
|
|
899
|
+
// between the quarantine and the restore attempt; quarantine remains.
|
|
900
|
+
}
|
|
901
|
+
throw new Error(`identity-bound removal target changed during reclamation; preserved quarantine ${quarantinePath}`);
|
|
902
|
+
}
|
|
903
|
+
if (options.expectedManagedVersion) {
|
|
904
|
+
const verifyQuarantineOwnership = async () => {
|
|
905
|
+
const manifest = await readRegularOmxPluginCacheManifest(quarantinePath, quarantinePath);
|
|
906
|
+
const owned = manifest?.name === OMX_PLUGIN_NAME
|
|
907
|
+
&& manifest.version === options.expectedManagedVersion
|
|
908
|
+
&& await hasRegularPublicationMarker(quarantinePath, ".omx-complete")
|
|
909
|
+
&& !(await hasRegularPublicationMarker(quarantinePath, ".omx-incomplete"))
|
|
910
|
+
&& await hasRegularPublicationMarker(quarantinePath, OMX_PLUGIN_MANAGED_MARKER);
|
|
911
|
+
const livePin = await readOmxPluginCacheFileNoFollow(join(quarantinePath, ".omx-live-pin"), { anchorDir: quarantinePath }) !== null;
|
|
912
|
+
if (!owned || livePin) {
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
return true;
|
|
916
|
+
};
|
|
917
|
+
await pluginCacheMutationTestHooks?.beforeRemove?.(quarantinePath, quarantineStats);
|
|
918
|
+
if (!await verifyQuarantineOwnership()) {
|
|
919
|
+
options.preservedPaths?.push(join(cacheBaseRef.path, quarantineName));
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
return removeChild(cacheBaseRef, quarantineName, {
|
|
923
|
+
...options,
|
|
924
|
+
preserveRecursive: false,
|
|
925
|
+
beforeDestructiveRemove: async () => {
|
|
926
|
+
const owned = await verifyQuarantineOwnership();
|
|
927
|
+
if (!owned)
|
|
928
|
+
options.preservedPaths?.push(join(cacheBaseRef.path, quarantineName));
|
|
929
|
+
return owned;
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
return removeChild(cacheBaseRef, quarantineName, {
|
|
934
|
+
...options,
|
|
935
|
+
preserveRecursive: options.preserveRecursive ?? options.recursive === true,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
async function reclaimStalePublicationLock(cacheBaseRef, lockName, lockStats, checkLateHeartbeat = true) {
|
|
939
|
+
const quarantineName = `.${lockName}.reclaim-${process.pid}-${randomUUID()}`;
|
|
940
|
+
await renameChild(cacheBaseRef, lockName, cacheBaseRef, quarantineName);
|
|
941
|
+
const quarantinePath = childOperationPath(cacheBaseRef, quarantineName);
|
|
942
|
+
let quarantineStats;
|
|
943
|
+
try {
|
|
944
|
+
quarantineStats = await lstat(quarantinePath);
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
throw new Error(`stale publication lock disappeared during reclamation: ${error.message}`);
|
|
948
|
+
}
|
|
949
|
+
if (quarantineStats.dev !== lockStats.dev || quarantineStats.ino !== lockStats.ino) {
|
|
950
|
+
try {
|
|
951
|
+
await link(quarantinePath, childOperationPath(cacheBaseRef, lockName));
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
// Preserve a replacement lock and its contents without overwriting it.
|
|
955
|
+
}
|
|
956
|
+
throw new Error(`publication lock changed during stale-lock reclamation; preserved quarantine ${quarantinePath}`);
|
|
957
|
+
}
|
|
958
|
+
if (checkLateHeartbeat) {
|
|
959
|
+
await pluginCacheMutationTestHooks?.beforeRemoveSyscall?.(quarantinePath, quarantineStats);
|
|
960
|
+
const quarantineBytes = await readFile(quarantinePath).catch(() => null);
|
|
961
|
+
const quarantineRecord = quarantineBytes === null ? null : parsePublicationLockRecord(quarantineBytes);
|
|
962
|
+
const quarantineStillStale = quarantineRecord !== null && publicationLockRecordStructurallyValid(quarantineRecord)
|
|
963
|
+
? isPublicationLockStale(quarantineRecord)
|
|
964
|
+
: publicationLockFileAgeExpired(quarantineStats);
|
|
965
|
+
if (!quarantineStillStale) {
|
|
966
|
+
try {
|
|
967
|
+
await link(quarantinePath, childOperationPath(cacheBaseRef, lockName));
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
// Preserve a successor lock or a late-heartbeat owner without clobbering.
|
|
971
|
+
}
|
|
972
|
+
throw new Error(`publication lock heartbeat changed during stale-lock reclamation; preserved quarantine ${quarantinePath}`);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
await removeChild(cacheBaseRef, quarantineName, { force: true });
|
|
976
|
+
}
|
|
977
|
+
async function claimPublicationLock(cacheBaseRef, cacheBase, noFollowFlags, tracker) {
|
|
978
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
979
|
+
try {
|
|
980
|
+
const lockHandle = await openRegularFileChild(cacheBaseRef, ".omx-publish.lock", fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollowFlags, 0o600);
|
|
981
|
+
try {
|
|
982
|
+
await lockHandle.writeFile(buildPublicationLockRecord());
|
|
983
|
+
const outcome = await syncRegularFile(lockHandle);
|
|
984
|
+
if (tracker)
|
|
985
|
+
recordRegularFileSyncOutcome(tracker, outcome);
|
|
986
|
+
}
|
|
987
|
+
catch (error) {
|
|
988
|
+
let lockIdentity;
|
|
989
|
+
try {
|
|
990
|
+
lockIdentity = await lockHandle.stat();
|
|
991
|
+
}
|
|
992
|
+
catch { /* preserve the primary failure */ }
|
|
993
|
+
try {
|
|
994
|
+
await lockHandle.close();
|
|
995
|
+
}
|
|
996
|
+
catch { /* preserve the primary failure */ }
|
|
997
|
+
if (lockIdentity) {
|
|
998
|
+
try {
|
|
999
|
+
await reclaimStalePublicationLock(cacheBaseRef, ".omx-publish.lock", lockIdentity, false);
|
|
1000
|
+
}
|
|
1001
|
+
catch { /* preserve the primary failure */ }
|
|
1002
|
+
}
|
|
1003
|
+
throw error;
|
|
1004
|
+
}
|
|
1005
|
+
return lockHandle;
|
|
1006
|
+
}
|
|
1007
|
+
catch (error) {
|
|
1008
|
+
if (error.code !== "EEXIST" || attempt > 0)
|
|
1009
|
+
throw error;
|
|
1010
|
+
const lockPath = join(cacheBaseRef.path, ".omx-publish.lock");
|
|
1011
|
+
let lockBefore;
|
|
1012
|
+
try {
|
|
1013
|
+
lockBefore = await lstat(lockPath);
|
|
1014
|
+
}
|
|
1015
|
+
catch {
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (!lockBefore.isFile() || lockBefore.isSymbolicLink() || typeof lockBefore.dev !== "number" || typeof lockBefore.ino !== "number") {
|
|
1019
|
+
throw new Error(`another OMX plugin cache publication is active at ${cacheBase}; refusing concurrent publication`);
|
|
1020
|
+
}
|
|
1021
|
+
const lockBytes = await readOmxPluginCacheFileNoFollow(lockPath, { anchorDir: cacheBaseRef.path });
|
|
1022
|
+
let lockAfter;
|
|
1023
|
+
try {
|
|
1024
|
+
lockAfter = await lstat(lockPath);
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
if (lockAfter.dev !== lockBefore.dev || lockAfter.ino !== lockBefore.ino)
|
|
1030
|
+
continue;
|
|
1031
|
+
let stale = false;
|
|
1032
|
+
if (lockBytes !== null) {
|
|
1033
|
+
const record = parsePublicationLockRecord(lockBytes);
|
|
1034
|
+
stale = record !== null && publicationLockRecordStructurallyValid(record)
|
|
1035
|
+
? isPublicationLockStale(record)
|
|
1036
|
+
: publicationLockFileAgeExpired(lockBefore);
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
stale = publicationLockFileAgeExpired(lockBefore);
|
|
1040
|
+
}
|
|
1041
|
+
if (!stale) {
|
|
1042
|
+
throw new Error(`another OMX plugin cache publication is active at ${cacheBase}; refusing concurrent publication`);
|
|
1043
|
+
}
|
|
1044
|
+
const reclaimBefore = await lstat(lockPath);
|
|
1045
|
+
if (reclaimBefore.dev !== lockBefore.dev || reclaimBefore.ino !== lockBefore.ino)
|
|
1046
|
+
continue;
|
|
1047
|
+
await pluginCacheMutationTestHooks?.beforeStaleLockReclaim?.(lockPath, reclaimBefore);
|
|
1048
|
+
try {
|
|
1049
|
+
await reclaimStalePublicationLock(cacheBaseRef, ".omx-publish.lock", reclaimBefore);
|
|
1050
|
+
}
|
|
1051
|
+
catch (reclaimError) {
|
|
1052
|
+
if (reclaimError.code === "ENOENT")
|
|
1053
|
+
continue;
|
|
1054
|
+
if (reclaimError.message.startsWith("publication lock changed during stale-lock reclamation")
|
|
1055
|
+
|| reclaimError.message.startsWith("publication lock heartbeat changed during stale-lock reclamation"))
|
|
1056
|
+
continue;
|
|
1057
|
+
throw reclaimError;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
throw new Error(`cannot recover stale OMX plugin cache publication lock at ${cacheBase}`);
|
|
1062
|
+
}
|
|
84
1063
|
/**
|
|
85
|
-
*
|
|
86
|
-
* `
|
|
87
|
-
*
|
|
88
|
-
*
|
|
1064
|
+
* #3552: executed assets Codex loads from the plugin cache must be regular files inside a
|
|
1065
|
+
* regular `hooks/` directory inside a regular snapshot root. `readFile`/content equality
|
|
1066
|
+
* follow symlinks to their external targets before any provenance check, so a symlinked
|
|
1067
|
+
* `<version>` cache root or a symlinked executed asset (hooks/hooks.json,
|
|
1068
|
+
* hooks/codex-native-hook.mjs, hooks/omx-command.json) with byte-identical external
|
|
1069
|
+
* content could satisfy the unchanged fast paths while the executed bytes stayed
|
|
1070
|
+
* attacker-writable outside the managed namespace. Returns a human-readable reason when
|
|
1071
|
+
* provenance is broken (missing/symlinked/non-regular root, hooks dir, or asset), or
|
|
1072
|
+
* null when the snapshot's provenance is intact. Callers treat non-null as fail-closed.
|
|
89
1073
|
*/
|
|
90
|
-
async function
|
|
1074
|
+
export async function omxPluginCacheExecutedAssetProvenanceReason(cacheDir) {
|
|
1075
|
+
if ((await readOmxPluginCacheFileNoFollow(join(cacheDir, ".omx-complete"), { anchorDir: cacheDir })) === null) {
|
|
1076
|
+
return `cache snapshot publication marker is missing at ${cacheDir}`;
|
|
1077
|
+
}
|
|
1078
|
+
if ((await readOmxPluginCacheFileNoFollow(join(cacheDir, ".omx-incomplete"), { anchorDir: cacheDir })) !== null) {
|
|
1079
|
+
return `cache snapshot publication is incomplete at ${cacheDir}`;
|
|
1080
|
+
}
|
|
1081
|
+
const expectations = [
|
|
1082
|
+
{ path: cacheDir, kind: "directory", label: "cache root" },
|
|
1083
|
+
{ path: join(cacheDir, "hooks"), kind: "directory", label: "hooks directory" },
|
|
1084
|
+
{ path: join(cacheDir, "hooks", "hooks.json"), kind: "file", label: "executed cache asset" },
|
|
1085
|
+
{ path: join(cacheDir, "hooks", "codex-native-hook.mjs"), kind: "file", label: "executed cache asset" },
|
|
1086
|
+
{ path: join(cacheDir, "hooks", OMX_PLUGIN_HOOK_LAUNCHER_FILE), kind: "file", label: "executed cache asset" },
|
|
1087
|
+
];
|
|
1088
|
+
for (const { path, kind, label } of expectations) {
|
|
1089
|
+
let stats;
|
|
1090
|
+
try {
|
|
1091
|
+
stats = await lstat(path);
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
return `${label} is missing at ${path}`;
|
|
1095
|
+
}
|
|
1096
|
+
const shapeOk = kind === "directory"
|
|
1097
|
+
? stats.isDirectory() && !stats.isSymbolicLink()
|
|
1098
|
+
: stats.isFile() && !stats.isSymbolicLink() && stats.nlink === 1;
|
|
1099
|
+
if (!shapeOk) {
|
|
1100
|
+
return `${label} at ${path} is a symlink or not a ${kind === "directory" ? "directory" : "regular file"}`;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
async function omxPluginCacheManifestProvenanceReason(cacheDir, expectedVersion) {
|
|
1106
|
+
const manifestDir = join(cacheDir, ".codex-plugin");
|
|
1107
|
+
const manifestPath = join(manifestDir, "plugin.json");
|
|
1108
|
+
let manifestDirStats;
|
|
1109
|
+
try {
|
|
1110
|
+
manifestDirStats = await lstat(manifestDir);
|
|
1111
|
+
}
|
|
1112
|
+
catch {
|
|
1113
|
+
return `plugin manifest directory is missing at ${manifestDir}`;
|
|
1114
|
+
}
|
|
1115
|
+
if (!manifestDirStats.isDirectory() || manifestDirStats.isSymbolicLink()) {
|
|
1116
|
+
return `plugin manifest directory at ${manifestDir} is a symlink or not a directory`;
|
|
1117
|
+
}
|
|
1118
|
+
let manifestStats;
|
|
1119
|
+
try {
|
|
1120
|
+
manifestStats = await lstat(manifestPath);
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
return `plugin manifest is missing at ${manifestPath}`;
|
|
1124
|
+
}
|
|
1125
|
+
if (!manifestStats.isFile() || manifestStats.isSymbolicLink() || manifestStats.nlink !== 1) {
|
|
1126
|
+
return `plugin manifest at ${manifestPath} is a symlink or not a regular file`;
|
|
1127
|
+
}
|
|
1128
|
+
const manifestBytes = await readOmxPluginCacheFileNoFollow(manifestPath, { anchorDir: cacheDir });
|
|
1129
|
+
let manifest = null;
|
|
1130
|
+
try {
|
|
1131
|
+
manifest = manifestBytes ? JSON.parse(manifestBytes.toString("utf-8")) : null;
|
|
1132
|
+
}
|
|
1133
|
+
catch {
|
|
1134
|
+
manifest = null;
|
|
1135
|
+
}
|
|
1136
|
+
if (!manifest)
|
|
1137
|
+
return `plugin manifest at ${manifestPath} is unreadable or invalid JSON`;
|
|
1138
|
+
if (manifest.name !== OMX_PLUGIN_NAME) {
|
|
1139
|
+
return `plugin manifest name is not ${OMX_PLUGIN_NAME} at ${manifestPath}`;
|
|
1140
|
+
}
|
|
1141
|
+
if (typeof manifest.version !== "string" || (expectedVersion !== undefined && manifest.version !== expectedVersion)) {
|
|
1142
|
+
return `plugin manifest version is not ${expectedVersion ?? "a valid string"} at ${manifestPath}`;
|
|
1143
|
+
}
|
|
1144
|
+
if (manifest.skills !== "./skills/") {
|
|
1145
|
+
return `plugin manifest skills pointer is not ./skills/ at ${manifestPath}`;
|
|
1146
|
+
}
|
|
1147
|
+
if (manifest.hooks !== "./hooks/hooks.json") {
|
|
1148
|
+
return `plugin manifest hooks pointer is not ./hooks/hooks.json at ${manifestPath}`;
|
|
1149
|
+
}
|
|
1150
|
+
if (manifest.mcpServers !== "./.mcp.json") {
|
|
1151
|
+
return `plugin manifest mcpServers pointer is not ./.mcp.json at ${manifestPath}`;
|
|
1152
|
+
}
|
|
1153
|
+
if (manifest.apps !== "./.app.json") {
|
|
1154
|
+
return `plugin manifest apps pointer is not ./.app.json at ${manifestPath}`;
|
|
1155
|
+
}
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
async function omxPluginCacheSkillsProvenanceReason(cacheDir, packagedMarketplace, expectedSkillNames) {
|
|
1159
|
+
const skillsDir = join(cacheDir, "skills");
|
|
1160
|
+
let skillsStats;
|
|
1161
|
+
try {
|
|
1162
|
+
skillsStats = await lstat(skillsDir);
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
return `skills directory is missing at ${skillsDir}`;
|
|
1166
|
+
}
|
|
1167
|
+
if (!skillsStats.isDirectory() || skillsStats.isSymbolicLink()) {
|
|
1168
|
+
return `skills directory at ${skillsDir} is a symlink or not a directory`;
|
|
1169
|
+
}
|
|
1170
|
+
let skillEntries;
|
|
1171
|
+
try {
|
|
1172
|
+
skillEntries = await readdir(skillsDir, { withFileTypes: true });
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
return `skills directory at ${skillsDir} is unreadable`;
|
|
1176
|
+
}
|
|
1177
|
+
const actualSkillNames = skillEntries.map((entry) => entry.name).sort();
|
|
1178
|
+
if (skillEntries.some((entry) => entry.isSymbolicLink() || !entry.isDirectory())) {
|
|
1179
|
+
return `skills directory at ${skillsDir} contains a symlink or non-directory entry`;
|
|
1180
|
+
}
|
|
1181
|
+
if (JSON.stringify(actualSkillNames) !== JSON.stringify([...expectedSkillNames].sort())) {
|
|
1182
|
+
return `skills directory contents differ at ${skillsDir}`;
|
|
1183
|
+
}
|
|
1184
|
+
for (const skillName of expectedSkillNames) {
|
|
1185
|
+
const skillDir = join(skillsDir, skillName);
|
|
1186
|
+
let skillDirStats;
|
|
1187
|
+
try {
|
|
1188
|
+
skillDirStats = await lstat(skillDir);
|
|
1189
|
+
}
|
|
1190
|
+
catch {
|
|
1191
|
+
return `expected skill directory is missing at ${skillDir}`;
|
|
1192
|
+
}
|
|
1193
|
+
if (!skillDirStats.isDirectory() || skillDirStats.isSymbolicLink()) {
|
|
1194
|
+
return `expected skill directory at ${skillDir} is a symlink or not a directory`;
|
|
1195
|
+
}
|
|
1196
|
+
const cachedSkill = join(skillDir, "SKILL.md");
|
|
1197
|
+
const packagedSkill = join(packagedMarketplace.pluginRoot, "skills", skillName, "SKILL.md");
|
|
1198
|
+
let cachedSkillStats;
|
|
1199
|
+
try {
|
|
1200
|
+
cachedSkillStats = await lstat(cachedSkill);
|
|
1201
|
+
}
|
|
1202
|
+
catch {
|
|
1203
|
+
return `expected skill file is missing at ${cachedSkill}`;
|
|
1204
|
+
}
|
|
1205
|
+
if (!cachedSkillStats.isFile() || cachedSkillStats.isSymbolicLink() || cachedSkillStats.nlink !== 1) {
|
|
1206
|
+
return `expected skill file at ${cachedSkill} is a symlink or not a regular file`;
|
|
1207
|
+
}
|
|
1208
|
+
if (!(await fileContentsEqual(cachedSkill, packagedSkill, cacheDir))) {
|
|
1209
|
+
return `expected skill file content differs at ${cachedSkill}`;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return null;
|
|
1213
|
+
}
|
|
1214
|
+
async function omxPluginCacheCompanionMetadataProvenanceReason(cacheDir, packagedMarketplace) {
|
|
1215
|
+
for (const [name, relativePath] of [["mcpServers", ".mcp.json"], ["apps", ".app.json"]]) {
|
|
1216
|
+
const cachedPath = join(cacheDir, relativePath);
|
|
1217
|
+
let stats;
|
|
1218
|
+
try {
|
|
1219
|
+
stats = await lstat(cachedPath);
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
return `plugin manifest ${name} companion file is missing at ${cachedPath}`;
|
|
1223
|
+
}
|
|
1224
|
+
if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) {
|
|
1225
|
+
return `plugin manifest ${name} companion file at ${cachedPath} is a symlink or not a regular file`;
|
|
1226
|
+
}
|
|
1227
|
+
if (!(await fileContentsEqual(cachedPath, join(packagedMarketplace.pluginRoot, relativePath), cacheDir))) {
|
|
1228
|
+
return `plugin manifest ${name} companion file content differs at ${cachedPath}`;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return null;
|
|
1232
|
+
}
|
|
1233
|
+
export async function omxPluginCacheProvenanceReason(cacheDir, packagedMarketplace, expectedVersion, options = {}) {
|
|
1234
|
+
const manifestReason = await omxPluginCacheManifestProvenanceReason(cacheDir, expectedVersion);
|
|
1235
|
+
if (manifestReason)
|
|
1236
|
+
return manifestReason;
|
|
1237
|
+
const companionReason = await omxPluginCacheCompanionMetadataProvenanceReason(cacheDir, packagedMarketplace);
|
|
1238
|
+
if (companionReason)
|
|
1239
|
+
return companionReason;
|
|
1240
|
+
const expectedSkillNames = await expectedPackagedOmxSkillNames(packagedMarketplace, options);
|
|
1241
|
+
if (!expectedSkillNames)
|
|
1242
|
+
return "packaged skill names are unavailable";
|
|
1243
|
+
const skillsReason = await omxPluginCacheSkillsProvenanceReason(cacheDir, packagedMarketplace, expectedSkillNames);
|
|
1244
|
+
if (skillsReason)
|
|
1245
|
+
return skillsReason;
|
|
1246
|
+
const assetReason = await omxPluginCacheExecutedAssetProvenanceReason(cacheDir);
|
|
1247
|
+
if (assetReason)
|
|
1248
|
+
return assetReason;
|
|
1249
|
+
if (!(await hasRegularPublicationMarker(cacheDir, ".omx-complete"))) {
|
|
1250
|
+
return `cache snapshot completion marker is malformed or does not match the immutable claim at ${cacheDir}`;
|
|
1251
|
+
}
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
async function openManagedCacheNamespace(cacheBase, codexHomeDir, options) {
|
|
91
1255
|
const managedNamespace = relative(resolve(codexHomeDir), resolve(cacheBase));
|
|
92
1256
|
if (!managedNamespace || managedNamespace.startsWith("..") || isAbsolute(managedNamespace)) {
|
|
93
|
-
throw new Error(`Refusing to
|
|
1257
|
+
throw new Error(`Refusing to access an OMX plugin cache outside the Codex home: ${resolve(cacheBase)}`);
|
|
94
1258
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
1259
|
+
const noFollowFlags = fsConstants.O_NOFOLLOW;
|
|
1260
|
+
const directoryFlags = fsConstants.O_DIRECTORY;
|
|
1261
|
+
if (process.platform !== "win32" && (typeof noFollowFlags !== "number" || typeof directoryFlags !== "number")) {
|
|
1262
|
+
throw new Error("platform cannot provide no-follow directory anchoring for the OMX plugin cache namespace");
|
|
1263
|
+
}
|
|
1264
|
+
if (options.create)
|
|
1265
|
+
await mkdir(codexHomeDir, { recursive: true });
|
|
1266
|
+
let homeRealpath;
|
|
1267
|
+
try {
|
|
1268
|
+
homeRealpath = await realpath(codexHomeDir);
|
|
1269
|
+
}
|
|
1270
|
+
catch (error) {
|
|
1271
|
+
if (isMissingPathError(error) && !options.create)
|
|
1272
|
+
return null;
|
|
1273
|
+
throw error;
|
|
1274
|
+
}
|
|
1275
|
+
const handles = [];
|
|
1276
|
+
try {
|
|
1277
|
+
const homeRef = await openDirectoryRef(homeRealpath);
|
|
1278
|
+
handles.push(homeRef.handle);
|
|
1279
|
+
let parentRef = homeRef;
|
|
1280
|
+
for (const component of managedNamespace.split(sep).filter(Boolean)) {
|
|
1281
|
+
let childRef;
|
|
1282
|
+
try {
|
|
1283
|
+
childRef = await openDirectoryChild(parentRef, component);
|
|
103
1284
|
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
if (!isMissingPathError(error) || !options.create) {
|
|
1287
|
+
if (isMissingPathError(error) && !options.create) {
|
|
1288
|
+
for (const handle of handles.reverse())
|
|
1289
|
+
await handle.close();
|
|
1290
|
+
return null;
|
|
1291
|
+
}
|
|
1292
|
+
const code = error.code;
|
|
1293
|
+
if (code === "ELOOP" || code === "ENOTDIR") {
|
|
1294
|
+
throw new Error(`Refusing to access OMX plugin cache through a symbolic link or non-directory namespace component: ${join(parentRef.path, component)}`);
|
|
1295
|
+
}
|
|
1296
|
+
throw error;
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
await mkdirDirectoryChild(parentRef, component);
|
|
1300
|
+
}
|
|
1301
|
+
catch (mkdirError) {
|
|
1302
|
+
if (mkdirError.code !== "EEXIST")
|
|
1303
|
+
throw mkdirError;
|
|
1304
|
+
}
|
|
1305
|
+
try {
|
|
1306
|
+
childRef = await openDirectoryChild(parentRef, component);
|
|
1307
|
+
}
|
|
1308
|
+
catch (retryError) {
|
|
1309
|
+
const code = retryError.code;
|
|
1310
|
+
if (code === "ELOOP" || code === "ENOTDIR") {
|
|
1311
|
+
throw new Error(`Refusing to access OMX plugin cache through a symbolic link or non-directory namespace component: ${join(parentRef.path, component)}`);
|
|
1312
|
+
}
|
|
1313
|
+
throw retryError;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
handles.push(childRef.handle);
|
|
1317
|
+
parentRef = childRef;
|
|
104
1318
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
throw new Error(`Refusing to mutate OMX plugin cache through a non-directory namespace component: ${current}`);
|
|
1319
|
+
return { handle: parentRef.handle, fdPath: parentRef.mutationPath ?? parentRef.scanOperationPath, path: parentRef.path, handles };
|
|
1320
|
+
}
|
|
1321
|
+
catch (error) {
|
|
1322
|
+
for (const handle of handles.reverse()) {
|
|
1323
|
+
try {
|
|
1324
|
+
await handle.close();
|
|
112
1325
|
}
|
|
1326
|
+
catch { /* preserve the primary failure */ }
|
|
113
1327
|
}
|
|
1328
|
+
throw error;
|
|
114
1329
|
}
|
|
115
1330
|
}
|
|
116
1331
|
async function inspectCacheRoot(cacheDir) {
|
|
117
1332
|
try {
|
|
118
1333
|
const stats = await lstat(cacheDir);
|
|
119
1334
|
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
120
|
-
|
|
1335
|
+
return "untrusted";
|
|
1336
|
+
}
|
|
1337
|
+
if (await hasRegularPublicationMarker(cacheDir, ".omx-incomplete"))
|
|
1338
|
+
return "untrusted";
|
|
1339
|
+
if (!(await hasRegularPublicationMarker(cacheDir, ".omx-complete")))
|
|
1340
|
+
return "directory";
|
|
1341
|
+
const manifest = await readRegularOmxPluginCacheManifest(cacheDir);
|
|
1342
|
+
if (!manifest) {
|
|
1343
|
+
const manifestPath = join(cacheDir, ".codex-plugin", "plugin.json");
|
|
1344
|
+
try {
|
|
1345
|
+
const manifestStats = await lstat(manifestPath);
|
|
1346
|
+
if (manifestStats.isSymbolicLink())
|
|
1347
|
+
return "directory";
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
// Treat missing or unreadable manifests as foreign cache entries.
|
|
1351
|
+
}
|
|
121
1352
|
}
|
|
122
|
-
const manifest = await readPluginManifest(join(cacheDir, ".codex-plugin", "plugin.json"));
|
|
123
1353
|
return manifest?.name === OMX_PLUGIN_NAME ? "directory" : "foreign";
|
|
124
1354
|
}
|
|
125
1355
|
catch (error) {
|
|
@@ -128,26 +1358,101 @@ async function inspectCacheRoot(cacheDir) {
|
|
|
128
1358
|
throw error;
|
|
129
1359
|
}
|
|
130
1360
|
}
|
|
131
|
-
async function
|
|
132
|
-
const
|
|
133
|
-
await
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1361
|
+
async function validateStagedPluginSnapshot(snapshot, packagedMarketplace, version, teamMode) {
|
|
1362
|
+
const snapshotPath = snapshot.operationPath;
|
|
1363
|
+
const manifestReason = await omxPluginCacheManifestProvenanceReason(snapshotPath, version);
|
|
1364
|
+
if (manifestReason)
|
|
1365
|
+
throw new Error(`Packaged OMX plugin snapshot has invalid provenance: ${manifestReason}`);
|
|
1366
|
+
const expectedSkillNames = await expectedPackagedOmxSkillNames(packagedMarketplace, { teamMode });
|
|
1367
|
+
if (!expectedSkillNames)
|
|
1368
|
+
throw new Error("Packaged OMX plugin snapshot cannot determine expected skills");
|
|
1369
|
+
const skillsReason = await omxPluginCacheSkillsProvenanceReason(snapshotPath, packagedMarketplace, expectedSkillNames);
|
|
1370
|
+
if (skillsReason)
|
|
1371
|
+
throw new Error(`Packaged OMX plugin snapshot has invalid provenance: ${skillsReason}`);
|
|
1372
|
+
const companionReason = await omxPluginCacheCompanionMetadataProvenanceReason(snapshotPath, packagedMarketplace);
|
|
1373
|
+
if (companionReason)
|
|
1374
|
+
throw new Error(`Packaged OMX plugin snapshot has invalid provenance: ${companionReason}`);
|
|
1375
|
+
for (const relativePath of [".mcp.json", ".app.json", "hooks/hooks.json", "hooks/codex-native-hook.mjs", `hooks/${OMX_PLUGIN_HOOK_LAUNCHER_FILE}`]) {
|
|
1376
|
+
const path = join(snapshotPath, relativePath);
|
|
1377
|
+
let stats;
|
|
1378
|
+
try {
|
|
1379
|
+
stats = await lstat(path);
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
throw new Error(`Packaged OMX plugin snapshot is missing required surface: ${path}`);
|
|
1383
|
+
}
|
|
1384
|
+
if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) {
|
|
1385
|
+
throw new Error(`Packaged OMX plugin snapshot has an invalid required surface: ${path}`);
|
|
1386
|
+
}
|
|
1387
|
+
if (await readOmxPluginCacheFileNoFollow(path, { anchorDir: snapshotPath }) === null) {
|
|
1388
|
+
throw new Error(`Packaged OMX plugin snapshot cannot read required surface: ${path}`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
for (const relativePath of [".mcp.json", ".app.json", "hooks/hooks.json"]) {
|
|
1392
|
+
const bytes = await readOmxPluginCacheFileNoFollow(join(snapshotPath, relativePath), { anchorDir: snapshotPath });
|
|
1393
|
+
try {
|
|
1394
|
+
if (!bytes)
|
|
1395
|
+
throw new Error("missing");
|
|
1396
|
+
JSON.parse(bytes.toString("utf-8"));
|
|
1397
|
+
}
|
|
1398
|
+
catch {
|
|
1399
|
+
throw new Error(`Packaged OMX plugin snapshot has invalid JSON: ${join(snapshot.path, relativePath)}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (!(await fileContentsEqual(join(snapshotPath, "hooks", "hooks.json"), join(packagedMarketplace.pluginRoot, "hooks", "hooks.json"), snapshotPath))) {
|
|
1403
|
+
throw new Error(`Packaged OMX plugin snapshot hooks.json differs from packaged hooks`);
|
|
1404
|
+
}
|
|
1405
|
+
if (!(await fileContentsEqual(join(snapshotPath, "hooks", "codex-native-hook.mjs"), join(packagedMarketplace.pluginRoot, "hooks", "codex-native-hook.mjs"), snapshotPath))) {
|
|
1406
|
+
throw new Error(`Packaged OMX plugin snapshot native hook differs from packaged hook`);
|
|
1407
|
+
}
|
|
1408
|
+
const launcherReason = await getPinnedLauncherIncompatibilityReason(snapshotPath, packagedMarketplace);
|
|
1409
|
+
if (launcherReason)
|
|
1410
|
+
throw new Error(`Packaged OMX plugin snapshot has invalid launcher provenance: ${launcherReason.reason}`);
|
|
1411
|
+
}
|
|
1412
|
+
async function stageCompletePluginSnapshot(stagingParent, packagedMarketplace, version, teamMode, tracker) {
|
|
1413
|
+
await assertDirectoryRef(stagingParent, "snapshot staging");
|
|
1414
|
+
await mkdirDirectoryChildExclusive(stagingParent, "snapshot");
|
|
1415
|
+
const snapshot = await openDirectoryChild(stagingParent, "snapshot");
|
|
1416
|
+
try {
|
|
1417
|
+
await createExclusiveFileChild(snapshot, ".omx-incomplete", `${process.pid}\n`, tracker);
|
|
1418
|
+
const packagedSource = await openDirectoryRef(packagedMarketplace.pluginRoot);
|
|
1419
|
+
try {
|
|
1420
|
+
await copyPackagedTree(packagedSource, snapshot, tracker);
|
|
1421
|
+
}
|
|
1422
|
+
finally {
|
|
1423
|
+
await packagedSource.handle.close();
|
|
1424
|
+
}
|
|
1425
|
+
await assertDirectoryRef(snapshot, "snapshot staging");
|
|
1426
|
+
await applyTeamModeToPluginCache(snapshot, teamMode);
|
|
1427
|
+
const hooksDir = await openDirectoryChild(snapshot, "hooks");
|
|
1428
|
+
try {
|
|
1429
|
+
await writePinnedHookLauncher(hooksDir, packagedMarketplace, tracker);
|
|
1430
|
+
}
|
|
1431
|
+
finally {
|
|
1432
|
+
await hooksDir.handle.close();
|
|
1433
|
+
}
|
|
1434
|
+
await validateStagedPluginSnapshot(snapshot, packagedMarketplace, version, teamMode);
|
|
1435
|
+
return snapshot;
|
|
1436
|
+
}
|
|
1437
|
+
catch (error) {
|
|
1438
|
+
await snapshot.handle.close();
|
|
1439
|
+
throw error;
|
|
144
1440
|
}
|
|
145
|
-
return snapshotDir;
|
|
146
1441
|
}
|
|
147
1442
|
export async function discoverOmxPluginCacheDirs(codexHomeDir) {
|
|
148
1443
|
const cacheRoot = join(codexHomeDir, "plugins", "cache");
|
|
149
|
-
|
|
1444
|
+
let cacheRootIdentity;
|
|
1445
|
+
try {
|
|
1446
|
+
const cacheRootStats = await lstat(cacheRoot);
|
|
1447
|
+
if (!cacheRootStats.isDirectory() || cacheRootStats.isSymbolicLink())
|
|
1448
|
+
return [];
|
|
1449
|
+
if (typeof cacheRootStats.dev !== "number" || typeof cacheRootStats.ino !== "number")
|
|
1450
|
+
return [];
|
|
1451
|
+
cacheRootIdentity = { dev: cacheRootStats.dev, ino: cacheRootStats.ino };
|
|
1452
|
+
}
|
|
1453
|
+
catch {
|
|
150
1454
|
return [];
|
|
1455
|
+
}
|
|
151
1456
|
const queue = [
|
|
152
1457
|
{ path: cacheRoot, depth: 0 },
|
|
153
1458
|
];
|
|
@@ -157,10 +1462,13 @@ export async function discoverOmxPluginCacheDirs(codexHomeDir) {
|
|
|
157
1462
|
const current = queue.shift();
|
|
158
1463
|
if (!current)
|
|
159
1464
|
break;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
1465
|
+
if (current.depth >= 2) {
|
|
1466
|
+
if (await hasRegularPublicationMarker(current.path, ".omx-incomplete"))
|
|
1467
|
+
continue;
|
|
1468
|
+
const manifest = await readRegularOmxPluginCacheManifest(current.path, cacheRoot);
|
|
163
1469
|
if (manifest?.name === OMX_PLUGIN_NAME) {
|
|
1470
|
+
if (!(await hasRegularPublicationMarker(current.path, ".omx-complete")))
|
|
1471
|
+
continue;
|
|
164
1472
|
matches.push(current.path);
|
|
165
1473
|
continue;
|
|
166
1474
|
}
|
|
@@ -179,25 +1487,105 @@ export async function discoverOmxPluginCacheDirs(codexHomeDir) {
|
|
|
179
1487
|
continue;
|
|
180
1488
|
if (entry.name === ".git" || entry.name === "node_modules")
|
|
181
1489
|
continue;
|
|
1490
|
+
// #3552 blocker 5: staging trees (`.omx-plugin-*` and reclaim
|
|
1491
|
+
// quarantines) live inside the scanned namespace only until
|
|
1492
|
+
// publication; discovery must never descend into them, so a
|
|
1493
|
+
// marker-committed snapshot can never be observed at its staging
|
|
1494
|
+
// path before the final no-replace publication.
|
|
1495
|
+
if (isPluginCacheStagingEntryName(entry.name))
|
|
1496
|
+
continue;
|
|
182
1497
|
queue.push({
|
|
183
1498
|
path: join(current.path, entry.name),
|
|
184
1499
|
depth: current.depth + 1,
|
|
185
1500
|
});
|
|
186
1501
|
}
|
|
187
1502
|
}
|
|
1503
|
+
try {
|
|
1504
|
+
const finalRootStats = await lstat(cacheRoot);
|
|
1505
|
+
if (!finalRootStats.isDirectory() || finalRootStats.isSymbolicLink() || finalRootStats.dev !== cacheRootIdentity.dev || finalRootStats.ino !== cacheRootIdentity.ino)
|
|
1506
|
+
return [];
|
|
1507
|
+
}
|
|
1508
|
+
catch {
|
|
1509
|
+
return [];
|
|
1510
|
+
}
|
|
188
1511
|
return matches.sort();
|
|
189
1512
|
}
|
|
190
|
-
|
|
191
|
-
const
|
|
1513
|
+
async function readRegularCacheSkillNames(cacheDir) {
|
|
1514
|
+
const skillsDir = join(cacheDir, "skills");
|
|
1515
|
+
try {
|
|
1516
|
+
const skillsStats = await lstat(skillsDir);
|
|
1517
|
+
if (!skillsStats.isDirectory() || skillsStats.isSymbolicLink())
|
|
1518
|
+
return null;
|
|
1519
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
1520
|
+
if (entries.some((entry) => entry.isSymbolicLink() || !entry.isDirectory()))
|
|
1521
|
+
return null;
|
|
1522
|
+
for (const entry of entries) {
|
|
1523
|
+
const skillFile = join(skillsDir, entry.name, "SKILL.md");
|
|
1524
|
+
const skillStats = await lstat(skillFile);
|
|
1525
|
+
if (!skillStats.isFile() || skillStats.isSymbolicLink() || skillStats.nlink !== 1)
|
|
1526
|
+
return null;
|
|
1527
|
+
if (await readOmxPluginCacheFileNoFollow(skillFile, { anchorDir: cacheDir }) === null)
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
return entries.map((entry) => entry.name).sort();
|
|
1531
|
+
}
|
|
1532
|
+
catch {
|
|
1533
|
+
return null;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
async function cacheCompanionIsReadable(cacheDir, relativePath) {
|
|
1537
|
+
const bytes = await readOmxPluginCacheFileNoFollow(join(cacheDir, relativePath), { anchorDir: cacheDir });
|
|
1538
|
+
if (bytes === null)
|
|
1539
|
+
return false;
|
|
1540
|
+
try {
|
|
1541
|
+
JSON.parse(bytes.toString("utf-8"));
|
|
1542
|
+
return true;
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
return false;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
export async function readOmxPluginCacheState(cacheDir, expectedVersion) {
|
|
1549
|
+
// #3552: never read cache state through a symlinked or non-directory snapshot root —
|
|
1550
|
+
// readFile-based manifest reads would follow an external target before provenance checks.
|
|
1551
|
+
// A missing pinned launcher is reported by the dedicated launcher checks, not here.
|
|
1552
|
+
const executedAssetReason = await omxPluginCacheExecutedAssetProvenanceReason(cacheDir);
|
|
1553
|
+
if (executedAssetReason)
|
|
1554
|
+
return null;
|
|
1555
|
+
if (await hasRegularPublicationMarker(cacheDir, ".omx-incomplete"))
|
|
1556
|
+
return null;
|
|
1557
|
+
if (await omxPluginCacheManifestProvenanceReason(cacheDir, expectedVersion))
|
|
1558
|
+
return null;
|
|
1559
|
+
const manifest = await readRegularOmxPluginCacheManifest(cacheDir);
|
|
192
1560
|
if (manifest?.name !== OMX_PLUGIN_NAME)
|
|
193
1561
|
return null;
|
|
1562
|
+
if (expectedVersion !== undefined && manifest.version !== expectedVersion)
|
|
1563
|
+
return null;
|
|
1564
|
+
const skillNames = await readRegularCacheSkillNames(cacheDir);
|
|
1565
|
+
if (!skillNames)
|
|
1566
|
+
return null;
|
|
1567
|
+
if (!(await cacheCompanionIsReadable(cacheDir, ".mcp.json")) || !(await cacheCompanionIsReadable(cacheDir, ".app.json")))
|
|
1568
|
+
return null;
|
|
1569
|
+
const launcher = await readPinnedLauncherRaw(cacheDir);
|
|
1570
|
+
if (launcher.error ||
|
|
1571
|
+
!launcher.parsed ||
|
|
1572
|
+
typeof launcher.parsed.command !== "string" ||
|
|
1573
|
+
launcher.parsed.command.trim() === "" ||
|
|
1574
|
+
!Array.isArray(launcher.parsed.argsPrefix) ||
|
|
1575
|
+
launcher.parsed.argsPrefix.length !== 1 ||
|
|
1576
|
+
typeof launcher.parsed.argsPrefix[0] !== "string" ||
|
|
1577
|
+
launcher.parsed.argsPrefix[0].trim() === "" ||
|
|
1578
|
+
Object.keys(launcher.parsed).some((key) => key !== "command" && key !== "argsPrefix"))
|
|
1579
|
+
return null;
|
|
194
1580
|
return {
|
|
195
1581
|
cacheDir,
|
|
196
1582
|
manifestVersion: typeof manifest.version === "string" ? manifest.version : null,
|
|
197
1583
|
skillsPointer: typeof manifest.skills === "string" ? manifest.skills : null,
|
|
198
|
-
skillNames
|
|
1584
|
+
skillNames,
|
|
199
1585
|
hooksPointer: typeof manifest.hooks === "string" ? manifest.hooks : null,
|
|
200
|
-
|
|
1586
|
+
mcpServersPointer: typeof manifest.mcpServers === "string" ? manifest.mcpServers : null,
|
|
1587
|
+
appsPointer: typeof manifest.apps === "string" ? manifest.apps : null,
|
|
1588
|
+
hookLauncherPinned: true,
|
|
201
1589
|
};
|
|
202
1590
|
}
|
|
203
1591
|
export async function hasExpectedOmxPluginCache(codexHomeDir, packagedMarketplace, options = {}) {
|
|
@@ -207,37 +1595,43 @@ export async function hasExpectedOmxPluginCache(codexHomeDir, packagedMarketplac
|
|
|
207
1595
|
]);
|
|
208
1596
|
if (!version || !expectedSkillNames)
|
|
209
1597
|
return false;
|
|
210
|
-
const state = await readOmxPluginCacheState(join(omxPluginCacheBase(codexHomeDir), version));
|
|
211
|
-
if (state
|
|
1598
|
+
const state = await readOmxPluginCacheState(options.cacheDirOverride ?? join(omxPluginCacheBase(codexHomeDir), version), version);
|
|
1599
|
+
if (!state)
|
|
1600
|
+
return false;
|
|
1601
|
+
if (state.manifestVersion !== version ||
|
|
212
1602
|
state.skillsPointer !== "./skills/" ||
|
|
213
1603
|
state.hooksPointer !== "./hooks/hooks.json" ||
|
|
214
1604
|
!state.hookLauncherPinned ||
|
|
215
|
-
!existsSync(join(state.cacheDir, "hooks", "hooks.json")) ||
|
|
216
|
-
!existsSync(join(state.cacheDir, "hooks", "codex-native-hook.mjs")) ||
|
|
217
1605
|
JSON.stringify(state.skillNames) !== JSON.stringify(expectedSkillNames)) {
|
|
218
1606
|
return false;
|
|
219
1607
|
}
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
async function fileContentsEqual(leftPath, rightPath) {
|
|
223
|
-
try {
|
|
224
|
-
const [left, right] = await Promise.all([
|
|
225
|
-
readFile(leftPath),
|
|
226
|
-
readFile(rightPath),
|
|
227
|
-
]);
|
|
228
|
-
return left.equals(right);
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
1608
|
+
if (await omxPluginCacheProvenanceReason(state.cacheDir, packagedMarketplace, version, options)) {
|
|
231
1609
|
return false;
|
|
232
1610
|
}
|
|
1611
|
+
return pluginHookCacheMatchesPackaged(state.cacheDir, packagedMarketplace);
|
|
1612
|
+
}
|
|
1613
|
+
async function fileContentsEqual(leftPath, rightPath, anchorDir) {
|
|
1614
|
+
const [left, right] = await Promise.all([
|
|
1615
|
+
readOmxPluginCacheFileNoFollow(leftPath, anchorDir ? { anchorDir } : {}),
|
|
1616
|
+
readOmxPluginCacheFileNoFollow(rightPath, { requireSingleLink: false }),
|
|
1617
|
+
]);
|
|
1618
|
+
return left !== null && right !== null && left.equals(right);
|
|
233
1619
|
}
|
|
234
1620
|
/**
|
|
235
1621
|
* Compares only plugin-scoped hook assets that Codex executes from the cache.
|
|
236
1622
|
* Manifest pointers and skill lists are validated by callers before using this
|
|
237
1623
|
* as a hook/launcher freshness predicate.
|
|
1624
|
+
*
|
|
1625
|
+
* #3552: the comparison is fail-closed about provenance — every executed asset
|
|
1626
|
+
* is lstat-validated as a regular file before it is read, so a symlinked asset
|
|
1627
|
+
* with byte-identical external content can no longer satisfy the unchanged
|
|
1628
|
+
* fast paths. `false` here means "do not trust this cache snapshot".
|
|
238
1629
|
*/
|
|
239
1630
|
export async function pluginHookCacheMatchesPackaged(cacheDir, packagedMarketplace) {
|
|
240
|
-
|
|
1631
|
+
if ((await omxPluginCacheExecutedAssetProvenanceReason(cacheDir)) !== null) {
|
|
1632
|
+
return false;
|
|
1633
|
+
}
|
|
1634
|
+
return await fileContentsEqual(join(cacheDir, "hooks", "hooks.json"), join(packagedMarketplace.pluginRoot, "hooks", "hooks.json"), cacheDir) && await fileContentsEqual(join(cacheDir, "hooks", "codex-native-hook.mjs"), join(packagedMarketplace.pluginRoot, "hooks", "codex-native-hook.mjs"), cacheDir) && await pinnedHookLauncherMatchesPackaged(cacheDir, packagedMarketplace);
|
|
241
1635
|
}
|
|
242
1636
|
function buildPinnedHookLauncherContent(packagedMarketplace) {
|
|
243
1637
|
return `${JSON.stringify({
|
|
@@ -246,94 +1640,630 @@ function buildPinnedHookLauncherContent(packagedMarketplace) {
|
|
|
246
1640
|
}, null, 2)}\n`;
|
|
247
1641
|
}
|
|
248
1642
|
async function pinnedHookLauncherMatchesPackaged(cacheDir, packagedMarketplace) {
|
|
1643
|
+
return await readRegularFileTextNoFollow(join(cacheDir, "hooks", OMX_PLUGIN_HOOK_LAUNCHER_FILE), { anchorDir: cacheDir }) === buildPinnedHookLauncherContent(packagedMarketplace);
|
|
1644
|
+
}
|
|
1645
|
+
async function writePinnedHookLauncher(hooksDir, packagedMarketplace, tracker) {
|
|
1646
|
+
// #3552 review 3859434238: the launcher is generated into a freshly
|
|
1647
|
+
// created staging snapshot, so it must be created exclusively; a
|
|
1648
|
+
// concurrent hard link at the staged path would otherwise let O_TRUNC
|
|
1649
|
+
// clobber a foreign inode before any provenance check.
|
|
1650
|
+
await createExclusiveFileChild(hooksDir, OMX_PLUGIN_HOOK_LAUNCHER_FILE, buildPinnedHookLauncherContent(packagedMarketplace), tracker);
|
|
1651
|
+
}
|
|
1652
|
+
async function applyTeamModeToPluginCache(cacheDir, teamMode) {
|
|
1653
|
+
if (teamModeEnabled(teamMode))
|
|
1654
|
+
return;
|
|
1655
|
+
let skillsDir;
|
|
249
1656
|
try {
|
|
250
|
-
|
|
1657
|
+
skillsDir = await openDirectoryChild(cacheDir, "skills");
|
|
251
1658
|
}
|
|
252
|
-
catch {
|
|
253
|
-
|
|
1659
|
+
catch (error) {
|
|
1660
|
+
if (isMissingPathError(error))
|
|
1661
|
+
return;
|
|
1662
|
+
throw error;
|
|
1663
|
+
}
|
|
1664
|
+
try {
|
|
1665
|
+
for (const skillName of TEAM_MODE_PLUGIN_SKILL_NAMES) {
|
|
1666
|
+
await removeChild(skillsDir, skillName, { recursive: true, force: true });
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
finally {
|
|
1670
|
+
await skillsDir.handle.close();
|
|
254
1671
|
}
|
|
255
1672
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
async function pathIsDirectory(path) {
|
|
1673
|
+
export const PLUGIN_LAUNCHER_RECOVERY_HINT = "codex plugin remove oh-my-codex@oh-my-codex-local --json";
|
|
1674
|
+
async function canonicalRealpath(path) {
|
|
260
1675
|
try {
|
|
261
|
-
return
|
|
1676
|
+
return await realpath(path);
|
|
262
1677
|
}
|
|
263
1678
|
catch {
|
|
264
|
-
return
|
|
1679
|
+
return null;
|
|
265
1680
|
}
|
|
266
1681
|
}
|
|
267
|
-
async function
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
1682
|
+
export async function readPinnedLauncherRaw(cacheDir) {
|
|
1683
|
+
const launcherPath = join(cacheDir, "hooks", OMX_PLUGIN_HOOK_LAUNCHER_FILE);
|
|
1684
|
+
const raw = await readRegularFileTextNoFollow(launcherPath, { anchorDir: cacheDir });
|
|
1685
|
+
if (raw === null) {
|
|
1686
|
+
try {
|
|
1687
|
+
await lstat(launcherPath);
|
|
1688
|
+
return { raw: null, parsed: null, error: "cannot read pinned launcher" };
|
|
1689
|
+
}
|
|
1690
|
+
catch (e) {
|
|
1691
|
+
if (isMissingPathError(e))
|
|
1692
|
+
return { raw: null, parsed: null, error: "missing pinned launcher" };
|
|
1693
|
+
return { raw: null, parsed: null, error: `cannot read pinned launcher: ${e.message}` };
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
try {
|
|
1697
|
+
const parsed = JSON.parse(raw);
|
|
1698
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1699
|
+
return { raw, parsed: null, error: `malformed pinned launcher JSON: expected object but got ${Array.isArray(parsed) ? "array" : String(parsed)}` };
|
|
1700
|
+
}
|
|
1701
|
+
return { raw, parsed, error: undefined };
|
|
1702
|
+
}
|
|
1703
|
+
catch (e) {
|
|
1704
|
+
return { raw, parsed: null, error: `malformed pinned launcher JSON: ${e.message}` };
|
|
272
1705
|
}
|
|
273
1706
|
}
|
|
274
|
-
async function
|
|
275
|
-
|
|
1707
|
+
export async function getPinnedLauncherIncompatibilityReason(cacheDir, packagedMarketplace) {
|
|
1708
|
+
const hooksDir = join(cacheDir, "hooks");
|
|
276
1709
|
try {
|
|
277
|
-
|
|
1710
|
+
const hooksStats = await lstat(hooksDir);
|
|
1711
|
+
if (!hooksStats.isDirectory() || hooksStats.isSymbolicLink()) {
|
|
1712
|
+
return { reason: `hooks directory at ${hooksDir} is a symlink or not a directory; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1713
|
+
}
|
|
278
1714
|
}
|
|
279
|
-
catch {
|
|
280
|
-
|
|
1715
|
+
catch (e) {
|
|
1716
|
+
if (isMissingPathError(e))
|
|
1717
|
+
return { reason: `hooks directory missing at ${hooksDir}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1718
|
+
return { reason: `cannot read hooks directory at ${hooksDir}: ${e.message}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
281
1719
|
}
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
1720
|
+
const launcherPath = join(cacheDir, "hooks", OMX_PLUGIN_HOOK_LAUNCHER_FILE);
|
|
1721
|
+
try {
|
|
1722
|
+
const launcherStats = await lstat(launcherPath);
|
|
1723
|
+
if (!launcherStats.isFile() || launcherStats.isSymbolicLink() || launcherStats.nlink !== 1) {
|
|
1724
|
+
return { reason: `pinned launcher at ${launcherPath} is a symlink or not a regular file; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
catch (e) {
|
|
1728
|
+
if (isMissingPathError(e))
|
|
1729
|
+
return { reason: `pinned launcher missing at ${launcherPath}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1730
|
+
return { reason: `cannot read pinned launcher at ${launcherPath}: ${e.message}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1731
|
+
}
|
|
1732
|
+
const raw = await readRegularFileTextNoFollow(launcherPath, { anchorDir: cacheDir });
|
|
1733
|
+
if (raw === null) {
|
|
1734
|
+
try {
|
|
1735
|
+
await lstat(launcherPath);
|
|
1736
|
+
return { reason: `cannot read pinned launcher at ${launcherPath}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1737
|
+
}
|
|
1738
|
+
catch (e) {
|
|
1739
|
+
if (isMissingPathError(e))
|
|
1740
|
+
return { reason: `pinned launcher missing at ${launcherPath}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1741
|
+
return { reason: `cannot read pinned launcher at ${launcherPath}: ${e.message}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
let parsed;
|
|
1745
|
+
try {
|
|
1746
|
+
parsed = JSON.parse(raw);
|
|
1747
|
+
}
|
|
1748
|
+
catch (e) {
|
|
1749
|
+
return { reason: `pinned launcher at ${launcherPath} is malformed JSON (${e.message}); run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1750
|
+
}
|
|
1751
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1752
|
+
return { reason: `pinned launcher at ${launcherPath} is malformed JSON (expected object but got ${Array.isArray(parsed) ? "array" : String(parsed)}); run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
293
1753
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
await rm(candidate.path, { recursive: true, force: true });
|
|
299
|
-
retired.push(candidate.path);
|
|
1754
|
+
const obj = parsed;
|
|
1755
|
+
const extraKeys = Object.keys(obj).filter((key) => key !== "command" && key !== "argsPrefix");
|
|
1756
|
+
if (extraKeys.length > 0) {
|
|
1757
|
+
return { reason: `pinned launcher at ${launcherPath} has extra keys (${extraKeys.sort().join(", ")}); run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
300
1758
|
}
|
|
301
|
-
|
|
1759
|
+
if (typeof obj.command !== "string" || obj.command.trim() === "") {
|
|
1760
|
+
return { reason: `pinned launcher at ${launcherPath} has invalid command; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1761
|
+
}
|
|
1762
|
+
if (!Array.isArray(obj.argsPrefix) || obj.argsPrefix.length !== 1 || typeof obj.argsPrefix[0] !== "string" || obj.argsPrefix[0].trim() === "") {
|
|
1763
|
+
return { reason: `pinned launcher at ${launcherPath} has invalid argsPrefix (expected exactly one packaged omx.js target); run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin` };
|
|
1764
|
+
}
|
|
1765
|
+
const command = obj.command.trim();
|
|
1766
|
+
const target = obj.argsPrefix[0];
|
|
1767
|
+
// Validate command field (generated launcher contract) — fail-closed on dead/mismatched executable
|
|
1768
|
+
if (!isAbsolute(command)) {
|
|
1769
|
+
return { reason: `pinned launcher command is not absolute: ${command}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`, target };
|
|
1770
|
+
}
|
|
1771
|
+
if (!existsSync(command)) {
|
|
1772
|
+
return { reason: `pinned launcher command does not exist: ${command}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`, target };
|
|
1773
|
+
}
|
|
1774
|
+
const expectedCommand = process.execPath;
|
|
1775
|
+
const [canonicalActualCommand, canonicalExpectedCommand] = await Promise.all([
|
|
1776
|
+
canonicalRealpath(command),
|
|
1777
|
+
canonicalRealpath(expectedCommand),
|
|
1778
|
+
]);
|
|
1779
|
+
if (canonicalActualCommand === null || canonicalExpectedCommand === null || canonicalActualCommand !== canonicalExpectedCommand) {
|
|
1780
|
+
return { reason: `pinned launcher command provenance mismatch: expected ${expectedCommand} but found ${command} (different Node executable); run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`, target };
|
|
1781
|
+
}
|
|
1782
|
+
if (!isAbsolute(target)) {
|
|
1783
|
+
return { reason: `pinned launcher target is not absolute: ${target}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`, target };
|
|
1784
|
+
}
|
|
1785
|
+
if (!existsSync(target)) {
|
|
1786
|
+
return { reason: `pinned launcher target does not exist: ${target} (package root removed?); run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`, target };
|
|
1787
|
+
}
|
|
1788
|
+
const expectedTarget = join(packagedMarketplace.packageRoot, "dist", "cli", "omx.js");
|
|
1789
|
+
const [canonicalActual, canonicalExpected] = await Promise.all([
|
|
1790
|
+
canonicalRealpath(target),
|
|
1791
|
+
canonicalRealpath(expectedTarget),
|
|
1792
|
+
]);
|
|
1793
|
+
if (canonicalActual === null || canonicalExpected === null || canonicalActual !== canonicalExpected) {
|
|
1794
|
+
return { reason: `pinned launcher provenance mismatch: expected ${expectedTarget} but found ${target} (different package root); run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`, target };
|
|
1795
|
+
}
|
|
1796
|
+
return null;
|
|
302
1797
|
}
|
|
303
|
-
export async function
|
|
1798
|
+
export async function retireUnpinnedManagedSnapshots(codexHomeDir, currentVersion, anchoredCacheBaseRef, publicationLockHeld = false, preservedDirs, heldPublicationLockGuard) {
|
|
1799
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1800
|
+
const noFollowFlags = fsConstants.O_NOFOLLOW;
|
|
1801
|
+
const directoryFlags = fsConstants.O_DIRECTORY;
|
|
1802
|
+
if (process.platform !== "win32" && (typeof noFollowFlags !== "number" || typeof directoryFlags !== "number")) {
|
|
1803
|
+
throw new Error("platform cannot provide no-follow directory anchoring for cache retirement");
|
|
1804
|
+
}
|
|
1805
|
+
let baseRef = anchoredCacheBaseRef;
|
|
1806
|
+
let ownsBaseRef = false;
|
|
1807
|
+
let publicationLock;
|
|
1808
|
+
let publicationLockIdentity;
|
|
1809
|
+
const durability = { degraded: false };
|
|
1810
|
+
let publicationLockGuard = heldPublicationLockGuard;
|
|
1811
|
+
let ownsPublicationLockGuard = false;
|
|
1812
|
+
const candidateRefs = [];
|
|
1813
|
+
let cleanupError = null;
|
|
1814
|
+
try {
|
|
1815
|
+
if (!baseRef) {
|
|
1816
|
+
baseRef = await openDirectoryRef(cacheBase);
|
|
1817
|
+
ownsBaseRef = true;
|
|
1818
|
+
}
|
|
1819
|
+
if (!publicationLockHeld) {
|
|
1820
|
+
publicationLock = await claimPublicationLock(baseRef, cacheBase, noFollowFlags, durability);
|
|
1821
|
+
publicationLockIdentity = await publicationLock.stat();
|
|
1822
|
+
await refreshPublicationLockRecord(join(cacheBase, ".omx-publish.lock"), publicationLock, publicationLockIdentity, durability);
|
|
1823
|
+
publicationLockGuard = startPublicationLockHeartbeat(join(cacheBase, ".omx-publish.lock"), publicationLock, publicationLockIdentity, durability);
|
|
1824
|
+
ownsPublicationLockGuard = true;
|
|
1825
|
+
}
|
|
1826
|
+
await publicationLockGuard?.assertOwned();
|
|
1827
|
+
const entries = await readdir(baseRef.operationPath, { withFileTypes: true });
|
|
1828
|
+
const managed = [];
|
|
1829
|
+
for (const entry of entries) {
|
|
1830
|
+
if (!entry.isDirectory() || entry.name === currentVersion)
|
|
1831
|
+
continue;
|
|
1832
|
+
let candidateRef;
|
|
1833
|
+
try {
|
|
1834
|
+
candidateRef = await openDirectoryChild(baseRef, entry.name);
|
|
1835
|
+
}
|
|
1836
|
+
catch {
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
const candidateStats = await candidateRef.handle.stat();
|
|
1840
|
+
if (!candidateStats.isDirectory() || candidateStats.isSymbolicLink()) {
|
|
1841
|
+
await candidateRef.handle.close();
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1844
|
+
candidateRefs.push(candidateRef);
|
|
1845
|
+
const manifest = await readRegularOmxPluginCacheManifest(candidateRef.operationPath, candidateRef.operationPath);
|
|
1846
|
+
if (manifest?.name !== OMX_PLUGIN_NAME || manifest.version !== entry.name)
|
|
1847
|
+
continue;
|
|
1848
|
+
if (!(await hasRegularPublicationMarker(candidateRef.operationPath, ".omx-complete")) || await hasRegularPublicationMarker(candidateRef.operationPath, ".omx-incomplete"))
|
|
1849
|
+
continue;
|
|
1850
|
+
if (!(await hasRegularPublicationMarker(candidateRef.operationPath, OMX_PLUGIN_MANAGED_MARKER)))
|
|
1851
|
+
continue;
|
|
1852
|
+
if (await readOmxPluginCacheFileNoFollow(join(candidateRef.operationPath, ".omx-live-pin"), { anchorDir: candidateRef.operationPath }) !== null)
|
|
1853
|
+
continue;
|
|
1854
|
+
managed.push({ path: join(cacheBase, entry.name), version: entry.name, mtimeMs: candidateStats.mtimeMs, ref: candidateRef, stats: candidateStats });
|
|
1855
|
+
}
|
|
1856
|
+
managed.sort((left, right) => right.version.localeCompare(left.version, undefined, { numeric: true }) ||
|
|
1857
|
+
right.mtimeMs - left.mtimeMs);
|
|
1858
|
+
const retired = [];
|
|
1859
|
+
for (const candidate of managed.slice(1)) {
|
|
1860
|
+
await publicationLockGuard?.assertOwned();
|
|
1861
|
+
await assertDirectoryRef(baseRef, "retirement");
|
|
1862
|
+
const currentStats = await candidate.ref.handle.stat();
|
|
1863
|
+
if (!currentStats.isDirectory() || currentStats.isSymbolicLink() || currentStats.dev !== candidate.stats.dev || currentStats.ino !== candidate.stats.ino) {
|
|
1864
|
+
throw new Error(`managed cache retirement target changed before removal: ${candidate.path}`);
|
|
1865
|
+
}
|
|
1866
|
+
if (await readOmxPluginCacheFileNoFollow(join(candidate.ref.operationPath, ".omx-live-pin"), { anchorDir: candidate.ref.operationPath }) !== null) {
|
|
1867
|
+
continue;
|
|
1868
|
+
}
|
|
1869
|
+
if (await removeChildIfIdentity(baseRef, candidate.version, currentStats, { recursive: true, force: true, expectedManagedVersion: candidate.version, preservedPaths: preservedDirs })) {
|
|
1870
|
+
retired.push(candidate.path);
|
|
1871
|
+
}
|
|
1872
|
+
else {
|
|
1873
|
+
preservedDirs?.push(candidate.path);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
return retired;
|
|
1877
|
+
}
|
|
1878
|
+
finally {
|
|
1879
|
+
if (ownsPublicationLockGuard && publicationLockGuard)
|
|
1880
|
+
clearInterval(publicationLockGuard.timer);
|
|
1881
|
+
for (const candidateRef of candidateRefs.reverse()) {
|
|
1882
|
+
try {
|
|
1883
|
+
await candidateRef.handle.close();
|
|
1884
|
+
}
|
|
1885
|
+
catch (error) {
|
|
1886
|
+
cleanupError ??= error;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
if (publicationLock) {
|
|
1890
|
+
try {
|
|
1891
|
+
await publicationLock.close();
|
|
1892
|
+
}
|
|
1893
|
+
catch (error) {
|
|
1894
|
+
cleanupError ??= error;
|
|
1895
|
+
}
|
|
1896
|
+
if (publicationLockIdentity) {
|
|
1897
|
+
const cur = await lstat(join(baseRef.path, ".omx-publish.lock")).catch(() => null);
|
|
1898
|
+
if (cur &&
|
|
1899
|
+
cur.isFile() &&
|
|
1900
|
+
!cur.isSymbolicLink() &&
|
|
1901
|
+
cur.dev === publicationLockIdentity.dev &&
|
|
1902
|
+
cur.ino === publicationLockIdentity.ino) {
|
|
1903
|
+
try {
|
|
1904
|
+
await reclaimStalePublicationLock(baseRef, ".omx-publish.lock", publicationLockIdentity, false);
|
|
1905
|
+
}
|
|
1906
|
+
catch (error) {
|
|
1907
|
+
cleanupError ??= error;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
emitDegradedDurabilityWarning("plugin cache publication", durability);
|
|
1913
|
+
if (ownsBaseRef && baseRef) {
|
|
1914
|
+
try {
|
|
1915
|
+
await baseRef.handle.close();
|
|
1916
|
+
}
|
|
1917
|
+
catch (error) {
|
|
1918
|
+
cleanupError ??= error;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
if (cleanupError)
|
|
1922
|
+
throw cleanupError;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
async function materializePackagedOmxPluginCacheImpl(codexHomeDir, packagedMarketplace, options = {}) {
|
|
304
1926
|
if (!packagedMarketplace)
|
|
305
1927
|
return { status: "unavailable" };
|
|
306
1928
|
const version = await packagedOmxPluginVersion(packagedMarketplace);
|
|
307
1929
|
if (!version)
|
|
308
1930
|
return { status: "unavailable" };
|
|
309
1931
|
const cacheDir = join(omxPluginCacheBase(codexHomeDir), version);
|
|
310
|
-
|
|
1932
|
+
const inspectedCacheDir = options.anchoredCacheDir ?? cacheDir;
|
|
1933
|
+
if (await hasExpectedOmxPluginCache(codexHomeDir, packagedMarketplace, {
|
|
1934
|
+
...options,
|
|
1935
|
+
cacheDirOverride: inspectedCacheDir,
|
|
1936
|
+
})) {
|
|
311
1937
|
return {
|
|
312
1938
|
status: "unchanged",
|
|
313
1939
|
cacheDir,
|
|
314
1940
|
version,
|
|
315
1941
|
retiredDirs: options.dryRun
|
|
316
1942
|
? []
|
|
317
|
-
: await retireUnpinnedManagedSnapshots(
|
|
1943
|
+
: await retireUnpinnedManagedSnapshots(codexHomeDir, version, options.anchoredCacheBaseRef),
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
// Same-version directory exists but is not byte-identical: distinguish immutable-preserved vs dead/provenance-incompatible launcher.
|
|
1947
|
+
// Preserve #3499 immutability: never rewrite an existing same-version directory in place.
|
|
1948
|
+
const rootState = await inspectCacheRoot(inspectedCacheDir);
|
|
1949
|
+
if (rootState === "untrusted") {
|
|
1950
|
+
return {
|
|
1951
|
+
status: "stale-launcher",
|
|
1952
|
+
cacheDir,
|
|
1953
|
+
version,
|
|
1954
|
+
reason: `OMX plugin cache root at ${cacheDir} is a symlink or non-directory entry; managed snapshots must be regular immutable directories; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`,
|
|
1955
|
+
launcherTarget: undefined,
|
|
1956
|
+
retiredDirs: options.dryRun ? [] : await retireUnpinnedManagedSnapshots(codexHomeDir, version, options.anchoredCacheBaseRef),
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
if (rootState === "directory") {
|
|
1960
|
+
const incompat = await getPinnedLauncherIncompatibilityReason(inspectedCacheDir, packagedMarketplace);
|
|
1961
|
+
if (incompat) {
|
|
1962
|
+
return {
|
|
1963
|
+
status: "stale-launcher",
|
|
1964
|
+
cacheDir,
|
|
1965
|
+
version,
|
|
1966
|
+
reason: incompat.reason,
|
|
1967
|
+
launcherTarget: incompat.target,
|
|
1968
|
+
retiredDirs: options.dryRun ? [] : await retireUnpinnedManagedSnapshots(codexHomeDir, version, options.anchoredCacheBaseRef),
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
const snapshotProvenanceReason = await omxPluginCacheProvenanceReason(inspectedCacheDir, packagedMarketplace, version, options);
|
|
1972
|
+
if (snapshotProvenanceReason) {
|
|
1973
|
+
return {
|
|
1974
|
+
status: "stale-launcher",
|
|
1975
|
+
cacheDir,
|
|
1976
|
+
version,
|
|
1977
|
+
reason: `${snapshotProvenanceReason}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`,
|
|
1978
|
+
launcherTarget: undefined,
|
|
1979
|
+
retiredDirs: options.dryRun ? [] : await retireUnpinnedManagedSnapshots(codexHomeDir, version, options.anchoredCacheBaseRef),
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
const assetProvenanceReason = await omxPluginCacheExecutedAssetProvenanceReason(inspectedCacheDir);
|
|
1983
|
+
if (assetProvenanceReason) {
|
|
1984
|
+
return {
|
|
1985
|
+
status: "stale-launcher",
|
|
1986
|
+
cacheDir,
|
|
1987
|
+
version,
|
|
1988
|
+
reason: `${assetProvenanceReason}; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`,
|
|
1989
|
+
launcherTarget: undefined,
|
|
1990
|
+
retiredDirs: options.dryRun ? [] : await retireUnpinnedManagedSnapshots(codexHomeDir, version, options.anchoredCacheBaseRef),
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
return {
|
|
1994
|
+
status: "unchanged",
|
|
1995
|
+
cacheDir,
|
|
1996
|
+
version,
|
|
1997
|
+
retiredDirs: options.dryRun ? [] : await retireUnpinnedManagedSnapshots(codexHomeDir, version, options.anchoredCacheBaseRef),
|
|
318
1998
|
};
|
|
319
1999
|
}
|
|
2000
|
+
if (rootState === "foreign") {
|
|
2001
|
+
return { status: "unavailable", cacheDir, version };
|
|
2002
|
+
}
|
|
320
2003
|
if (!options.dryRun) {
|
|
321
2004
|
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
if (
|
|
325
|
-
return {
|
|
2005
|
+
const noFollowFlags = fsConstants.O_NOFOLLOW;
|
|
2006
|
+
const directoryFlags = fsConstants.O_DIRECTORY;
|
|
2007
|
+
if (process.platform !== "win32" && (typeof noFollowFlags !== "number" || typeof directoryFlags !== "number")) {
|
|
2008
|
+
return {
|
|
2009
|
+
status: "stale-launcher",
|
|
2010
|
+
cacheDir,
|
|
2011
|
+
version,
|
|
2012
|
+
reason: "platform cannot provide no-follow directory anchoring for immutable cache publication",
|
|
2013
|
+
launcherTarget: undefined,
|
|
2014
|
+
retiredDirs: [],
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
let cacheBaseRef = options.anchoredCacheBaseRef;
|
|
2018
|
+
let ownsCacheBaseRef = false;
|
|
2019
|
+
if (!cacheBaseRef) {
|
|
2020
|
+
cacheBaseRef = await openDirectoryRef(cacheBase);
|
|
2021
|
+
ownsCacheBaseRef = true;
|
|
2022
|
+
}
|
|
2023
|
+
const durability = { degraded: false };
|
|
2024
|
+
let lockHandle;
|
|
2025
|
+
let lockIdentity;
|
|
2026
|
+
let publicationLockGuard;
|
|
2027
|
+
try {
|
|
2028
|
+
lockHandle = await claimPublicationLock(cacheBaseRef, cacheBase, noFollowFlags, durability);
|
|
2029
|
+
lockIdentity = await lockHandle.stat();
|
|
2030
|
+
// Keep heartbeatAt fresh while the critical section is held so a slow live
|
|
2031
|
+
// publisher is never reclaimed by a concurrent claimant after LEASE_MS.
|
|
2032
|
+
if (!await refreshPublicationLockRecord(join(cacheBaseRef.path, ".omx-publish.lock"), lockHandle, lockIdentity, durability)) {
|
|
2033
|
+
throw new Error(`publication lock ownership lost at ${join(cacheBaseRef.path, ".omx-publish.lock")}`);
|
|
2034
|
+
}
|
|
2035
|
+
publicationLockGuard = startPublicationLockHeartbeat(join(cacheBaseRef.path, ".omx-publish.lock"), lockHandle, lockIdentity, durability);
|
|
2036
|
+
}
|
|
2037
|
+
catch (error) {
|
|
2038
|
+
emitDegradedDurabilityWarning("plugin cache publication", durability);
|
|
2039
|
+
if (ownsCacheBaseRef)
|
|
2040
|
+
await cacheBaseRef.handle.close();
|
|
2041
|
+
return {
|
|
2042
|
+
status: "stale-launcher",
|
|
2043
|
+
cacheDir,
|
|
2044
|
+
version,
|
|
2045
|
+
reason: `cannot claim immutable OMX plugin cache publication at ${cacheBase}: ${error.message}`,
|
|
2046
|
+
launcherTarget: undefined,
|
|
2047
|
+
retiredDirs: [],
|
|
2048
|
+
};
|
|
326
2049
|
}
|
|
327
|
-
|
|
2050
|
+
let tempName;
|
|
2051
|
+
let tempRef;
|
|
2052
|
+
let snapshotRef;
|
|
2053
|
+
let outcome = {
|
|
2054
|
+
status: "materialized",
|
|
2055
|
+
cacheDir,
|
|
2056
|
+
version,
|
|
2057
|
+
retiredDirs: [],
|
|
2058
|
+
};
|
|
2059
|
+
let cleanupError = null;
|
|
2060
|
+
let finalRef;
|
|
2061
|
+
let claimRef;
|
|
328
2062
|
try {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
await
|
|
2063
|
+
await publicationLockGuard.assertOwned();
|
|
2064
|
+
const candidateTempName = `.omx-plugin-${version}-${process.pid}-${randomUUID()}`;
|
|
2065
|
+
await mkdirDirectoryChildExclusive(cacheBaseRef, candidateTempName);
|
|
2066
|
+
await assertDirectoryRef(cacheBaseRef, "temporary staging");
|
|
2067
|
+
tempName = candidateTempName;
|
|
2068
|
+
tempRef = await openDirectoryChild(cacheBaseRef, tempName);
|
|
2069
|
+
snapshotRef = await stageCompletePluginSnapshot(tempRef, packagedMarketplace, version, options.teamMode, durability);
|
|
2070
|
+
await syncDirectoryTree(snapshotRef, durability);
|
|
2071
|
+
await options.onCacheDirPrepared?.(cacheDir);
|
|
2072
|
+
try {
|
|
2073
|
+
await publicationLockGuard.assertOwned();
|
|
2074
|
+
// #3552 blockers 3+5 / review 3860267789: never rename onto
|
|
2075
|
+
// `<version>` (rename silently replaces an empty destination and
|
|
2076
|
+
// pre/post inode checks cannot make it atomic). The destination is
|
|
2077
|
+
// claimed with an atomic exclusive mkdir self-described by
|
|
2078
|
+
// `.omx-incomplete`; the staged snapshot entries are then renamed
|
|
2079
|
+
// into that empty claim (destination names cannot pre-exist), and
|
|
2080
|
+
// `.omx-complete` is committed last inside the claim. Every trust
|
|
2081
|
+
// path requires `.omx-complete`, so no intermediate state is
|
|
2082
|
+
// trusted, and no pre-existing directory is ever replaced.
|
|
2083
|
+
try {
|
|
2084
|
+
claimRef = await claimPublicationDestination(cacheBaseRef, version, durability);
|
|
2085
|
+
}
|
|
2086
|
+
catch (error) {
|
|
2087
|
+
if (error.code === "EEXIST") {
|
|
2088
|
+
outcome = {
|
|
2089
|
+
status: "stale-launcher",
|
|
2090
|
+
cacheDir,
|
|
2091
|
+
version,
|
|
2092
|
+
reason: `same-version OMX plugin cache already exists at ${cacheDir}; refusing to replace or remove an existing directory; run ${PLUGIN_LAUNCHER_RECOVERY_HINT} then rerun omx setup --plugin`,
|
|
2093
|
+
launcherTarget: undefined,
|
|
2094
|
+
retiredDirs: [],
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
else {
|
|
2098
|
+
throw error;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
if (claimRef) {
|
|
2102
|
+
// Drop the snapshot's staging marker and the claim's placeholder;
|
|
2103
|
+
// the claim is then populated only through exclusive creates and
|
|
2104
|
+
// the final marker is the trust switch.
|
|
2105
|
+
await removeChild(snapshotRef, ".omx-incomplete", { force: true });
|
|
2106
|
+
await removeChild(claimRef, ".omx-incomplete", { force: true });
|
|
2107
|
+
await assertDirectoryRef(claimRef, "publication claim");
|
|
2108
|
+
const claimEntries = await readdir(claimRef.scanOperationPath, { withFileTypes: true });
|
|
2109
|
+
if (claimEntries.length > 0) {
|
|
2110
|
+
throw new Error(`publication claim is not empty at ${join(cacheBaseRef.path, version)}`);
|
|
2111
|
+
}
|
|
2112
|
+
// The portable no-replace primitive is exclusive create, not rename.
|
|
2113
|
+
// Copy the private staged tree into the empty claim with exclusive
|
|
2114
|
+
// mkdir/O_EXCL file creation so a destination inserted between any
|
|
2115
|
+
// checks is rejected instead of overwritten.
|
|
2116
|
+
await copyStagedTreeNoReplace(snapshotRef, claimRef, durability);
|
|
2117
|
+
await snapshotRef.handle.close();
|
|
2118
|
+
snapshotRef = undefined;
|
|
2119
|
+
await createExclusiveFileChild(claimRef, OMX_PLUGIN_MANAGED_MARKER, `${process.pid}\n`, durability);
|
|
2120
|
+
await pluginCacheMutationTestHooks?.beforeFinalClaimValidation?.(claimRef.path);
|
|
2121
|
+
await publicationLockGuard.assertOwned();
|
|
2122
|
+
await validateStagedPluginSnapshot(claimRef, packagedMarketplace, version, options.teamMode);
|
|
2123
|
+
await pluginCacheMutationTestHooks?.beforeCompleteMarker?.(claimRef.path);
|
|
2124
|
+
await validateStagedPluginSnapshot(claimRef, packagedMarketplace, version, options.teamMode);
|
|
2125
|
+
await syncDirectoryTree(claimRef, durability);
|
|
2126
|
+
await publicationLockGuard.assertOwned();
|
|
2127
|
+
const claimDigest = await computeOmxPluginCacheClaimDigest(claimRef.path);
|
|
2128
|
+
await createExclusiveFileChild(claimRef, ".omx-complete", `${JSON.stringify({ version, claimDigest })}\n`, durability);
|
|
2129
|
+
const claimDirSyncOutcome = await syncDirectory(claimRef.handle);
|
|
2130
|
+
recordDirectorySyncOutcome(durability, claimDirSyncOutcome);
|
|
2131
|
+
await assertDirectoryRef(claimRef, "publication commit");
|
|
2132
|
+
finalRef = claimRef;
|
|
2133
|
+
const syncOutcome = await syncDirectory(cacheBaseRef.handle);
|
|
2134
|
+
recordDirectorySyncOutcome(durability, syncOutcome);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
catch (error) {
|
|
2138
|
+
if (error.code === "EEXIST") {
|
|
2139
|
+
outcome = {
|
|
2140
|
+
status: "stale-launcher",
|
|
2141
|
+
cacheDir,
|
|
2142
|
+
version,
|
|
2143
|
+
reason: `same-version OMX plugin cache appeared concurrently at ${cacheDir}; refusing to replace an immutable cache`,
|
|
2144
|
+
launcherTarget: undefined,
|
|
2145
|
+
retiredDirs: [],
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
else {
|
|
2149
|
+
throw error;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
if (outcome.status === "materialized") {
|
|
2153
|
+
try {
|
|
2154
|
+
await publicationLockGuard.assertOwned();
|
|
2155
|
+
const preservedDirs = [];
|
|
2156
|
+
outcome.retiredDirs = await retireUnpinnedManagedSnapshots(codexHomeDir, version, cacheBaseRef, true, preservedDirs, publicationLockGuard);
|
|
2157
|
+
if (preservedDirs.length > 0)
|
|
2158
|
+
outcome.preservedDirs = preservedDirs;
|
|
2159
|
+
}
|
|
2160
|
+
catch (error) {
|
|
2161
|
+
outcome = {
|
|
2162
|
+
status: "stale-launcher",
|
|
2163
|
+
cacheDir,
|
|
2164
|
+
version,
|
|
2165
|
+
reason: `immutable OMX plugin cache retirement failed closed: ${error.message}`,
|
|
2166
|
+
launcherTarget: undefined,
|
|
2167
|
+
retiredDirs: [],
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
catch (error) {
|
|
2173
|
+
outcome = {
|
|
2174
|
+
status: "stale-launcher",
|
|
2175
|
+
cacheDir,
|
|
2176
|
+
version,
|
|
2177
|
+
reason: `immutable OMX plugin cache publication failed closed: ${error.message}`,
|
|
2178
|
+
launcherTarget: undefined,
|
|
2179
|
+
retiredDirs: [],
|
|
2180
|
+
};
|
|
333
2181
|
}
|
|
334
2182
|
finally {
|
|
335
|
-
|
|
2183
|
+
if (publicationLockGuard)
|
|
2184
|
+
clearInterval(publicationLockGuard.timer);
|
|
2185
|
+
try {
|
|
2186
|
+
if (finalRef)
|
|
2187
|
+
await finalRef.handle.close();
|
|
2188
|
+
}
|
|
2189
|
+
catch (error) {
|
|
2190
|
+
cleanupError ??= error;
|
|
2191
|
+
}
|
|
2192
|
+
if (claimRef && claimRef !== finalRef) {
|
|
2193
|
+
try {
|
|
2194
|
+
await claimRef.handle.close();
|
|
2195
|
+
}
|
|
2196
|
+
catch (error) {
|
|
2197
|
+
cleanupError ??= error;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
try {
|
|
2201
|
+
if (snapshotRef)
|
|
2202
|
+
await snapshotRef.handle.close();
|
|
2203
|
+
}
|
|
2204
|
+
catch (error) {
|
|
2205
|
+
cleanupError ??= error;
|
|
2206
|
+
}
|
|
2207
|
+
try {
|
|
2208
|
+
if (tempRef)
|
|
2209
|
+
await tempRef.handle.close();
|
|
2210
|
+
}
|
|
2211
|
+
catch (error) {
|
|
2212
|
+
cleanupError ??= error;
|
|
2213
|
+
}
|
|
2214
|
+
try {
|
|
2215
|
+
if (tempName)
|
|
2216
|
+
await removeChild(cacheBaseRef, tempName, { recursive: true, force: true });
|
|
2217
|
+
}
|
|
2218
|
+
catch (error) {
|
|
2219
|
+
cleanupError = error;
|
|
2220
|
+
}
|
|
2221
|
+
try {
|
|
2222
|
+
await lockHandle.close();
|
|
2223
|
+
}
|
|
2224
|
+
catch (error) {
|
|
2225
|
+
cleanupError ??= error;
|
|
2226
|
+
}
|
|
2227
|
+
try {
|
|
2228
|
+
if (!lockIdentity)
|
|
2229
|
+
throw new Error(`publication lock identity unavailable at ${cacheBase}`);
|
|
2230
|
+
// Own-lock release must not quarantine a successor: only remove
|
|
2231
|
+
// if the file on disk still has the dev/ino we created.
|
|
2232
|
+
const cur = await lstat(join(cacheBaseRef.path, ".omx-publish.lock")).catch(() => null);
|
|
2233
|
+
if (cur &&
|
|
2234
|
+
cur.isFile() &&
|
|
2235
|
+
!cur.isSymbolicLink() &&
|
|
2236
|
+
cur.dev === lockIdentity.dev &&
|
|
2237
|
+
cur.ino === lockIdentity.ino) {
|
|
2238
|
+
await reclaimStalePublicationLock(cacheBaseRef, ".omx-publish.lock", lockIdentity, false);
|
|
2239
|
+
}
|
|
2240
|
+
const syncOutcome = await syncDirectory(cacheBaseRef.handle);
|
|
2241
|
+
recordDirectorySyncOutcome(durability, syncOutcome);
|
|
2242
|
+
}
|
|
2243
|
+
catch (error) {
|
|
2244
|
+
cleanupError ??= error;
|
|
2245
|
+
}
|
|
2246
|
+
if (ownsCacheBaseRef) {
|
|
2247
|
+
try {
|
|
2248
|
+
await cacheBaseRef.handle.close();
|
|
2249
|
+
}
|
|
2250
|
+
catch (error) {
|
|
2251
|
+
cleanupError ??= error;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
336
2254
|
}
|
|
2255
|
+
emitDegradedDurabilityWarning("plugin cache publication", durability);
|
|
2256
|
+
if (cleanupError) {
|
|
2257
|
+
return {
|
|
2258
|
+
status: "stale-launcher",
|
|
2259
|
+
cacheDir,
|
|
2260
|
+
version,
|
|
2261
|
+
reason: `immutable OMX plugin cache publication cleanup failed closed: ${cleanupError.message}`,
|
|
2262
|
+
launcherTarget: undefined,
|
|
2263
|
+
retiredDirs: [],
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2266
|
+
return outcome;
|
|
337
2267
|
}
|
|
338
2268
|
return {
|
|
339
2269
|
status: "materialized",
|
|
@@ -341,9 +2271,96 @@ export async function materializePackagedOmxPluginCache(codexHomeDir, packagedMa
|
|
|
341
2271
|
version,
|
|
342
2272
|
retiredDirs: options.dryRun
|
|
343
2273
|
? []
|
|
344
|
-
: await retireUnpinnedManagedSnapshots(
|
|
2274
|
+
: await retireUnpinnedManagedSnapshots(codexHomeDir, version, options.anchoredCacheBaseRef),
|
|
345
2275
|
};
|
|
346
2276
|
}
|
|
2277
|
+
export async function materializePackagedOmxPluginCache(codexHomeDir, packagedMarketplace, options = {}) {
|
|
2278
|
+
if (!packagedMarketplace)
|
|
2279
|
+
return { status: "unavailable" };
|
|
2280
|
+
const version = await packagedOmxPluginVersion(packagedMarketplace);
|
|
2281
|
+
if (!version)
|
|
2282
|
+
return { status: "unavailable" };
|
|
2283
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
2284
|
+
const cacheDir = join(cacheBase, version);
|
|
2285
|
+
const noFollowFlags = fsConstants.O_NOFOLLOW;
|
|
2286
|
+
const directoryFlags = fsConstants.O_DIRECTORY;
|
|
2287
|
+
if (process.platform !== "win32" && (typeof noFollowFlags !== "number" || typeof directoryFlags !== "number")) {
|
|
2288
|
+
return {
|
|
2289
|
+
status: "stale-launcher",
|
|
2290
|
+
cacheDir,
|
|
2291
|
+
version,
|
|
2292
|
+
reason: "platform cannot provide no-follow directory anchoring for immutable cache validation/publication",
|
|
2293
|
+
launcherTarget: undefined,
|
|
2294
|
+
retiredDirs: [],
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
let namespace;
|
|
2298
|
+
try {
|
|
2299
|
+
namespace = await openManagedCacheNamespace(cacheBase, codexHomeDir, { create: !options.dryRun });
|
|
2300
|
+
}
|
|
2301
|
+
catch (error) {
|
|
2302
|
+
const code = error.code;
|
|
2303
|
+
if (code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP") {
|
|
2304
|
+
return {
|
|
2305
|
+
status: "stale-launcher",
|
|
2306
|
+
cacheDir,
|
|
2307
|
+
version,
|
|
2308
|
+
reason: `cannot anchor OMX plugin cache namespace at ${cacheBase}: ${code}; refusing to trust or publish cache contents`,
|
|
2309
|
+
launcherTarget: undefined,
|
|
2310
|
+
retiredDirs: [],
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
throw error;
|
|
2314
|
+
}
|
|
2315
|
+
if (!namespace) {
|
|
2316
|
+
return { status: "materialized", cacheDir, version, retiredDirs: [] };
|
|
2317
|
+
}
|
|
2318
|
+
const cacheBaseFdPath = namespace.fdPath;
|
|
2319
|
+
let result;
|
|
2320
|
+
try {
|
|
2321
|
+
result = await materializePackagedOmxPluginCacheImpl(codexHomeDir, packagedMarketplace, {
|
|
2322
|
+
...options,
|
|
2323
|
+
anchoredCacheBaseRef: {
|
|
2324
|
+
handle: namespace.handle,
|
|
2325
|
+
path: namespace.path,
|
|
2326
|
+
operationPath: cacheBaseFdPath,
|
|
2327
|
+
scanOperationPath: cacheBaseFdPath,
|
|
2328
|
+
mutationPath: cacheBaseFdPath,
|
|
2329
|
+
},
|
|
2330
|
+
anchoredCacheDir: join(namespace.fdPath, version),
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
catch (error) {
|
|
2334
|
+
result = {
|
|
2335
|
+
status: "stale-launcher",
|
|
2336
|
+
cacheDir,
|
|
2337
|
+
version,
|
|
2338
|
+
reason: `anchored OMX plugin cache operation failed closed: ${error.message}`,
|
|
2339
|
+
launcherTarget: undefined,
|
|
2340
|
+
retiredDirs: [],
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
let closeError = null;
|
|
2344
|
+
for (const handle of namespace.handles.reverse()) {
|
|
2345
|
+
try {
|
|
2346
|
+
await handle.close();
|
|
2347
|
+
}
|
|
2348
|
+
catch (error) {
|
|
2349
|
+
closeError ??= error;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
if (closeError) {
|
|
2353
|
+
return {
|
|
2354
|
+
status: "stale-launcher",
|
|
2355
|
+
cacheDir,
|
|
2356
|
+
version,
|
|
2357
|
+
reason: `anchored OMX plugin cache descriptor close failed closed: ${closeError.message}`,
|
|
2358
|
+
launcherTarget: undefined,
|
|
2359
|
+
retiredDirs: [],
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
return result;
|
|
2363
|
+
}
|
|
347
2364
|
function marketplaceTableHeaderPattern() {
|
|
348
2365
|
return new RegExp(`^\\s*\\[marketplaces\\.${OMX_LOCAL_MARKETPLACE_NAME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]\\s*$`);
|
|
349
2366
|
}
|