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
|
@@ -0,0 +1,1662 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { cp, lstat, link, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
import { PLUGIN_LAUNCHER_RECOVERY_HINT, hasExpectedOmxPluginCache, materializePackagedOmxPluginCache, readOmxPluginCacheFileNoFollow, omxPluginCacheProvenanceReason, omxPluginCacheExecutedAssetProvenanceReason, omxPluginCacheBase, pluginHookCacheMatchesPackaged, packagedOmxPluginVersion, readOmxPluginCacheState, discoverOmxPluginCacheDirs, computeOmxPluginCacheClaimDigest, resolvePackagedOmxMarketplace, setPluginCacheMutationHooksForTest, } from "../plugin-marketplace.js";
|
|
8
|
+
const packageRoot = process.cwd();
|
|
9
|
+
function parseLastPublicationLockRecord(bytes) {
|
|
10
|
+
const lines = bytes.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
11
|
+
return JSON.parse(lines.at(-1) ?? "");
|
|
12
|
+
}
|
|
13
|
+
async function withIsolatedUserHome(wd, fn) {
|
|
14
|
+
const home = join(wd, "home");
|
|
15
|
+
await mkdir(home, { recursive: true });
|
|
16
|
+
const previousHome = process.env.CODEX_HOME;
|
|
17
|
+
process.env.CODEX_HOME = home;
|
|
18
|
+
try {
|
|
19
|
+
return await fn(home);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
if (previousHome === undefined)
|
|
23
|
+
delete process.env.CODEX_HOME;
|
|
24
|
+
else
|
|
25
|
+
process.env.CODEX_HOME = previousHome;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function withPlatform(platform, fn) {
|
|
29
|
+
const original = process.platform;
|
|
30
|
+
Object.defineProperty(process, "platform", { configurable: true, value: platform });
|
|
31
|
+
try {
|
|
32
|
+
return await fn();
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
Object.defineProperty(process, "platform", { configurable: true, value: original });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function packagedPluginVersion() {
|
|
39
|
+
const manifest = JSON.parse(await readFile(join(packageRoot, "plugins", "oh-my-codex", ".codex-plugin", "plugin.json"), "utf-8"));
|
|
40
|
+
return manifest.version;
|
|
41
|
+
}
|
|
42
|
+
async function expectedPackagedSkillNames() {
|
|
43
|
+
const entries = await readdir(join(packageRoot, "plugins", "oh-my-codex", "skills"), { withFileTypes: true });
|
|
44
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
45
|
+
}
|
|
46
|
+
async function pinnedLauncherContent() {
|
|
47
|
+
return `${JSON.stringify({
|
|
48
|
+
command: process.execPath,
|
|
49
|
+
argsPrefix: [join(packageRoot, "dist", "cli", "omx.js")],
|
|
50
|
+
}, null, 2)}\n`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Seeds a byte-identical regular cache snapshot for the current packageRoot,
|
|
54
|
+
* exactly like `stageCompletePluginSnapshot` + materialize would produce.
|
|
55
|
+
*/
|
|
56
|
+
async function seedRegularSnapshot(codexHomeDir) {
|
|
57
|
+
const version = await packagedPluginVersion();
|
|
58
|
+
const cacheDir = join(codexHomeDir, "plugins", "cache", "oh-my-codex-local", "oh-my-codex", version);
|
|
59
|
+
await mkdir(dirname(cacheDir), { recursive: true });
|
|
60
|
+
await cp(join(packageRoot, "plugins", "oh-my-codex"), cacheDir, {
|
|
61
|
+
recursive: true,
|
|
62
|
+
});
|
|
63
|
+
await writeFile(join(cacheDir, "hooks", "omx-command.json"), await pinnedLauncherContent());
|
|
64
|
+
const claimDigest = await computeOmxPluginCacheClaimDigest(cacheDir);
|
|
65
|
+
await writeFile(join(cacheDir, ".omx-complete"), `${JSON.stringify({ version, claimDigest })}\n`);
|
|
66
|
+
return cacheDir;
|
|
67
|
+
}
|
|
68
|
+
describe("issue 3552 P1 symlink trust bypass in unchanged fast paths", () => {
|
|
69
|
+
it("rejects unsafe packaged versions before constructing cache paths", async () => {
|
|
70
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-unsafe-version-"));
|
|
71
|
+
try {
|
|
72
|
+
const manifestPath = join(wd, "plugin.json");
|
|
73
|
+
await writeFile(manifestPath, JSON.stringify({ version: "../escape" }));
|
|
74
|
+
const packaged = {
|
|
75
|
+
marketplacePath: join(wd, "marketplace.json"),
|
|
76
|
+
packageRoot,
|
|
77
|
+
pluginRoot: packageRoot,
|
|
78
|
+
pluginManifestPath: manifestPath,
|
|
79
|
+
};
|
|
80
|
+
assert.equal(await packagedOmxPluginVersion(packaged), null);
|
|
81
|
+
const result = await materializePackagedOmxPluginCache(join(wd, "codex"), packaged);
|
|
82
|
+
assert.equal(result.status, "unavailable", JSON.stringify(result));
|
|
83
|
+
assert.equal(existsSync(join(wd, "codex")), false);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await rm(wd, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
it("does not publish a snapshot missing a required plugin surface", async () => {
|
|
90
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-incomplete-snapshot-"));
|
|
91
|
+
try {
|
|
92
|
+
const fakePluginRoot = join(wd, "plugin");
|
|
93
|
+
await cp(join(packageRoot, "plugins", "oh-my-codex"), fakePluginRoot, { recursive: true });
|
|
94
|
+
await rm(join(fakePluginRoot, ".mcp.json"), { force: true });
|
|
95
|
+
const packaged = {
|
|
96
|
+
marketplacePath: join(wd, "marketplace.json"),
|
|
97
|
+
packageRoot,
|
|
98
|
+
pluginRoot: fakePluginRoot,
|
|
99
|
+
pluginManifestPath: join(fakePluginRoot, ".codex-plugin", "plugin.json"),
|
|
100
|
+
};
|
|
101
|
+
const codexHomeDir = join(wd, "codex");
|
|
102
|
+
const version = await packagedPluginVersion();
|
|
103
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
104
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
105
|
+
assert.match(result.reason ?? "", /required surface|companion file/);
|
|
106
|
+
assert.equal(existsSync(join(omxPluginCacheBase(codexHomeDir), version)), false);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await rm(wd, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
it("keeps Darwin publication on identity-gated visible-path mutations", async () => {
|
|
113
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-darwin-publication-"));
|
|
114
|
+
try {
|
|
115
|
+
await withPlatform("darwin", async () => {
|
|
116
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
117
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
118
|
+
assert.ok(packaged);
|
|
119
|
+
const first = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
120
|
+
assert.equal(first.status, "materialized", JSON.stringify(first));
|
|
121
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), true);
|
|
122
|
+
// Mutations stay reachable (no blanket ENOTSUP) because every one is
|
|
123
|
+
// exclusive-create or inode-gated through the validated anchor.
|
|
124
|
+
assert.equal(existsSync(omxPluginCacheBase(codexHomeDir)), true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await rm(wd, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
it("keeps the Windows reparse-safe publication path available", async () => {
|
|
133
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-win32-publication-"));
|
|
134
|
+
try {
|
|
135
|
+
await withPlatform("win32", async () => {
|
|
136
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
137
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
138
|
+
assert.ok(packaged);
|
|
139
|
+
const first = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
140
|
+
if (process.platform === "win32") {
|
|
141
|
+
assert.equal(first.status, "materialized", JSON.stringify(first));
|
|
142
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), true);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
assert.notEqual(first.status, "unavailable");
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
await rm(wd, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
it("control: regular immutable snapshot stays unchanged and hook-matching", async () => {
|
|
155
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-control-"));
|
|
156
|
+
try {
|
|
157
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
158
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
159
|
+
assert.ok(packaged);
|
|
160
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
161
|
+
assert.equal((await lstat(cacheDir)).isSymbolicLink(), false);
|
|
162
|
+
assert.equal(await omxPluginCacheExecutedAssetProvenanceReason(cacheDir), null);
|
|
163
|
+
assert.equal(await pluginHookCacheMatchesPackaged(cacheDir, packaged), true);
|
|
164
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), true);
|
|
165
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
166
|
+
assert.equal(r.status, "unchanged");
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await rm(wd, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
it("fails closed when the committed publication marker is removed", async () => {
|
|
174
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-marker-loss-"));
|
|
175
|
+
try {
|
|
176
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
177
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
178
|
+
assert.ok(packaged);
|
|
179
|
+
const first = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
180
|
+
assert.equal(first.status, "materialized");
|
|
181
|
+
await rm(join(first.cacheDir, ".omx-complete"), { force: true });
|
|
182
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
183
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
184
|
+
assert.equal(result.status, "stale-launcher");
|
|
185
|
+
assert.match(result.reason ?? "", /managed snapshots|publication marker|codex plugin remove/);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
await rm(wd, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
it("fails closed on malformed completion markers and ignores the mutable live pin", async () => {
|
|
193
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-marker-integrity-"));
|
|
194
|
+
try {
|
|
195
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
196
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
197
|
+
assert.ok(packaged);
|
|
198
|
+
const first = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
199
|
+
assert.equal(first.status, "materialized");
|
|
200
|
+
const completePath = join(first.cacheDir, ".omx-complete");
|
|
201
|
+
for (const marker of ["{\n", "not-json\n"]) {
|
|
202
|
+
await writeFile(completePath, marker);
|
|
203
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false, marker);
|
|
204
|
+
}
|
|
205
|
+
const second = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
206
|
+
assert.equal(second.status, "stale-launcher");
|
|
207
|
+
const clean = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
208
|
+
assert.equal(clean.status, "stale-launcher");
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await rm(wd, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
it("does not invalidate a healthy claim when a live pin is added", async () => {
|
|
216
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-live-pin-digest-"));
|
|
217
|
+
try {
|
|
218
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
219
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
220
|
+
assert.ok(packaged);
|
|
221
|
+
const first = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
222
|
+
assert.equal(first.status, "materialized");
|
|
223
|
+
await writeFile(join(first.cacheDir, ".omx-live-pin"), "pinned\n");
|
|
224
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), true);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await rm(wd, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
it("rejects extra skill directories from immutable provenance", async () => {
|
|
232
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-extra-skill-"));
|
|
233
|
+
try {
|
|
234
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
235
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
236
|
+
assert.ok(packaged);
|
|
237
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
238
|
+
await mkdir(join(cacheDir, "skills", "attacker"), { recursive: true });
|
|
239
|
+
await writeFile(join(cacheDir, "skills", "attacker", "SKILL.md"), "attacker\n");
|
|
240
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
241
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
242
|
+
assert.equal(result.status, "stale-launcher");
|
|
243
|
+
assert.match(result.reason ?? "", /skills directory contents differ/);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await rm(wd, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
it("fails closed at the state boundary for missing managed assets", async () => {
|
|
251
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-state-boundary-"));
|
|
252
|
+
try {
|
|
253
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
254
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
255
|
+
await rm(join(cacheDir, "hooks", "hooks.json"), { force: true });
|
|
256
|
+
assert.equal(await readOmxPluginCacheState(cacheDir), null);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
await rm(wd, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
it("fails closed when state surfaces have invalid pointers, companions, skills, or launcher", async () => {
|
|
264
|
+
const cases = [
|
|
265
|
+
["pointer", async (cacheDir) => {
|
|
266
|
+
const manifestPath = join(cacheDir, ".codex-plugin", "plugin.json");
|
|
267
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
268
|
+
await writeFile(manifestPath, JSON.stringify({ ...manifest, skills: "./attacker-skills/" }));
|
|
269
|
+
}],
|
|
270
|
+
["companion", async (cacheDir) => {
|
|
271
|
+
await writeFile(join(cacheDir, ".mcp.json"), "not-json\n");
|
|
272
|
+
}],
|
|
273
|
+
["skill", async (cacheDir) => {
|
|
274
|
+
await rm(join(cacheDir, "skills", "worker", "SKILL.md"), { force: true });
|
|
275
|
+
}],
|
|
276
|
+
["launcher", async (cacheDir) => {
|
|
277
|
+
await writeFile(join(cacheDir, "hooks", "omx-command.json"), "{}\n");
|
|
278
|
+
}],
|
|
279
|
+
];
|
|
280
|
+
for (const [label, mutate] of cases) {
|
|
281
|
+
const wd = await mkdtemp(join(tmpdir(), `omx-3552-state-${label}-`));
|
|
282
|
+
try {
|
|
283
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
284
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
285
|
+
await mutate(cacheDir);
|
|
286
|
+
assert.equal(await readOmxPluginCacheState(cacheDir), null, `${label} state failure was trusted`);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
await rm(wd, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
it("rejects an anchored parent replacement before reading a managed asset", async () => {
|
|
295
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-parent-barrier-"));
|
|
296
|
+
try {
|
|
297
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
298
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
299
|
+
const hooksDir = join(cacheDir, "hooks");
|
|
300
|
+
const externalDir = join(wd, "external-hooks");
|
|
301
|
+
await rename(hooksDir, externalDir);
|
|
302
|
+
await symlink(externalDir, hooksDir);
|
|
303
|
+
const bytes = await readOmxPluginCacheFileNoFollow(join(cacheDir, "hooks", "hooks.json"), { anchorDir: cacheDir });
|
|
304
|
+
assert.equal(bytes, null);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
await rm(wd, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
it("rejects a symlinked intermediate skills directory during descriptor-bound reads", async () => {
|
|
312
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-intermediate-skills-symlink-"));
|
|
313
|
+
try {
|
|
314
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
315
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
316
|
+
const skillsDir = join(cacheDir, "skills");
|
|
317
|
+
const externalSkills = join(wd, "external-skills");
|
|
318
|
+
await rename(skillsDir, externalSkills);
|
|
319
|
+
await symlink(externalSkills, skillsDir);
|
|
320
|
+
assert.equal(await readOmxPluginCacheFileNoFollow(join(cacheDir, "skills", "worker", "SKILL.md"), { anchorDir: cacheDir }), null);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
await rm(wd, { recursive: true, force: true });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
it("rejects an interrupted publication without a committed marker", async () => {
|
|
328
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-publication-interrupt-"));
|
|
329
|
+
try {
|
|
330
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
331
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
332
|
+
assert.ok(packaged);
|
|
333
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
334
|
+
await rm(join(cacheDir, ".omx-complete"), { force: true });
|
|
335
|
+
await writeFile(join(cacheDir, ".omx-incomplete"), "interrupted\n");
|
|
336
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
337
|
+
assert.equal(await readOmxPluginCacheState(cacheDir), null);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
await rm(wd, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
it("does not discover a manifest without a committed publication marker", async () => {
|
|
345
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-discovery-marker-"));
|
|
346
|
+
try {
|
|
347
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
348
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
349
|
+
await rm(join(cacheDir, ".omx-complete"), { force: true });
|
|
350
|
+
assert.deepEqual(await discoverOmxPluginCacheDirs(codexHomeDir), []);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
await rm(wd, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
it("symlinked current-version cache root is rejected: no unchanged, no external mutation, stale-launcher with recovery hint", async () => {
|
|
358
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-root-symlink-"));
|
|
359
|
+
try {
|
|
360
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
361
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
362
|
+
assert.ok(packaged);
|
|
363
|
+
const realCache = await seedRegularSnapshot(codexHomeDir);
|
|
364
|
+
// Attacker relocates the snapshot outside the managed namespace and
|
|
365
|
+
// replaces the <version> root with a symlink to it.
|
|
366
|
+
const externalTarget = join(wd, "external", "snapshot");
|
|
367
|
+
await mkdir(dirname(externalTarget), { recursive: true });
|
|
368
|
+
await rename(realCache, externalTarget);
|
|
369
|
+
await symlink(externalTarget, realCache);
|
|
370
|
+
assert.equal((await lstat(realCache)).isSymbolicLink(), true);
|
|
371
|
+
// Fast path must refuse the symlinked root instead of reading through it.
|
|
372
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false, "symlinked cache root must not satisfy hasExpectedOmxPluginCache");
|
|
373
|
+
assert.equal(await readOmxPluginCacheState(realCache), null, "cache state must not be read through a symlinked root");
|
|
374
|
+
assert.equal(await pluginHookCacheMatchesPackaged(realCache, packaged), false, "hook assets behind a symlinked root must not match");
|
|
375
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
376
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
377
|
+
assert.match(r.reason, /symlink or non-directory/);
|
|
378
|
+
assert.match(r.reason, new RegExp(PLUGIN_LAUNCHER_RECOVERY_HINT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
379
|
+
// Non-mutation proof: the external target stays exactly where the attacker
|
|
380
|
+
// put it (setup neither followed nor rewrote it), and the external copy is
|
|
381
|
+
// still attacker-writable — which is precisely why it can never be trusted.
|
|
382
|
+
const sentinel = await readFile(join(externalTarget, "hooks", "hooks.json"), "utf-8");
|
|
383
|
+
const packagedHook = await readFile(join(packageRoot, "plugins", "oh-my-codex", "hooks", "hooks.json"), "utf-8");
|
|
384
|
+
assert.equal(sentinel, packagedHook, "external target untouched by setup");
|
|
385
|
+
await writeFile(join(externalTarget, "hooks", "hooks.json"), "// attacker mutation\n");
|
|
386
|
+
const mutatedExternal = await readFile(join(realCache, "hooks", "hooks.json"), "utf-8");
|
|
387
|
+
assert.equal(mutatedExternal, "// attacker mutation\n", "proof the pre-fix trust boundary would have followed the external target");
|
|
388
|
+
assert.equal(await pluginHookCacheMatchesPackaged(realCache, packaged), false, "mutated external target must still never match after the mutation");
|
|
389
|
+
assert.equal(existsSync(realCache), true);
|
|
390
|
+
assert.equal((await lstat(realCache)).isSymbolicLink(), true);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
await rm(wd, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
const assetCases = [
|
|
398
|
+
["hooks.json", false],
|
|
399
|
+
["codex-native-hook.mjs", false],
|
|
400
|
+
["omx-command.json", true],
|
|
401
|
+
];
|
|
402
|
+
for (const [asset, isLauncher] of assetCases) {
|
|
403
|
+
it(`symlinked hooks/${asset} with byte-identical external content is rejected (unchanged fast path + materializer)`, async () => {
|
|
404
|
+
const wd = await mkdtemp(join(tmpdir(), `omx-3552-asset-${asset.replace(/[^a-z]/g, "")}-`));
|
|
405
|
+
try {
|
|
406
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
407
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
408
|
+
assert.ok(packaged);
|
|
409
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
410
|
+
const assetPath = join(cacheDir, "hooks", asset);
|
|
411
|
+
const external = join(wd, "external", asset);
|
|
412
|
+
await mkdir(dirname(external), { recursive: true });
|
|
413
|
+
// Byte-identical content moved outside the managed namespace.
|
|
414
|
+
await rename(assetPath, external);
|
|
415
|
+
await symlink(external, assetPath);
|
|
416
|
+
assert.equal((await lstat(assetPath)).isSymbolicLink(), true);
|
|
417
|
+
assert.equal((await readFile(assetPath, "utf-8")) === (await readFile(external, "utf-8")), true, "external target is byte-identical (the pre-fix trust bypass condition)");
|
|
418
|
+
const reason = await omxPluginCacheExecutedAssetProvenanceReason(cacheDir);
|
|
419
|
+
assert.ok(reason, `${asset} symlink must produce a provenance reason`);
|
|
420
|
+
assert.match(reason, new RegExp(asset.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
421
|
+
assert.match(reason, /symlink or not a regular file/);
|
|
422
|
+
assert.equal(await pluginHookCacheMatchesPackaged(cacheDir, packaged), false, `symlinked ${asset} must not satisfy hook cache matching`);
|
|
423
|
+
if (!isLauncher) {
|
|
424
|
+
// hooks.json / codex-native-hook.mjs mismatches flip hasExpectedOmxPluginCache
|
|
425
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false, `symlinked ${asset} must not satisfy hasExpectedOmxPluginCache`);
|
|
426
|
+
}
|
|
427
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
428
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
429
|
+
assert.match(r.reason, /symlink or not a regular file/);
|
|
430
|
+
assert.match(r.reason, new RegExp(PLUGIN_LAUNCHER_RECOVERY_HINT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
431
|
+
// Non-mutation proof: the symlink and the external target both survive
|
|
432
|
+
// the materializer run untouched (#3499 immutability preserved), and a
|
|
433
|
+
// later external mutation never flips the verdict back to trusted.
|
|
434
|
+
assert.equal((await lstat(assetPath)).isSymbolicLink(), true);
|
|
435
|
+
await writeFile(external, "// attacker mutation\n");
|
|
436
|
+
const r2 = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
437
|
+
assert.equal(r2.status, "stale-launcher", "mutated external target stays rejected");
|
|
438
|
+
assert.equal((await readFile(assetPath, "utf-8")), "// attacker mutation\n", "reads through the symlink follow the attacker target (why it is untrusted)");
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
await rm(wd, { recursive: true, force: true });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
it("symlinked hooks directory itself is rejected before any executed-asset read", async () => {
|
|
447
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-hooks-dir-symlink-"));
|
|
448
|
+
try {
|
|
449
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
450
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
451
|
+
assert.ok(packaged);
|
|
452
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
453
|
+
const external = join(wd, "external", "hooks");
|
|
454
|
+
await mkdir(dirname(external), { recursive: true });
|
|
455
|
+
await rename(join(cacheDir, "hooks"), external);
|
|
456
|
+
await symlink(external, join(cacheDir, "hooks"));
|
|
457
|
+
assert.equal((await lstat(join(cacheDir, "hooks"))).isSymbolicLink(), true);
|
|
458
|
+
const reason = await omxPluginCacheExecutedAssetProvenanceReason(cacheDir);
|
|
459
|
+
assert.ok(reason);
|
|
460
|
+
assert.match(reason, /symlink or not a directory/);
|
|
461
|
+
assert.equal(await pluginHookCacheMatchesPackaged(cacheDir, packaged), false);
|
|
462
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
463
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
464
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
465
|
+
assert.match(r.reason, /symlink or not a (regular file|directory)/);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
await rm(wd, { recursive: true, force: true });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
it("non-regular executed asset (FIFO replaced by directory) is rejected fail-closed", async () => {
|
|
473
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-nonregular-"));
|
|
474
|
+
try {
|
|
475
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
476
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
477
|
+
assert.ok(packaged);
|
|
478
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
479
|
+
await rm(join(cacheDir, "hooks", "hooks.json"), { force: true });
|
|
480
|
+
await mkdir(join(cacheDir, "hooks", "hooks.json"));
|
|
481
|
+
const reason = await omxPluginCacheExecutedAssetProvenanceReason(cacheDir);
|
|
482
|
+
assert.ok(reason);
|
|
483
|
+
assert.match(reason, /symlink or not a regular file/);
|
|
484
|
+
assert.equal(await pluginHookCacheMatchesPackaged(cacheDir, packaged), false);
|
|
485
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
486
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
await rm(wd, { recursive: true, force: true });
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
it("manifest pointer drift is rejected before an existing snapshot can return unchanged", async () => {
|
|
494
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-manifest-drift-"));
|
|
495
|
+
try {
|
|
496
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
497
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
498
|
+
assert.ok(packaged);
|
|
499
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
500
|
+
const manifestPath = join(cacheDir, ".codex-plugin", "plugin.json");
|
|
501
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
502
|
+
await writeFile(manifestPath, JSON.stringify({ ...manifest, skills: "./attacker-skills/", hooks: "./attacker-hooks/hooks.json" }));
|
|
503
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
504
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
505
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
506
|
+
assert.match(r.reason, /manifest (skills|hooks) pointer/);
|
|
507
|
+
assert.match(r.reason, new RegExp(PLUGIN_LAUNCHER_RECOVERY_HINT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
508
|
+
assert.match(await readFile(manifestPath, "utf-8"), /attacker-skills/);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
await rm(wd, { recursive: true, force: true });
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
it("symlinked manifest is rejected even when its external content is canonical", async () => {
|
|
516
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-manifest-symlink-"));
|
|
517
|
+
try {
|
|
518
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
519
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
520
|
+
assert.ok(packaged);
|
|
521
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
522
|
+
const manifestPath = join(cacheDir, ".codex-plugin", "plugin.json");
|
|
523
|
+
const externalManifest = join(wd, "external", "plugin.json");
|
|
524
|
+
await mkdir(dirname(externalManifest), { recursive: true });
|
|
525
|
+
await rename(manifestPath, externalManifest);
|
|
526
|
+
await symlink(externalManifest, manifestPath);
|
|
527
|
+
assert.equal((await lstat(manifestPath)).isSymbolicLink(), true);
|
|
528
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
529
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
530
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
531
|
+
assert.match(r.reason, /plugin manifest .*symlink/);
|
|
532
|
+
assert.equal((await lstat(manifestPath)).isSymbolicLink(), true);
|
|
533
|
+
assert.equal(await readFile(externalManifest, "utf-8"), await readFile(join(packaged.pluginRoot, ".codex-plugin", "plugin.json"), "utf-8"));
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
await rm(wd, { recursive: true, force: true });
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
it("skills directory symlink with matching names and attacker content is rejected and preserved", async () => {
|
|
541
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-skills-symlink-"));
|
|
542
|
+
try {
|
|
543
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
544
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
545
|
+
assert.ok(packaged);
|
|
546
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
547
|
+
const skillsPath = join(cacheDir, "skills");
|
|
548
|
+
const externalSkills = join(wd, "external", "skills");
|
|
549
|
+
await mkdir(dirname(externalSkills), { recursive: true });
|
|
550
|
+
await rename(skillsPath, externalSkills);
|
|
551
|
+
await writeFile(join(externalSkills, "worker", "SKILL.md"), "# attacker-controlled skill\n");
|
|
552
|
+
await symlink(externalSkills, skillsPath);
|
|
553
|
+
assert.equal((await lstat(skillsPath)).isSymbolicLink(), true);
|
|
554
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
555
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
556
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
557
|
+
assert.match(r.reason, /skills directory .*symlink/);
|
|
558
|
+
assert.equal((await lstat(skillsPath)).isSymbolicLink(), true);
|
|
559
|
+
assert.equal(await readFile(join(externalSkills, "worker", "SKILL.md"), "utf-8"), "# attacker-controlled skill\n");
|
|
560
|
+
await writeFile(join(externalSkills, "worker", "SKILL.md"), "# attacker mutation\n");
|
|
561
|
+
const r2 = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
562
|
+
assert.equal(r2.status, "stale-launcher", JSON.stringify(r2));
|
|
563
|
+
assert.equal(await readFile(join(skillsPath, "worker", "SKILL.md"), "utf-8"), "# attacker mutation\n");
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
await rm(wd, { recursive: true, force: true });
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
it("regular attacker-mutated SKILL.md content is rejected before unchanged acceptance", async () => {
|
|
571
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-skill-content-"));
|
|
572
|
+
try {
|
|
573
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
574
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
575
|
+
assert.ok(packaged);
|
|
576
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
577
|
+
const skillPath = join(cacheDir, "skills", "worker", "SKILL.md");
|
|
578
|
+
const sentinel = "# attacker-mutated regular skill\n";
|
|
579
|
+
await writeFile(skillPath, sentinel);
|
|
580
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
581
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
582
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
583
|
+
assert.match(r.reason, /expected skill file content differs/);
|
|
584
|
+
assert.equal(await readFile(skillPath, "utf-8"), sentinel);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
finally {
|
|
588
|
+
await rm(wd, { recursive: true, force: true });
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
const companionCases = [".mcp.json", ".app.json"];
|
|
592
|
+
for (const companion of companionCases) {
|
|
593
|
+
it(`${companion} content drift is rejected before unchanged acceptance`, async () => {
|
|
594
|
+
const wd = await mkdtemp(join(tmpdir(), `omx-3552-${companion.slice(1)}-drift-`));
|
|
595
|
+
try {
|
|
596
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
597
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
598
|
+
assert.ok(packaged);
|
|
599
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
600
|
+
const cachedPath = join(cacheDir, companion);
|
|
601
|
+
const sentinel = `{"attacker":"${companion}"}\n`;
|
|
602
|
+
await writeFile(cachedPath, sentinel);
|
|
603
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
604
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
605
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
606
|
+
assert.match(r.reason, /companion file content differs/);
|
|
607
|
+
assert.equal(await readFile(cachedPath, "utf-8"), sentinel);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
finally {
|
|
611
|
+
await rm(wd, { recursive: true, force: true });
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
it(`${companion} symlink with canonical external content is rejected and preserved`, async () => {
|
|
615
|
+
const wd = await mkdtemp(join(tmpdir(), `omx-3552-${companion.slice(1)}-symlink-`));
|
|
616
|
+
try {
|
|
617
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
618
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
619
|
+
assert.ok(packaged);
|
|
620
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
621
|
+
const cachedPath = join(cacheDir, companion);
|
|
622
|
+
const externalPath = join(wd, "external", companion);
|
|
623
|
+
await mkdir(dirname(externalPath), { recursive: true });
|
|
624
|
+
await rename(cachedPath, externalPath);
|
|
625
|
+
await symlink(externalPath, cachedPath);
|
|
626
|
+
assert.equal((await lstat(cachedPath)).isSymbolicLink(), true);
|
|
627
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
628
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
629
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
630
|
+
assert.match(r.reason, /companion file .*symlink/);
|
|
631
|
+
assert.equal((await lstat(cachedPath)).isSymbolicLink(), true);
|
|
632
|
+
await writeFile(externalPath, "{\"attacker\":true}\n");
|
|
633
|
+
const r2 = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
634
|
+
assert.equal(r2.status, "stale-launcher", JSON.stringify(r2));
|
|
635
|
+
assert.equal(await readFile(cachedPath, "utf-8"), "{\"attacker\":true}\n");
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
finally {
|
|
639
|
+
await rm(wd, { recursive: true, force: true });
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
it(`${companion} non-regular entry is rejected fail-closed`, async () => {
|
|
643
|
+
const wd = await mkdtemp(join(tmpdir(), `omx-3552-${companion.slice(1)}-nonregular-`));
|
|
644
|
+
try {
|
|
645
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
646
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
647
|
+
assert.ok(packaged);
|
|
648
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
649
|
+
const cachedPath = join(cacheDir, companion);
|
|
650
|
+
await rm(cachedPath, { force: true });
|
|
651
|
+
await mkdir(cachedPath);
|
|
652
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), false);
|
|
653
|
+
const r = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
654
|
+
assert.equal(r.status, "stale-launcher", JSON.stringify(r));
|
|
655
|
+
assert.match(r.reason, /companion file .*not a regular file/);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
await rm(wd, { recursive: true, force: true });
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
it("rejects companion symlink swaps during descriptor-bound provenance validation", async () => {
|
|
664
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-companion-toctou-"));
|
|
665
|
+
try {
|
|
666
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
667
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
668
|
+
assert.ok(packaged);
|
|
669
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
670
|
+
for (const companion of companionCases) {
|
|
671
|
+
const cachedPath = join(cacheDir, companion);
|
|
672
|
+
const externalPath = join(wd, "external", companion);
|
|
673
|
+
const canonical = await readFile(cachedPath);
|
|
674
|
+
await mkdir(dirname(externalPath), { recursive: true });
|
|
675
|
+
await writeFile(externalPath, `{"attacker":"${companion}"}\n`);
|
|
676
|
+
for (let iteration = 0; iteration < 2000; iteration += 1) {
|
|
677
|
+
await rm(cachedPath, { force: true });
|
|
678
|
+
await symlink(externalPath, cachedPath);
|
|
679
|
+
const reason = await omxPluginCacheProvenanceReason(cacheDir, packaged);
|
|
680
|
+
assert.match(reason ?? "", /companion file .*symlink/);
|
|
681
|
+
await rm(cachedPath, { force: true });
|
|
682
|
+
await writeFile(cachedPath, canonical);
|
|
683
|
+
}
|
|
684
|
+
assert.equal(await readFile(externalPath, "utf-8"), `{"attacker":"${companion}"}\n`);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
finally {
|
|
689
|
+
await rm(wd, { recursive: true, force: true });
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
it("does not replace a same-version cache claimed concurrently", async () => {
|
|
693
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-cache-claim-race-"));
|
|
694
|
+
try {
|
|
695
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
696
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
697
|
+
assert.ok(packaged);
|
|
698
|
+
const results = await Promise.all([
|
|
699
|
+
materializePackagedOmxPluginCache(codexHomeDir, packaged),
|
|
700
|
+
materializePackagedOmxPluginCache(codexHomeDir, packaged),
|
|
701
|
+
]);
|
|
702
|
+
assert.ok(results.every((result) => result.status !== "unavailable"));
|
|
703
|
+
assert.ok(results.some((result) => result.status === "materialized" || result.status === "unchanged"));
|
|
704
|
+
const cacheDir = results.find((result) => result.cacheDir)?.cacheDir;
|
|
705
|
+
assert.ok(cacheDir);
|
|
706
|
+
assert.equal(existsSync(join(cacheDir, ".omx-incomplete")), false);
|
|
707
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), true);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
finally {
|
|
711
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
712
|
+
await rm(wd, { recursive: true, force: true });
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
it("refuses a same-version directory claimed at the publication barrier", async () => {
|
|
716
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-publication-barrier-"));
|
|
717
|
+
try {
|
|
718
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
719
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
720
|
+
assert.ok(packaged);
|
|
721
|
+
const version = await packagedPluginVersion();
|
|
722
|
+
const cacheDir = join(codexHomeDir, "plugins", "cache", "oh-my-codex-local", "oh-my-codex", version);
|
|
723
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged, {
|
|
724
|
+
onCacheDirPrepared: async (preparedCacheDir) => {
|
|
725
|
+
assert.equal(preparedCacheDir, cacheDir);
|
|
726
|
+
await mkdir(preparedCacheDir, { recursive: true });
|
|
727
|
+
await writeFile(join(preparedCacheDir, "attacker-sentinel"), "preserve\n");
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
731
|
+
assert.match(result.reason ?? "", /already exists|refusing to replace/);
|
|
732
|
+
assert.equal(await readFile(join(cacheDir, "attacker-sentinel"), "utf-8"), "preserve\n");
|
|
733
|
+
assert.equal(existsSync(join(cacheDir, ".omx-complete")), false);
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
finally {
|
|
737
|
+
await rm(wd, { recursive: true, force: true });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
it("fails closed on a foreign .omx-incomplete directory instead of reclaiming it", async () => {
|
|
741
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-foreign-incomplete-claim-"));
|
|
742
|
+
try {
|
|
743
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
744
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
745
|
+
assert.ok(packaged);
|
|
746
|
+
const version = await packagedPluginVersion();
|
|
747
|
+
const cacheDir = join(omxPluginCacheBase(codexHomeDir), version);
|
|
748
|
+
await mkdir(cacheDir, { recursive: true });
|
|
749
|
+
await writeFile(join(cacheDir, "foreign-sentinel"), "preserve\n");
|
|
750
|
+
await writeFile(join(cacheDir, ".omx-incomplete"), "crashed publisher\n");
|
|
751
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
752
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
753
|
+
assert.match(result.reason ?? "", /already exists|refusing to replace|symlink or non-directory/);
|
|
754
|
+
assert.equal(await readFile(join(cacheDir, "foreign-sentinel"), "utf-8"), "preserve\n");
|
|
755
|
+
assert.equal(existsSync(join(cacheDir, ".omx-incomplete")), true);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
finally {
|
|
759
|
+
await rm(wd, { recursive: true, force: true });
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
it("does not replace an EMPTY same-version directory claimed concurrently (no-replace publication)", async () => {
|
|
763
|
+
// Blocker 3: POSIX rename() silently replaces an empty directory, so the
|
|
764
|
+
// empty-destination case must be covered separately from the sentinel case.
|
|
765
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-empty-claim-barrier-"));
|
|
766
|
+
try {
|
|
767
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
768
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
769
|
+
assert.ok(packaged);
|
|
770
|
+
const version = await packagedPluginVersion();
|
|
771
|
+
const cacheDir = join(omxPluginCacheBase(codexHomeDir), version);
|
|
772
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged, {
|
|
773
|
+
onCacheDirPrepared: async (preparedCacheDir) => {
|
|
774
|
+
assert.equal(preparedCacheDir, cacheDir);
|
|
775
|
+
await mkdir(preparedCacheDir, { recursive: true });
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
779
|
+
assert.match(result.reason ?? "", /already exists|refusing to replace/);
|
|
780
|
+
// The claimant (empty directory) survives untouched.
|
|
781
|
+
assert.equal((await lstat(cacheDir)).isDirectory(), true);
|
|
782
|
+
assert.equal((await readdir(cacheDir)).length, 0);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
finally {
|
|
786
|
+
await rm(wd, { recursive: true, force: true });
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
it("does not clobber a newly claimed lock during stale-lock restore", async () => {
|
|
790
|
+
// Blocker 4: the restore path must use a no-clobber primitive (link) so a
|
|
791
|
+
// replacement lock acquired by another publisher is never overwritten.
|
|
792
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-lock-restore-noclobber-"));
|
|
793
|
+
try {
|
|
794
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
795
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
796
|
+
assert.ok(packaged);
|
|
797
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
798
|
+
const lockPath = join(cacheBase, ".omx-publish.lock");
|
|
799
|
+
await mkdir(cacheBase, { recursive: true });
|
|
800
|
+
// Seed a stale lock from a dead pid; another publisher concurrently
|
|
801
|
+
// re-claims the name after the reclaimer quarantines it.
|
|
802
|
+
await writeFile(lockPath, JSON.stringify({ pid: 99999999, createdAt: Date.now() - 60_000 }));
|
|
803
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged, {
|
|
804
|
+
onCacheDirPrepared: async () => {
|
|
805
|
+
// Simulate the restore-gap claimant: replace the (now removed)
|
|
806
|
+
// lock name with fresh content while publication holds staging.
|
|
807
|
+
const entries = await readdir(cacheBase);
|
|
808
|
+
if (!entries.includes(".omx-publish.lock")) {
|
|
809
|
+
await writeFile(lockPath, JSON.stringify({ pid: process.pid, createdAt: Date.now(), heartbeatAt: Date.now() }), { flag: "wx" });
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
// Either this publisher lost the race (stale-launcher) or it completed
|
|
814
|
+
// after removing its own lock; in both cases a live claimant's lock
|
|
815
|
+
// content must never be overwritten by the restore path.
|
|
816
|
+
const finalLock = existsSync(lockPath) ? await readFile(lockPath, "utf-8") : null;
|
|
817
|
+
if (finalLock !== null) {
|
|
818
|
+
const record = JSON.parse(finalLock);
|
|
819
|
+
assert.ok(record.pid, "surviving lock must keep its claimant record");
|
|
820
|
+
}
|
|
821
|
+
assert.ok(result.status === "materialized" || result.status === "stale-launcher");
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
finally {
|
|
825
|
+
await rm(wd, { recursive: true, force: true });
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
it("never discovers a marker-committed staging snapshot before publication", async () => {
|
|
829
|
+
// Blocker 5: discovery must exclude staging trees, so a snapshot that has
|
|
830
|
+
// its .omx-complete marker committed while still staged is invisible.
|
|
831
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-staging-invisible-"));
|
|
832
|
+
try {
|
|
833
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
834
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
835
|
+
assert.ok(packaged);
|
|
836
|
+
const version = await packagedPluginVersion();
|
|
837
|
+
let observedDuringStaging = false;
|
|
838
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged, {
|
|
839
|
+
onCacheDirPrepared: async () => {
|
|
840
|
+
const discovered = await discoverOmxPluginCacheDirs(codexHomeDir);
|
|
841
|
+
if (discovered.some((dir) => dir.includes(".omx-plugin-")))
|
|
842
|
+
observedDuringStaging = true;
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
assert.equal(result.status, "materialized", JSON.stringify(result));
|
|
846
|
+
assert.equal(observedDuringStaging, false, "discovery observed a staging path");
|
|
847
|
+
const published = await discoverOmxPluginCacheDirs(codexHomeDir);
|
|
848
|
+
assert.ok(published.some((dir) => dir === join(omxPluginCacheBase(codexHomeDir), version)));
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
finally {
|
|
852
|
+
await rm(wd, { recursive: true, force: true });
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
it("does not treat a PID-reused live owner as a stale lock while its lease is valid", async () => {
|
|
856
|
+
// Blocker 6: a crashed publisher's pid can be reused by a live process;
|
|
857
|
+
// the lock must remain held while the recorded lease is fresh.
|
|
858
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-pid-reuse-lease-"));
|
|
859
|
+
try {
|
|
860
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
861
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
862
|
+
assert.ok(packaged);
|
|
863
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
864
|
+
const lockPath = join(cacheBase, ".omx-publish.lock");
|
|
865
|
+
await mkdir(cacheBase, { recursive: true });
|
|
866
|
+
// Live process pid + fresh heartbeat: lock is held regardless of PID reuse.
|
|
867
|
+
await writeFile(lockPath, JSON.stringify({ pid: process.pid, createdAt: Date.now(), heartbeatAt: Date.now(), processToken: "other" }));
|
|
868
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
869
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
870
|
+
assert.match(result.reason ?? "", /another OMX plugin cache publication is active|cannot claim/);
|
|
871
|
+
assert.equal(existsSync(lockPath), true);
|
|
872
|
+
// An expired lease with a live-but-unrelated pid (PID reuse) is stale.
|
|
873
|
+
await writeFile(lockPath, JSON.stringify({ pid: process.pid, createdAt: Date.now() - 600_000, heartbeatAt: Date.now() - 600_000, processToken: "crashed" }));
|
|
874
|
+
const recovered = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
875
|
+
assert.equal(recovered.status, "materialized", JSON.stringify(recovered));
|
|
876
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), true);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
finally {
|
|
880
|
+
await rm(wd, { recursive: true, force: true });
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
it("recovers a publication lock owned by a dead process", async () => {
|
|
884
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-stale-publication-lock-"));
|
|
885
|
+
try {
|
|
886
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
887
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
888
|
+
assert.ok(packaged);
|
|
889
|
+
const cacheBase = join(codexHomeDir, "plugins", "cache", "oh-my-codex-local", "oh-my-codex");
|
|
890
|
+
const lockPath = join(cacheBase, ".omx-publish.lock");
|
|
891
|
+
await mkdir(cacheBase, { recursive: true });
|
|
892
|
+
await writeFile(lockPath, JSON.stringify({ pid: 99999999, createdAt: Date.now() - 60_000 }));
|
|
893
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
894
|
+
assert.equal(result.status, "materialized", JSON.stringify(result));
|
|
895
|
+
assert.equal(existsSync(lockPath), false);
|
|
896
|
+
assert.equal(await hasExpectedOmxPluginCache(codexHomeDir, packaged), true);
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
await rm(wd, { recursive: true, force: true });
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
it("keeps direct materialization dry-run read-only when the cache namespace is absent", async () => {
|
|
904
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-dry-run-read-only-"));
|
|
905
|
+
try {
|
|
906
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
907
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
908
|
+
assert.ok(packaged);
|
|
909
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged, { dryRun: true });
|
|
910
|
+
assert.equal(result.status, "materialized", JSON.stringify(result));
|
|
911
|
+
assert.equal(existsSync(join(codexHomeDir, "plugins")), false);
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
finally {
|
|
915
|
+
await rm(wd, { recursive: true, force: true });
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
it("does not accept attacker bytes after companion replacement", async () => {
|
|
919
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-companion-concurrent-"));
|
|
920
|
+
try {
|
|
921
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
922
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
923
|
+
assert.ok(packaged);
|
|
924
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
925
|
+
for (const companion of companionCases) {
|
|
926
|
+
const cachedPath = join(cacheDir, companion);
|
|
927
|
+
const externalPath = join(wd, "external", companion);
|
|
928
|
+
const canonical = await readFile(cachedPath);
|
|
929
|
+
await mkdir(dirname(externalPath), { recursive: true });
|
|
930
|
+
await writeFile(externalPath, canonical);
|
|
931
|
+
for (let iteration = 0; iteration < 2000; iteration += 1) {
|
|
932
|
+
await rm(cachedPath, { force: true });
|
|
933
|
+
await symlink(externalPath, cachedPath);
|
|
934
|
+
await writeFile(externalPath, `{"attacker":"${companion}"}\n`);
|
|
935
|
+
const rejected = await readOmxPluginCacheFileNoFollow(cachedPath);
|
|
936
|
+
assert.equal(rejected, null, `${companion} accepted attacker-controlled symlink bytes`);
|
|
937
|
+
await rm(cachedPath, { force: true });
|
|
938
|
+
await writeFile(cachedPath, canonical);
|
|
939
|
+
await writeFile(externalPath, canonical);
|
|
940
|
+
}
|
|
941
|
+
assert.equal((await readFile(externalPath)).equals(canonical), true);
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
finally {
|
|
946
|
+
await rm(wd, { recursive: true, force: true });
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
it("rejects hard-linked cache provenance files across shared surfaces", async () => {
|
|
950
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-hardlink-"));
|
|
951
|
+
try {
|
|
952
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
953
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
954
|
+
assert.ok(packaged);
|
|
955
|
+
const surfaces = [
|
|
956
|
+
".mcp.json",
|
|
957
|
+
".app.json",
|
|
958
|
+
".codex-plugin/plugin.json",
|
|
959
|
+
"skills/worker/SKILL.md",
|
|
960
|
+
"hooks/hooks.json",
|
|
961
|
+
"hooks/codex-native-hook.mjs",
|
|
962
|
+
"hooks/omx-command.json",
|
|
963
|
+
];
|
|
964
|
+
for (const surface of surfaces) {
|
|
965
|
+
const cacheDir = await seedRegularSnapshot(codexHomeDir);
|
|
966
|
+
const cachedPath = join(cacheDir, surface);
|
|
967
|
+
const externalPath = join(wd, "hardlinks", surface.replaceAll("/", "-"));
|
|
968
|
+
await mkdir(dirname(externalPath), { recursive: true });
|
|
969
|
+
await link(cachedPath, externalPath);
|
|
970
|
+
await rm(cachedPath, { force: true });
|
|
971
|
+
await link(externalPath, cachedPath);
|
|
972
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
973
|
+
assert.notEqual(result.status, "unchanged", `${surface} hardlink was trusted`);
|
|
974
|
+
assert.equal((await lstat(cachedPath)).nlink > 1, true);
|
|
975
|
+
assert.equal((await readFile(externalPath)).equals(await readFile(cachedPath)), true);
|
|
976
|
+
await rm(cacheDir, { recursive: true, force: true });
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
finally {
|
|
981
|
+
await rm(wd, { recursive: true, force: true });
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
it("long live publication lease is renewed while holding the lock (P2 heartbeat)", async () => {
|
|
985
|
+
// Slow publisher on a slow filesystem would exceed PUBLICATION_LOCK_LEASE_MS.
|
|
986
|
+
// The holder must refresh heartbeatAt on the open fd so a concurrent claimant
|
|
987
|
+
// never sees an expired live lease. This exercises the real publication path
|
|
988
|
+
// via a staged onCacheDirPrepared delay.
|
|
989
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-heartbeat-live-"));
|
|
990
|
+
try {
|
|
991
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
992
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
993
|
+
assert.ok(packaged);
|
|
994
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
995
|
+
const lockPath = join(cacheBase, ".omx-publish.lock");
|
|
996
|
+
await mkdir(cacheBase, { recursive: true });
|
|
997
|
+
// Seed a fresh live lock then run a publication that holds it: inject a
|
|
998
|
+
// short delay inside the critical section; second concurrent attempt must
|
|
999
|
+
// still see the live owner as active.
|
|
1000
|
+
const first = await materializePackagedOmxPluginCache(codexHomeDir, packaged, {
|
|
1001
|
+
onCacheDirPrepared: async () => {
|
|
1002
|
+
const bytes = await readFile(lockPath);
|
|
1003
|
+
const before = parseLastPublicationLockRecord(bytes.toString("utf-8"));
|
|
1004
|
+
assert.ok(typeof before.heartbeatAt === "number");
|
|
1005
|
+
// Concurrent claimant while critical section is still held.
|
|
1006
|
+
const concurrent = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1007
|
+
assert.equal(concurrent.status, "stale-launcher");
|
|
1008
|
+
assert.match(concurrent.reason ?? "", /another OMX plugin cache publication is active|cannot claim/);
|
|
1009
|
+
// Write a stale-looking heartbeat under a reused PID would previously
|
|
1010
|
+
// have been considered stale even for a live owner; ensure the holder's
|
|
1011
|
+
// interval has kept the real lease non-expired (done via the interval).
|
|
1012
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1013
|
+
const afterBytes = await readFile(lockPath);
|
|
1014
|
+
const after = parseLastPublicationLockRecord(afterBytes.toString("utf-8"));
|
|
1015
|
+
assert.ok(typeof after.heartbeatAt === "number");
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
assert.equal(first.status, "materialized", JSON.stringify(first));
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
finally {
|
|
1022
|
+
await rm(wd, { recursive: true, force: true });
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
it("cleanup does not quarantine a successor lock (P2 successor)", async () => {
|
|
1026
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-successor-lock-"));
|
|
1027
|
+
try {
|
|
1028
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1029
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
1030
|
+
assert.ok(packaged);
|
|
1031
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1032
|
+
const lockPath = join(cacheBase, ".omx-publish.lock");
|
|
1033
|
+
await mkdir(cacheBase, { recursive: true });
|
|
1034
|
+
// First publisher materializes and releases; immediately after, a second
|
|
1035
|
+
// publisher claims a new lock. Verify the first publisher's cleanup did not
|
|
1036
|
+
// quarantine the successor (dev/ino fencing in the finally block).
|
|
1037
|
+
const first = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1038
|
+
assert.equal(first.status, "materialized", JSON.stringify(first));
|
|
1039
|
+
// Lock is removed on success path; seed a successor and ensure materialize
|
|
1040
|
+
// still respects it, then that successor's own lifecycle is intact.
|
|
1041
|
+
await writeFile(lockPath, JSON.stringify({ pid: process.pid, createdAt: Date.now(), heartbeatAt: Date.now(), processToken: "successor" }));
|
|
1042
|
+
const second = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1043
|
+
assert.equal(second.status, "stale-launcher", JSON.stringify(second));
|
|
1044
|
+
assert.match(second.reason ?? "", /another OMX plugin cache publication is active|cannot claim/);
|
|
1045
|
+
const successorBytes = await readFile(lockPath, "utf-8");
|
|
1046
|
+
const successor = JSON.parse(successorBytes);
|
|
1047
|
+
assert.equal(successor.processToken, "successor");
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
finally {
|
|
1051
|
+
await rm(wd, { recursive: true, force: true });
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
it("standalone retirement holds heartbeat for >120s scan (P2 3861490863)", async () => {
|
|
1055
|
+
// retireUnpinnedManagedSnapshots acquires its own publication lock when
|
|
1056
|
+
// called standalone (publicationLockHeld=false). A scan/removal that
|
|
1057
|
+
// lasts beyond PUBLICATION_LOCK_LEASE_MS must not be reclaimable
|
|
1058
|
+
// mid-retirement. The standalone path must refresh heartbeatAt on the
|
|
1059
|
+
// open fd (same lifecycle as publication).
|
|
1060
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-long-retirement-"));
|
|
1061
|
+
try {
|
|
1062
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1063
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
1064
|
+
assert.ok(packaged);
|
|
1065
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1066
|
+
await mkdir(cacheBase, { recursive: true });
|
|
1067
|
+
for (const v of ["0.20.0", "0.20.1", "0.20.2"]) {
|
|
1068
|
+
const dir = join(cacheBase, v);
|
|
1069
|
+
await mkdir(dir, { recursive: true });
|
|
1070
|
+
await writeFile(join(dir, ".omx-managed"), "fixture\n");
|
|
1071
|
+
await mkdir(join(dir, ".codex-plugin"), { recursive: true });
|
|
1072
|
+
await writeFile(join(dir, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "oh-my-codex", version: v, skills: "./skills/", hooks: "./hooks/hooks.json" }));
|
|
1073
|
+
await writeFile(join(dir, ".omx-complete"), `${JSON.stringify({ version: v, claimDigest: await computeOmxPluginCacheClaimDigest(dir) })}\n`);
|
|
1074
|
+
}
|
|
1075
|
+
const version = await packagedPluginVersion();
|
|
1076
|
+
const { retireUnpinnedManagedSnapshots } = await import("../plugin-marketplace.js");
|
|
1077
|
+
const retirement = retireUnpinnedManagedSnapshots(codexHomeDir, version);
|
|
1078
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1079
|
+
const concurrent = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1080
|
+
assert.ok(concurrent.status === "stale-launcher" || concurrent.status === "materialized" || concurrent.status === "unchanged");
|
|
1081
|
+
if (concurrent.status === "stale-launcher") {
|
|
1082
|
+
assert.match(concurrent.reason ?? "", /another OMX plugin cache publication is active|cannot claim/);
|
|
1083
|
+
}
|
|
1084
|
+
const retired = await retirement;
|
|
1085
|
+
assert.ok(Array.isArray(retired));
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
finally {
|
|
1089
|
+
await rm(wd, { recursive: true, force: true });
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
it("retirement replacement after validation is preserved (P2 3861490875)", async () => {
|
|
1093
|
+
// Deterministic identity-bound test: stat, replace directory with foreign
|
|
1094
|
+
// inode, then call removeChildIfIdentity with stale original stats.
|
|
1095
|
+
// The helper must preserve the foreign replacement at its canonical version
|
|
1096
|
+
// path or in quarantine, because portable directory restoration cannot be
|
|
1097
|
+
// made atomic no-replace, and must not delete foreign content.
|
|
1098
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-replacement-restore-"));
|
|
1099
|
+
try {
|
|
1100
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1101
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1102
|
+
await mkdir(cacheBase, { recursive: true });
|
|
1103
|
+
const victim = "0.20.0";
|
|
1104
|
+
const victimPath = join(cacheBase, victim);
|
|
1105
|
+
await mkdir(victimPath, { recursive: true });
|
|
1106
|
+
await writeFile(join(victimPath, "victim.txt"), "victim\n");
|
|
1107
|
+
const { lstat: lstatP } = await import("fs/promises");
|
|
1108
|
+
const originalStats = await lstatP(victimPath);
|
|
1109
|
+
// Keep original inode alive so the foreign replacement gets a different inode
|
|
1110
|
+
// (immediate rm+mkdir can reuse the same inode on this fs, which would
|
|
1111
|
+
// make the dev/ino check pass and the helper would incorrectly delete foreign).
|
|
1112
|
+
const keepPath = join(cacheBase, victim + ".keep-" + process.pid);
|
|
1113
|
+
await rm(keepPath, { recursive: true, force: true }).catch(() => { });
|
|
1114
|
+
const { rename: renameP } = await import("fs/promises");
|
|
1115
|
+
await renameP(victimPath, keepPath);
|
|
1116
|
+
await mkdir(victimPath, { recursive: true });
|
|
1117
|
+
const foreignSentinel = join(victimPath, "foreign.txt");
|
|
1118
|
+
await writeFile(foreignSentinel, "foreign\n");
|
|
1119
|
+
// keepPath holds the original inode; clean it up after the check
|
|
1120
|
+
const { removeChildIfIdentity } = await import("../plugin-marketplace.js");
|
|
1121
|
+
// Build a minimal DirectoryRef over cacheBase using the same helper
|
|
1122
|
+
// the production code uses (open via fs/promises so lstat/dev/ino match).
|
|
1123
|
+
const { open } = await import("fs/promises");
|
|
1124
|
+
const { constants } = await import("fs");
|
|
1125
|
+
const fd = await open(cacheBase, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
1126
|
+
const baseRef = { handle: fd, path: cacheBase, operationPath: `/proc/self/fd/${fd.fd}`, scanOperationPath: `/proc/self/fd/${fd.fd}`, mutationPath: `/proc/self/fd/${fd.fd}` };
|
|
1127
|
+
try {
|
|
1128
|
+
await removeChildIfIdentity(baseRef, victim, originalStats, { recursive: true, force: true });
|
|
1129
|
+
}
|
|
1130
|
+
catch (e) {
|
|
1131
|
+
assert.match(e.message, /identity-bound removal target changed/);
|
|
1132
|
+
}
|
|
1133
|
+
finally {
|
|
1134
|
+
try {
|
|
1135
|
+
await fd.close();
|
|
1136
|
+
}
|
|
1137
|
+
catch { }
|
|
1138
|
+
}
|
|
1139
|
+
await rm(keepPath, { recursive: true, force: true }).catch(() => { });
|
|
1140
|
+
const foreignAtVersion = existsSync(foreignSentinel) && (await readFile(foreignSentinel, "utf-8")) === "foreign\n";
|
|
1141
|
+
const quarantineWithForeign = (await readdir(cacheBase)).some((n) => n.includes(".reclaim-") && existsSync(join(cacheBase, n, "foreign.txt")));
|
|
1142
|
+
assert.equal(foreignAtVersion || quarantineWithForeign, true, "foreign replacement was deleted");
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
finally {
|
|
1146
|
+
await rm(wd, { recursive: true, force: true });
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
it("preserves both artifacts when retirement is interposed before quarantine", async () => {
|
|
1150
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-retirement-interpose-"));
|
|
1151
|
+
try {
|
|
1152
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1153
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1154
|
+
await mkdir(cacheBase, { recursive: true });
|
|
1155
|
+
const victim = "0.20.0";
|
|
1156
|
+
const victimPath = join(cacheBase, victim);
|
|
1157
|
+
await mkdir(victimPath, { recursive: true });
|
|
1158
|
+
await writeFile(join(victimPath, "victim.txt"), "victim\n");
|
|
1159
|
+
const originalStats = await lstat(victimPath);
|
|
1160
|
+
const originalPath = join(cacheBase, `${victim}.original`);
|
|
1161
|
+
let interposed = false;
|
|
1162
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1163
|
+
beforeRename: async (sourcePath) => {
|
|
1164
|
+
if (interposed || basename(sourcePath) !== victim)
|
|
1165
|
+
return;
|
|
1166
|
+
interposed = true;
|
|
1167
|
+
await rename(victimPath, originalPath);
|
|
1168
|
+
await mkdir(victimPath, { recursive: true });
|
|
1169
|
+
await writeFile(join(victimPath, "successor.txt"), "successor\n");
|
|
1170
|
+
},
|
|
1171
|
+
});
|
|
1172
|
+
try {
|
|
1173
|
+
const { removeChildIfIdentity } = await import("../plugin-marketplace.js");
|
|
1174
|
+
const { open } = await import("fs/promises");
|
|
1175
|
+
const { constants } = await import("fs");
|
|
1176
|
+
const fd = await open(cacheBase, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
1177
|
+
const baseRef = {
|
|
1178
|
+
handle: fd,
|
|
1179
|
+
path: cacheBase,
|
|
1180
|
+
operationPath: `/proc/self/fd/${fd.fd}`,
|
|
1181
|
+
scanOperationPath: `/proc/self/fd/${fd.fd}`,
|
|
1182
|
+
mutationPath: `/proc/self/fd/${fd.fd}`,
|
|
1183
|
+
};
|
|
1184
|
+
try {
|
|
1185
|
+
await assert.rejects(removeChildIfIdentity(baseRef, victim, originalStats, { recursive: true, force: true }), /preserved quarantine/);
|
|
1186
|
+
}
|
|
1187
|
+
finally {
|
|
1188
|
+
await fd.close();
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
finally {
|
|
1192
|
+
resetHooks();
|
|
1193
|
+
}
|
|
1194
|
+
assert.equal(await readFile(join(originalPath, "victim.txt"), "utf-8"), "victim\n");
|
|
1195
|
+
const quarantined = (await readdir(cacheBase)).find((name) => name.includes(".reclaim-"));
|
|
1196
|
+
assert.ok(quarantined, "interposed successor must remain quarantined for bounded cleanup");
|
|
1197
|
+
const successorAtVersion = existsSync(join(victimPath, "successor.txt"));
|
|
1198
|
+
const successorInQuarantine = existsSync(join(cacheBase, quarantined, "successor.txt"));
|
|
1199
|
+
assert.equal(successorAtVersion || successorInQuarantine, true, "interposed successor must remain reachable");
|
|
1200
|
+
if (successorInQuarantine) {
|
|
1201
|
+
assert.equal(await readFile(join(cacheBase, quarantined, "successor.txt"), "utf-8"), "successor\n");
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
finally {
|
|
1206
|
+
await rm(wd, { recursive: true, force: true });
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
it("fails closed when staging destination appears between no-replace checks", async () => {
|
|
1210
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-staging-interpose-"));
|
|
1211
|
+
try {
|
|
1212
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1213
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
1214
|
+
assert.ok(packaged);
|
|
1215
|
+
const version = await packagedPluginVersion();
|
|
1216
|
+
const cacheDir = join(omxPluginCacheBase(codexHomeDir), version);
|
|
1217
|
+
let destinationName;
|
|
1218
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1219
|
+
beforeMkdirExclusive: async (parentPath, name) => {
|
|
1220
|
+
if (destinationName || parentPath !== cacheDir || name === ".omx-incomplete")
|
|
1221
|
+
return;
|
|
1222
|
+
destinationName = name;
|
|
1223
|
+
await writeFile(join(parentPath, name), "staging successor\n");
|
|
1224
|
+
},
|
|
1225
|
+
beforeExclusiveCreate: async (parentPath, name) => {
|
|
1226
|
+
if (destinationName || parentPath !== cacheDir || name === ".omx-incomplete")
|
|
1227
|
+
return;
|
|
1228
|
+
destinationName = name;
|
|
1229
|
+
await writeFile(join(parentPath, name), "staging successor\n");
|
|
1230
|
+
},
|
|
1231
|
+
});
|
|
1232
|
+
try {
|
|
1233
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1234
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
1235
|
+
}
|
|
1236
|
+
finally {
|
|
1237
|
+
resetHooks();
|
|
1238
|
+
}
|
|
1239
|
+
assert.ok(destinationName);
|
|
1240
|
+
assert.equal(await readFile(join(cacheDir, destinationName), "utf-8"), "staging successor\n");
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
finally {
|
|
1244
|
+
await rm(wd, { recursive: true, force: true });
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
it("fails closed when a staged source changes after its bytes are read", async () => {
|
|
1248
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-staged-source-race-"));
|
|
1249
|
+
try {
|
|
1250
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1251
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
1252
|
+
assert.ok(packaged);
|
|
1253
|
+
const version = await packagedPluginVersion();
|
|
1254
|
+
const cacheDir = join(omxPluginCacheBase(codexHomeDir), version);
|
|
1255
|
+
let interposed = false;
|
|
1256
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1257
|
+
afterStagedFileRead: async (path) => {
|
|
1258
|
+
if (interposed || (!path.includes("/snapshot/hooks/") && !path.includes("\\snapshot\\hooks\\")) || !path.endsWith("hooks.json"))
|
|
1259
|
+
return;
|
|
1260
|
+
interposed = true;
|
|
1261
|
+
await writeFile(path, "{\"attacker\":true}\n");
|
|
1262
|
+
},
|
|
1263
|
+
});
|
|
1264
|
+
try {
|
|
1265
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1266
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
1267
|
+
}
|
|
1268
|
+
finally {
|
|
1269
|
+
resetHooks();
|
|
1270
|
+
}
|
|
1271
|
+
assert.equal(existsSync(join(cacheDir, ".omx-complete")), false);
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
finally {
|
|
1275
|
+
await rm(wd, { recursive: true, force: true });
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
it("validates the final claim before publishing the complete marker", async () => {
|
|
1279
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-final-claim-validation-"));
|
|
1280
|
+
try {
|
|
1281
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1282
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
1283
|
+
assert.ok(packaged);
|
|
1284
|
+
const version = await packagedPluginVersion();
|
|
1285
|
+
const cacheDir = join(omxPluginCacheBase(codexHomeDir), version);
|
|
1286
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1287
|
+
beforeCompleteMarker: async (claimPath) => {
|
|
1288
|
+
await writeFile(join(claimPath, "hooks", "hooks.json"), "{\"attacker\":true}\n");
|
|
1289
|
+
},
|
|
1290
|
+
});
|
|
1291
|
+
try {
|
|
1292
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1293
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
1294
|
+
}
|
|
1295
|
+
finally {
|
|
1296
|
+
resetHooks();
|
|
1297
|
+
}
|
|
1298
|
+
assert.equal(existsSync(join(cacheDir, ".omx-complete")), false);
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
finally {
|
|
1302
|
+
await rm(wd, { recursive: true, force: true });
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
it("preserves a replacement interposed between quarantine lstat and recursive removal", async () => {
|
|
1306
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-removal-interpose-"));
|
|
1307
|
+
try {
|
|
1308
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1309
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1310
|
+
await mkdir(cacheBase, { recursive: true });
|
|
1311
|
+
const victim = "0.20.0";
|
|
1312
|
+
const victimPath = join(cacheBase, victim);
|
|
1313
|
+
await mkdir(victimPath, { recursive: true });
|
|
1314
|
+
await writeFile(join(victimPath, "victim.txt"), "victim\n");
|
|
1315
|
+
const originalStats = await lstat(victimPath);
|
|
1316
|
+
const preservedPath = join(cacheBase, `${victim}.preserved`);
|
|
1317
|
+
let interposed = false;
|
|
1318
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1319
|
+
beforeRemove: async (path) => {
|
|
1320
|
+
if (interposed || !path.includes(".reclaim-"))
|
|
1321
|
+
return;
|
|
1322
|
+
interposed = true;
|
|
1323
|
+
await rename(path, preservedPath);
|
|
1324
|
+
await mkdir(path, { recursive: true });
|
|
1325
|
+
await writeFile(join(path, "replacement.txt"), "replacement\n");
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
try {
|
|
1329
|
+
const { removeChildIfIdentity } = await import("../plugin-marketplace.js");
|
|
1330
|
+
const { open } = await import("fs/promises");
|
|
1331
|
+
const { constants } = await import("fs");
|
|
1332
|
+
const fd = await open(cacheBase, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
1333
|
+
const baseRef = {
|
|
1334
|
+
handle: fd,
|
|
1335
|
+
path: cacheBase,
|
|
1336
|
+
operationPath: `/proc/self/fd/${fd.fd}`,
|
|
1337
|
+
scanOperationPath: `/proc/self/fd/${fd.fd}`,
|
|
1338
|
+
mutationPath: `/proc/self/fd/${fd.fd}`,
|
|
1339
|
+
};
|
|
1340
|
+
try {
|
|
1341
|
+
await assert.rejects(removeChildIfIdentity(baseRef, victim, originalStats, { recursive: true, force: true, preserveRecursive: false }), /changed before deletion/);
|
|
1342
|
+
}
|
|
1343
|
+
finally {
|
|
1344
|
+
await fd.close();
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
finally {
|
|
1348
|
+
resetHooks();
|
|
1349
|
+
}
|
|
1350
|
+
assert.equal(await readFile(join(preservedPath, "victim.txt"), "utf-8"), "victim\n");
|
|
1351
|
+
const replacementAtVersion = existsSync(join(cacheBase, victim, "replacement.txt"));
|
|
1352
|
+
const quarantined = (await readdir(cacheBase)).find((name) => name.includes(".reclaim-"));
|
|
1353
|
+
const replacementInQuarantine = quarantined
|
|
1354
|
+
? existsSync(join(cacheBase, quarantined, "replacement.txt"))
|
|
1355
|
+
: false;
|
|
1356
|
+
assert.equal(replacementAtVersion || replacementInQuarantine, true, "replacement must remain reachable");
|
|
1357
|
+
if (replacementInQuarantine) {
|
|
1358
|
+
assert.equal(await readFile(join(cacheBase, quarantined, "replacement.txt"), "utf-8"), "replacement\n");
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
finally {
|
|
1363
|
+
await rm(wd, { recursive: true, force: true });
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
it("preserves a replacement interposed after the final removal identity check", async () => {
|
|
1367
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-removal-final-fence-"));
|
|
1368
|
+
try {
|
|
1369
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1370
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1371
|
+
await mkdir(cacheBase, { recursive: true });
|
|
1372
|
+
const victim = "0.20.0";
|
|
1373
|
+
const victimPath = join(cacheBase, victim);
|
|
1374
|
+
await mkdir(victimPath, { recursive: true });
|
|
1375
|
+
await writeFile(join(victimPath, "victim.txt"), "victim\n");
|
|
1376
|
+
const originalStats = await lstat(victimPath);
|
|
1377
|
+
const preservedPath = join(cacheBase, `${victim}.preserved`);
|
|
1378
|
+
let interposed = false;
|
|
1379
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1380
|
+
beforeRemoveSyscall: async (path) => {
|
|
1381
|
+
if (interposed || !path.includes(".reclaim-"))
|
|
1382
|
+
return;
|
|
1383
|
+
interposed = true;
|
|
1384
|
+
await rename(path, preservedPath);
|
|
1385
|
+
await mkdir(path, { recursive: true });
|
|
1386
|
+
await writeFile(join(path, "replacement.txt"), "replacement\n");
|
|
1387
|
+
},
|
|
1388
|
+
});
|
|
1389
|
+
try {
|
|
1390
|
+
const { removeChildIfIdentity } = await import("../plugin-marketplace.js");
|
|
1391
|
+
const { open } = await import("fs/promises");
|
|
1392
|
+
const { constants } = await import("fs");
|
|
1393
|
+
const fd = await open(cacheBase, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
1394
|
+
const baseRef = {
|
|
1395
|
+
handle: fd,
|
|
1396
|
+
path: cacheBase,
|
|
1397
|
+
operationPath: `/proc/self/fd/${fd.fd}`,
|
|
1398
|
+
scanOperationPath: `/proc/self/fd/${fd.fd}`,
|
|
1399
|
+
mutationPath: `/proc/self/fd/${fd.fd}`,
|
|
1400
|
+
};
|
|
1401
|
+
try {
|
|
1402
|
+
await assert.rejects(removeChildIfIdentity(baseRef, victim, originalStats, { recursive: true, force: true, preserveRecursive: false }), /changed before removal syscall/);
|
|
1403
|
+
}
|
|
1404
|
+
finally {
|
|
1405
|
+
await fd.close();
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
finally {
|
|
1409
|
+
resetHooks();
|
|
1410
|
+
}
|
|
1411
|
+
assert.equal(await readFile(join(preservedPath, "victim.txt"), "utf-8"), "victim\n");
|
|
1412
|
+
const quarantined = (await readdir(cacheBase)).find((name) => name.includes(".reclaim-"));
|
|
1413
|
+
const replacementAtVersion = existsSync(join(cacheBase, victim, "replacement.txt"));
|
|
1414
|
+
const replacementInQuarantine = quarantined
|
|
1415
|
+
? existsSync(join(cacheBase, quarantined, "replacement.txt"))
|
|
1416
|
+
: false;
|
|
1417
|
+
assert.equal(replacementAtVersion || replacementInQuarantine, true, "replacement must remain reachable");
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
finally {
|
|
1421
|
+
await rm(wd, { recursive: true, force: true });
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
it("preserves a successor during interposed stale-lock cleanup", async () => {
|
|
1425
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-stale-lock-interpose-"));
|
|
1426
|
+
try {
|
|
1427
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1428
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
1429
|
+
assert.ok(packaged);
|
|
1430
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1431
|
+
const lockPath = join(cacheBase, ".omx-publish.lock");
|
|
1432
|
+
const originalPath = join(cacheBase, ".omx-publish.lock.original");
|
|
1433
|
+
await mkdir(cacheBase, { recursive: true });
|
|
1434
|
+
await writeFile(lockPath, JSON.stringify({ pid: 99999999, createdAt: Date.now() - 60_000 }));
|
|
1435
|
+
let interposed = false;
|
|
1436
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1437
|
+
beforeRename: async (sourcePath) => {
|
|
1438
|
+
if (interposed || basename(sourcePath) !== ".omx-publish.lock")
|
|
1439
|
+
return;
|
|
1440
|
+
interposed = true;
|
|
1441
|
+
await rename(lockPath, originalPath);
|
|
1442
|
+
await writeFile(lockPath, JSON.stringify({
|
|
1443
|
+
pid: process.pid,
|
|
1444
|
+
createdAt: Date.now(),
|
|
1445
|
+
heartbeatAt: Date.now(),
|
|
1446
|
+
processToken: "successor",
|
|
1447
|
+
}));
|
|
1448
|
+
},
|
|
1449
|
+
});
|
|
1450
|
+
try {
|
|
1451
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1452
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
1453
|
+
}
|
|
1454
|
+
finally {
|
|
1455
|
+
resetHooks();
|
|
1456
|
+
}
|
|
1457
|
+
const successor = parseLastPublicationLockRecord(await readFile(lockPath, "utf-8"));
|
|
1458
|
+
assert.equal(successor.processToken, "successor");
|
|
1459
|
+
assert.equal(existsSync(originalPath), true);
|
|
1460
|
+
assert.ok((await readdir(cacheBase)).some((name) => name.includes(".reclaim-")), "raced lock quarantine must remain for bounded cleanup");
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
finally {
|
|
1464
|
+
await rm(wd, { recursive: true, force: true });
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
it("re-reads a same-inode heartbeat before stale-lock quarantine", async () => {
|
|
1468
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-heartbeat-recheck-"));
|
|
1469
|
+
try {
|
|
1470
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1471
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
1472
|
+
assert.ok(packaged);
|
|
1473
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1474
|
+
const lockPath = join(cacheBase, ".omx-publish.lock");
|
|
1475
|
+
await mkdir(cacheBase, { recursive: true });
|
|
1476
|
+
await writeFile(lockPath, `${JSON.stringify({ pid: 99999999, createdAt: Date.now() - 600_000, heartbeatAt: Date.now() - 600_000, processToken: "old" })}\n`);
|
|
1477
|
+
let refreshed = false;
|
|
1478
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1479
|
+
beforeStaleLockReclaim: async (path) => {
|
|
1480
|
+
if (refreshed)
|
|
1481
|
+
return;
|
|
1482
|
+
refreshed = true;
|
|
1483
|
+
await writeFile(path, `${JSON.stringify({ pid: process.pid, createdAt: Date.now(), heartbeatAt: Date.now(), processToken: "same-inode-live" })}\n`, { flag: "a" });
|
|
1484
|
+
},
|
|
1485
|
+
});
|
|
1486
|
+
try {
|
|
1487
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1488
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
1489
|
+
}
|
|
1490
|
+
finally {
|
|
1491
|
+
resetHooks();
|
|
1492
|
+
}
|
|
1493
|
+
const record = parseLastPublicationLockRecord(await readFile(lockPath, "utf-8"));
|
|
1494
|
+
assert.equal(record.processToken, "same-inode-live");
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
finally {
|
|
1498
|
+
await rm(wd, { recursive: true, force: true });
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
it("aborts stale-lock cleanup when the quarantined owner heartbeats late", async () => {
|
|
1502
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-late-heartbeat-"));
|
|
1503
|
+
try {
|
|
1504
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1505
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
1506
|
+
assert.ok(packaged);
|
|
1507
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1508
|
+
const lockPath = join(cacheBase, ".omx-publish.lock");
|
|
1509
|
+
await mkdir(cacheBase, { recursive: true });
|
|
1510
|
+
await writeFile(lockPath, `${JSON.stringify({ pid: 99999999, createdAt: Date.now() - 600_000, heartbeatAt: Date.now() - 600_000, processToken: "old" })}\n`);
|
|
1511
|
+
let heartbeated = false;
|
|
1512
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1513
|
+
beforeRemoveSyscall: async (path) => {
|
|
1514
|
+
if (heartbeated || !path.includes(".reclaim-"))
|
|
1515
|
+
return;
|
|
1516
|
+
heartbeated = true;
|
|
1517
|
+
await writeFile(path, `${JSON.stringify({ pid: process.pid, createdAt: Date.now(), heartbeatAt: Date.now(), processToken: "late-owner" })}\n`);
|
|
1518
|
+
},
|
|
1519
|
+
});
|
|
1520
|
+
try {
|
|
1521
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged);
|
|
1522
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
1523
|
+
}
|
|
1524
|
+
finally {
|
|
1525
|
+
resetHooks();
|
|
1526
|
+
}
|
|
1527
|
+
const successor = parseLastPublicationLockRecord(await readFile(lockPath, "utf-8"));
|
|
1528
|
+
assert.equal(successor.processToken, "late-owner");
|
|
1529
|
+
assert.ok((await readdir(cacheBase)).some((name) => name.includes(".reclaim-")), "late-heartbeat quarantine must remain bounded");
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
finally {
|
|
1533
|
+
await rm(wd, { recursive: true, force: true });
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
it("aborts a publisher when its retained lock fd loses the pathname", async () => {
|
|
1537
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-lock-path-loss-"));
|
|
1538
|
+
try {
|
|
1539
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1540
|
+
const packaged = await resolvePackagedOmxMarketplace(packageRoot);
|
|
1541
|
+
assert.ok(packaged);
|
|
1542
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1543
|
+
const lockPath = join(cacheBase, ".omx-publish.lock");
|
|
1544
|
+
let interposed = false;
|
|
1545
|
+
const result = await materializePackagedOmxPluginCache(codexHomeDir, packaged, {
|
|
1546
|
+
onCacheDirPrepared: async () => {
|
|
1547
|
+
if (interposed)
|
|
1548
|
+
return;
|
|
1549
|
+
interposed = true;
|
|
1550
|
+
await rm(lockPath, { force: true });
|
|
1551
|
+
await writeFile(lockPath, `${JSON.stringify({ pid: process.pid, createdAt: Date.now(), heartbeatAt: Date.now(), processToken: "successor" })}\n`, { flag: "wx" });
|
|
1552
|
+
},
|
|
1553
|
+
});
|
|
1554
|
+
assert.equal(result.status, "stale-launcher", JSON.stringify(result));
|
|
1555
|
+
assert.match(result.reason ?? "", /publication lock ownership lost/);
|
|
1556
|
+
const successor = parseLastPublicationLockRecord(await readFile(lockPath, "utf-8"));
|
|
1557
|
+
assert.equal(successor.processToken, "successor");
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
finally {
|
|
1561
|
+
await rm(wd, { recursive: true, force: true });
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
it("does not retire a same-name cache without the managed ownership marker", async () => {
|
|
1565
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-foreign-managed-name-"));
|
|
1566
|
+
try {
|
|
1567
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1568
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1569
|
+
const foreign = join(cacheBase, "0.20.0");
|
|
1570
|
+
await mkdir(join(foreign, ".codex-plugin"), { recursive: true });
|
|
1571
|
+
await writeFile(join(foreign, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "oh-my-codex", version: "0.20.0" }));
|
|
1572
|
+
await writeFile(join(foreign, ".omx-complete"), `${JSON.stringify({ version: "0.20.0", claimDigest: await computeOmxPluginCacheClaimDigest(foreign) })}\n`);
|
|
1573
|
+
const { retireUnpinnedManagedSnapshots } = await import("../plugin-marketplace.js");
|
|
1574
|
+
assert.deepEqual(await retireUnpinnedManagedSnapshots(codexHomeDir, "0.21.0"), []);
|
|
1575
|
+
assert.equal(existsSync(foreign), true);
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
finally {
|
|
1579
|
+
await rm(wd, { recursive: true, force: true });
|
|
1580
|
+
}
|
|
1581
|
+
});
|
|
1582
|
+
it("preserves a live pin inserted before retirement quarantine", async () => {
|
|
1583
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-live-pin-race-"));
|
|
1584
|
+
try {
|
|
1585
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1586
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1587
|
+
const victim = join(cacheBase, "0.20.0");
|
|
1588
|
+
await mkdir(join(victim, ".codex-plugin"), { recursive: true });
|
|
1589
|
+
await writeFile(join(victim, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "oh-my-codex", version: "0.20.0" }));
|
|
1590
|
+
await writeFile(join(victim, ".omx-managed"), "fixture\n");
|
|
1591
|
+
await writeFile(join(victim, ".omx-complete"), `${JSON.stringify({ version: "0.20.0", claimDigest: await computeOmxPluginCacheClaimDigest(victim) })}\n`);
|
|
1592
|
+
const newer = join(cacheBase, "0.20.1");
|
|
1593
|
+
await mkdir(join(newer, ".codex-plugin"), { recursive: true });
|
|
1594
|
+
await writeFile(join(newer, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "oh-my-codex", version: "0.20.1" }));
|
|
1595
|
+
await writeFile(join(newer, ".omx-managed"), "fixture\n");
|
|
1596
|
+
await writeFile(join(newer, ".omx-complete"), `${JSON.stringify({ version: "0.20.1", claimDigest: await computeOmxPluginCacheClaimDigest(newer) })}\n`);
|
|
1597
|
+
let interposed = false;
|
|
1598
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1599
|
+
beforeRename: async (sourcePath) => {
|
|
1600
|
+
if (interposed || !sourcePath.includes("0.20.0"))
|
|
1601
|
+
return;
|
|
1602
|
+
interposed = true;
|
|
1603
|
+
await writeFile(join(sourcePath, ".omx-live-pin"), "late pin\n");
|
|
1604
|
+
},
|
|
1605
|
+
});
|
|
1606
|
+
try {
|
|
1607
|
+
const { retireUnpinnedManagedSnapshots } = await import("../plugin-marketplace.js");
|
|
1608
|
+
const preservedDirs = [];
|
|
1609
|
+
assert.deepEqual(await retireUnpinnedManagedSnapshots(codexHomeDir, "0.21.0", undefined, false, preservedDirs), []);
|
|
1610
|
+
assert.equal(interposed, true, "retirement interposition did not reach the candidate quarantine rename");
|
|
1611
|
+
assert.ok(preservedDirs.some((path) => existsSync(join(path, ".omx-live-pin"))));
|
|
1612
|
+
}
|
|
1613
|
+
finally {
|
|
1614
|
+
resetHooks();
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
finally {
|
|
1619
|
+
await rm(wd, { recursive: true, force: true });
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
it("preserves a live pin inserted after quarantine before recursive cleanup", async () => {
|
|
1623
|
+
const wd = await mkdtemp(join(tmpdir(), "omx-3552-live-pin-late-cleanup-"));
|
|
1624
|
+
try {
|
|
1625
|
+
await withIsolatedUserHome(wd, async (codexHomeDir) => {
|
|
1626
|
+
const cacheBase = omxPluginCacheBase(codexHomeDir);
|
|
1627
|
+
const seed = async (version) => {
|
|
1628
|
+
const dir = join(cacheBase, version);
|
|
1629
|
+
await mkdir(join(dir, ".codex-plugin"), { recursive: true });
|
|
1630
|
+
await writeFile(join(dir, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "oh-my-codex", version }));
|
|
1631
|
+
await writeFile(join(dir, ".omx-managed"), "fixture\n");
|
|
1632
|
+
await writeFile(join(dir, ".omx-complete"), `${JSON.stringify({ version, claimDigest: await computeOmxPluginCacheClaimDigest(dir) })}\n`);
|
|
1633
|
+
};
|
|
1634
|
+
await seed("0.20.0");
|
|
1635
|
+
await seed("0.20.1");
|
|
1636
|
+
let interposed = false;
|
|
1637
|
+
const resetHooks = setPluginCacheMutationHooksForTest({
|
|
1638
|
+
beforeRemoveSyscall: async (path) => {
|
|
1639
|
+
if (interposed || !path.includes(".reclaim-"))
|
|
1640
|
+
return;
|
|
1641
|
+
interposed = true;
|
|
1642
|
+
await writeFile(join(path, ".omx-live-pin"), "late pin\n");
|
|
1643
|
+
},
|
|
1644
|
+
});
|
|
1645
|
+
try {
|
|
1646
|
+
const { retireUnpinnedManagedSnapshots } = await import("../plugin-marketplace.js");
|
|
1647
|
+
const preservedDirs = [];
|
|
1648
|
+
assert.deepEqual(await retireUnpinnedManagedSnapshots(codexHomeDir, "0.21.0", undefined, false, preservedDirs), []);
|
|
1649
|
+
assert.equal(interposed, true);
|
|
1650
|
+
assert.ok(preservedDirs.some((path) => existsSync(join(path, ".omx-live-pin"))));
|
|
1651
|
+
}
|
|
1652
|
+
finally {
|
|
1653
|
+
resetHooks();
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
finally {
|
|
1658
|
+
await rm(wd, { recursive: true, force: true });
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
});
|
|
1662
|
+
//# sourceMappingURL=plugin-cache-symlink-provenance-3552.test.js.map
|