gentle-pi 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -18
- package/assets/agents/gentle-ai-explore.md +11 -4
- package/assets/agents/gentle-ai-verify.md +23 -4
- package/assets/agents/gentle-ai-worker.md +6 -6
- package/assets/install-wizard/index.html +47 -0
- package/assets/install-wizard/wizard.css +1279 -0
- package/assets/install-wizard/wizard.js +951 -0
- package/assets/orchestrator-delegation.md +70 -163
- package/assets/orchestrator-memory.md +11 -4
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-tracking.md +53 -0
- package/assets/orchestrator-verification.md +28 -0
- package/assets/orchestrator-writer.md +38 -0
- package/assets/orchestrator.md +29 -25
- package/assets/sounds/LICENSE.md +52 -0
- package/assets/sounds/attention.wav +0 -0
- package/assets/sounds/error.wav +0 -0
- package/assets/sounds/success.wav +0 -0
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +708 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +399 -0
- package/docs/delegated-verification.md +3 -5
- package/docs/gentle-agents-activity.md +513 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +90 -33
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +1 -1
- package/docs/permission-modes.md +127 -0
- package/docs/prompt-history.md +1 -1
- package/docs/readme-reference.md +310 -47
- package/docs/render-cache-performance.md +28 -0
- package/docs/session-profile-format.md +39 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/yolo-mode.md +11 -10
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/child-context.ts +11 -2
- package/extensions/child-safety.ts +4 -2
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +718 -87
- package/extensions/gentle-ai.ts +991 -263
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +271 -80
- package/extensions/gentle-todo.ts +123 -18
- package/extensions/history/index.ts +11 -6
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +91 -21
- package/extensions/skill-registry.ts +175 -46
- package/extensions/startup-banner.ts +11 -2
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +40 -1
- package/lib/agents-runner.ts +90 -15
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +459 -0
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +484 -0
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/background-jobs-tools.ts +202 -0
- package/lib/background-jobs.ts +253 -0
- package/lib/background-monitor.ts +154 -0
- package/lib/bounded-writer-admission.ts +83 -10
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +14 -4
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-shell-bundled.ts +535 -0
- package/lib/gentle-shell-launcher.ts +75 -24
- package/lib/gentle-shell-startup-flow.ts +54 -0
- package/lib/gentle-shell-startup-update.ts +92 -0
- package/lib/gentle-shell-uninstall.ts +840 -0
- package/lib/gentle-shell-update-panel.ts +231 -0
- package/lib/herdr-activity.ts +183 -0
- package/lib/inprocess-reviewer.ts +3 -0
- package/lib/jobs-sidebar-state.ts +20 -0
- package/lib/jobs-view.ts +161 -0
- package/lib/model-price-ratio.ts +83 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/nan-provider.ts +33 -11
- package/lib/native-review-cli.ts +194 -73
- package/lib/notification-audio-native.ts +162 -0
- package/lib/notification-audio-windows.ts +399 -0
- package/lib/notification-audio.ts +332 -0
- package/lib/notification-customize.ts +241 -0
- package/lib/notification-events.ts +113 -0
- package/lib/notification-policy.ts +148 -0
- package/lib/notification-pulse-client.ts +367 -0
- package/lib/notification-pulse-protocol.ts +305 -0
- package/lib/notification-pulse-stream.ts +213 -0
- package/lib/notification-pulse-worker.ts +66 -0
- package/lib/notification-scheduler.ts +173 -0
- package/lib/notification-service.ts +202 -0
- package/lib/notification-ui.ts +87 -0
- package/lib/odd-phase.ts +1 -1
- package/lib/orchestrator-alias.ts +20 -0
- package/lib/orchestrator-catalog.ts +79 -0
- package/lib/orchestrator-consultation.ts +61 -0
- package/lib/orchestrator-discovery.ts +49 -0
- package/lib/orchestrator-helper-consent.ts +122 -0
- package/lib/orchestrator-helper.ts +167 -0
- package/lib/orchestrator-message-card.ts +105 -0
- package/lib/orchestrator-presence.ts +115 -14
- package/lib/orchestrator-scope.ts +80 -0
- package/lib/orchestrator-state.ts +107 -0
- package/lib/orchestrator-tool-card.ts +280 -0
- package/lib/orchestrator-work-search.ts +218 -0
- package/lib/orchestrator-work.ts +79 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/review-candidate-view-owner.ts +8 -6
- package/lib/review-candidate-view.ts +3 -2
- package/lib/review-compact-contract.ts +2 -2
- package/lib/review-host-relay.ts +7 -2
- package/lib/review-integration-v2.ts +100 -22
- package/lib/review-publication-gate.ts +1 -1
- package/lib/review-risk-assessment.ts +77 -51
- package/lib/review-sidebar-state.ts +2 -1
- package/lib/review-transaction.ts +72 -3
- package/lib/runtime-metrics-children.ts +1 -1
- package/lib/runtime-metrics-native.ts +3 -2
- package/lib/selection-engine.ts +22 -7
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +261 -0
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-worktree-registry.ts +74 -8
- package/lib/shell-bar.ts +41 -11
- package/lib/shell-changes-view.ts +40 -13
- package/lib/shell-changes.ts +88 -71
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-sidebar-layout.ts +5 -1
- package/lib/shell-todo.ts +81 -26
- package/lib/shell-usage.ts +4 -3
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/vim-editor-adapter.ts +27 -5
- package/lib/visual-customization-policy.ts +20 -9
- package/lib/visual-customize-view.ts +171 -20
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +198 -121
- package/package.json +5 -5
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +536 -0
- package/runtime/gentle-shell-launcher.mjs +74 -23
- package/runtime/gentle-shell-startup-flow.mjs +55 -0
- package/runtime/gentle-shell-startup-update.mjs +93 -0
- package/runtime/gentle-shell-uninstall.mjs +841 -0
- package/runtime/gentle-shell-update-panel.mjs +232 -0
- package/runtime/native-review-cli.mjs +191 -70
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +77 -51
- package/runtime/status-timing-diagnostics.mjs +202 -0
- package/runtime/telemetry-trigger.mjs +3 -1
- package/scripts/bootstrap.cmd +255 -0
- package/scripts/bootstrap.sh +168 -0
- package/scripts/build-distribution.mjs +157 -0
- package/scripts/build-installer-bundles.mjs +136 -0
- package/scripts/build-runtime-modules.mjs +7 -0
- package/scripts/bundled-install.mjs +1308 -0
- package/scripts/bundled-wizard.mjs +399 -0
- package/scripts/check-types.mjs +4 -6
- package/scripts/gentle-ai-installer.mjs +79 -32
- package/scripts/install-gentle-ai.mjs +48 -1
- package/scripts/install-tui-mode-setting.mjs +10 -1
- package/scripts/install-wizard-preview.mjs +154 -0
- package/scripts/installer-downloads.mjs +648 -0
- package/scripts/installer-preflight.mjs +387 -0
- package/scripts/installer-probes.mjs +564 -0
- package/scripts/installer-runner.mjs +1102 -0
- package/scripts/installer-server.mjs +881 -0
- package/scripts/installer-windows-artifacts.json +15 -0
- package/scripts/installer-windows.mjs +752 -0
- package/scripts/main-channel.mjs +452 -0
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/pi-update.mjs +223 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +22 -8
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +23 -5
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +82 -18
- package/skills/gentle-ai/SKILL.md +12 -11
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +12 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-protocol.test.ts +57 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +89 -4
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +276 -0
- package/tests/auto-mode-sdk.test.ts +84 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +634 -0
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +454 -0
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/background-cache-warming.test.ts +2 -1
- package/tests/background-jobs.test.ts +227 -0
- package/tests/background-monitor.test.ts +195 -0
- package/tests/background-subagents-default-mode.test.ts +28 -5
- package/tests/bounded-stop-contract.test.ts +85 -0
- package/tests/bounded-writer-admission.test.ts +52 -1
- package/tests/bridge-wake-lifecycle.test.ts +129 -0
- package/tests/bridge-wake-rendering.test.ts +56 -0
- package/tests/build-distribution.test.ts +269 -0
- package/tests/builtin-codemode-optout.test.ts +242 -0
- package/tests/bundled-install.test.ts +1319 -0
- package/tests/bundled-wizard.test.ts +505 -0
- package/tests/card-content-policy.test.ts +68 -0
- package/tests/card-style-policy.test.ts +34 -2
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-package-entrypoints.test.ts +65 -0
- package/tests/child-package-injection.test.ts +74 -0
- package/tests/child-safety.test.ts +12 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +107 -2
- package/tests/command-palette.test.ts +5 -5
- package/tests/context-threshold.test.ts +31 -0
- package/tests/delegate-for-reason-contract.test.ts +327 -0
- package/tests/delegated-key-learnings-contract.test.ts +3 -2
- package/tests/devbinary/native-review-parity.devtest.ts +1 -1
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +316 -0
- package/tests/generic-agent-tools.test.ts +16 -1
- package/tests/gentle-agents.test.ts +1895 -76
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +19 -2
- package/tests/gentle-ai-child-guards.test.ts +202 -0
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
- package/tests/gentle-ai-installer.test.ts +548 -58
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +6 -6
- package/tests/gentle-ai.test.ts +999 -31
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +263 -19
- package/tests/gentle-shell-bundled.test.ts +1036 -0
- package/tests/gentle-shell-launcher.test.ts +251 -25
- package/tests/gentle-shell-startup-flow.test.ts +75 -0
- package/tests/gentle-shell-startup-update.test.ts +85 -0
- package/tests/gentle-shell-uninstall.test.ts +978 -0
- package/tests/gentle-shell-update-panel.test.ts +158 -0
- package/tests/gentle-shell.test.ts +611 -100
- package/tests/gentle-todo.test.ts +317 -14
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/inprocess-reviewer.test.ts +20 -0
- package/tests/install-gentle-ai-failure.test.ts +69 -0
- package/tests/install-tui-mode-setting.test.ts +15 -0
- package/tests/install-wizard.test.ts +1223 -0
- package/tests/installer-bundles.test.ts +122 -0
- package/tests/installer-posix-bootstrap.test.ts +1201 -0
- package/tests/installer-preflight.test.ts +750 -0
- package/tests/installer-probes.test.ts +1106 -0
- package/tests/installer-runner.test.ts +2159 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +395 -0
- package/tests/main-channel.test.ts +344 -0
- package/tests/maintainer/provider-relay.maintest.ts +13 -12
- package/tests/model-price-ratio.test.ts +116 -0
- package/tests/model-profiles-preservation.test.ts +153 -0
- package/tests/model-routing-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +28 -18
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +236 -1
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +1 -1
- package/tests/native-review-parity.test.ts +12 -48
- package/tests/native-risk-evidence-parity.test.ts +21 -0
- package/tests/no-self-pause-contract.test.ts +87 -0
- package/tests/notification-audio-native.test.ts +281 -0
- package/tests/notification-audio-windows.test.ts +581 -0
- package/tests/notification-audio.test.ts +548 -0
- package/tests/notification-customize.test.ts +655 -0
- package/tests/notification-events.test.ts +115 -0
- package/tests/notification-policy.test.ts +201 -0
- package/tests/notification-pulse-client.test.ts +356 -0
- package/tests/notification-pulse-protocol.test.ts +213 -0
- package/tests/notification-pulse-stream.test.ts +320 -0
- package/tests/notification-scheduler.test.ts +258 -0
- package/tests/notification-ui.test.ts +157 -0
- package/tests/npm-pack-result.test.ts +41 -0
- package/tests/odd-integration.test.ts +14 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
- package/tests/odd-routing-contract.test.ts +80 -23
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +29 -6
- package/tests/orchestrator-catalog.test.ts +146 -0
- package/tests/orchestrator-consultation-sdk.test.ts +614 -0
- package/tests/orchestrator-consultation.test.ts +111 -0
- package/tests/orchestrator-discovery.test.ts +241 -0
- package/tests/orchestrator-helper-consent.test.ts +171 -0
- package/tests/orchestrator-helper.test.ts +297 -0
- package/tests/orchestrator-message-card.test.ts +190 -0
- package/tests/orchestrator-presence.test.ts +23 -0
- package/tests/orchestrator-rdd-ownership.test.ts +6 -5
- package/tests/orchestrator-scope.test.ts +107 -0
- package/tests/orchestrator-state.test.ts +114 -0
- package/tests/orchestrator-status-publication-contract.test.ts +34 -0
- package/tests/orchestrator-tool-card.test.ts +398 -0
- package/tests/orchestrator-work-search.test.ts +318 -0
- package/tests/orchestrator-work.test.ts +147 -0
- package/tests/package-manifest.test.ts +105 -21
- package/tests/packed-runner-owned-path.test.ts +35 -1
- package/tests/parallel-reason-contract.test.ts +21 -0
- package/tests/parallel-writer-admission.test.ts +210 -0
- package/tests/permission-mode-policy.test.ts +194 -0
- package/tests/persona-single-channel.test.ts +4 -12
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/provider-defect-handoff.test.ts +4 -3
- package/tests/quiet-tool-rendering.test.ts +160 -3
- package/tests/rdd-aware-verification-contract.test.ts +29 -41
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/review-agent-end-preflight.test.ts +62 -10
- package/tests/review-candidate-view.test.ts +101 -12
- package/tests/review-contract-prompt.test.ts +33 -4
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +255 -1
- package/tests/review-controller-workspace-root.test.ts +43 -2
- package/tests/review-controller.test.ts +2 -2
- package/tests/review-correction-lifecycle.test.ts +2 -1
- package/tests/review-gate.test.ts +7 -4
- package/tests/review-graph-schema.test.ts +2 -2
- package/tests/review-host-relay-routing.test.ts +5 -3
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +21 -1
- package/tests/review-json-arguments.test.ts +167 -0
- package/tests/review-last-event-closure.test.ts +157 -8
- package/tests/review-ledger-contract.test.ts +5 -4
- package/tests/review-policy-judgment-day.test.ts +1 -1
- package/tests/review-policy-ordinary.test.ts +2 -2
- package/tests/review-relay-transport-agent.test.ts +66 -1
- package/tests/review-risk-assessment.test.ts +109 -33
- package/tests/review-session-standing-permission-controller.test.ts +16 -17
- package/tests/review-session-standing-permission-ipc.test.ts +6 -5
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +83 -5
- package/tests/runtime-harness.mjs +42 -15
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +65 -5
- package/tests/session-change-capture.test.ts +10 -10
- package/tests/session-changes-shell.test.ts +242 -48
- package/tests/session-changes.test.ts +17 -24
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +394 -0
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +57 -2
- package/tests/shell-bar.test.ts +40 -0
- package/tests/shell-changes-view.test.ts +85 -34
- package/tests/shell-changes.test.ts +79 -62
- package/tests/shell-render-cache-runtime.test.ts +69 -0
- package/tests/shell-render-cache.test.ts +129 -0
- package/tests/shell-sidebar-layout.test.ts +27 -2
- package/tests/shell-todo.test.ts +132 -11
- package/tests/shell-usage-view.test.ts +1 -1
- package/tests/shell-usage.test.ts +35 -0
- package/tests/short-check-routing-contract.test.ts +49 -0
- package/tests/skill-registry-watcher.test.ts +119 -0
- package/tests/skill-registry.test.ts +311 -5
- package/tests/startup-banner.test.ts +117 -3
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/status-timing-diagnostics.test.ts +283 -0
- package/tests/support/orchestrator-modules.ts +19 -0
- package/tests/task-size-routing-contract.test.ts +241 -0
- package/tests/telemetry-trigger.test.ts +27 -1
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/verify-agent-contract.test.ts +90 -0
- package/tests/verify-first-launch-contract.test.ts +139 -0
- package/tests/verify-package-files.test.ts +56 -0
- package/tests/vim-editor-adapter.test.ts +47 -21
- package/tests/visual-customization-policy.test.ts +32 -3
- package/tests/visual-customize-view.test.ts +288 -10
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +18 -3
- package/tests/yolo-mode-runtime.test.ts +37 -10
- package/tests/yolo-mode.test.ts +38 -5
- package/tests/yolo-session-policy.test.ts +1 -1
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join, posix, win32 } from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import test, { type TestContext } from "node:test";
|
|
9
|
+
import {
|
|
10
|
+
BUNDLED_VERIFY_ENV,
|
|
11
|
+
bundledChildEnv,
|
|
12
|
+
bundledPi,
|
|
13
|
+
bundledSetupEnv,
|
|
14
|
+
bundledUpdateNext,
|
|
15
|
+
bundledUpdateNotice,
|
|
16
|
+
bundledUpdatePlan,
|
|
17
|
+
parseBundledUpgradeArgs,
|
|
18
|
+
withBundledNpmCommand,
|
|
19
|
+
resolveBundledInstall,
|
|
20
|
+
setBundledInstallForTesting,
|
|
21
|
+
type BundledFs,
|
|
22
|
+
type BundledPi,
|
|
23
|
+
} from "../lib/gentle-shell-bundled.ts";
|
|
24
|
+
import { artifactFor, engramPinVersion } from "../scripts/installer-downloads.mjs";
|
|
25
|
+
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
26
|
+
import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
|
|
27
|
+
|
|
28
|
+
// A fake filesystem keyed by exact paths, so every platform's paths are plain
|
|
29
|
+
// strings on every runner: realpath maps a link to its target, and any other
|
|
30
|
+
// path holding a file to itself; readText returns a regular file's text,
|
|
31
|
+
// readLink a symlink's.
|
|
32
|
+
function fakeFs({ real = {}, files = {}, links = {} }: { real?: Record<string, string>; files?: Record<string, string>; links?: Record<string, string> }): BundledFs {
|
|
33
|
+
const known = [...Object.keys(files), ...Object.keys(links), ...Object.values(real)];
|
|
34
|
+
const exists = (path: string) => known.some((entry) => entry === path || entry.startsWith(`${path}/`) || entry.startsWith(`${path}\\`));
|
|
35
|
+
return {
|
|
36
|
+
realpath: (path) => real[path] ?? (exists(path) ? path : undefined),
|
|
37
|
+
readText: (path) => files[path],
|
|
38
|
+
readLink: (path) => links[path],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ID = "4.1.0-1.2.0";
|
|
43
|
+
const POSIX_ROOT = "/home/ana/.gentle-shell";
|
|
44
|
+
const POSIX_VERSION = posix.join(POSIX_ROOT, "versions", ID);
|
|
45
|
+
const POSIX_PACKAGE = posix.join(POSIX_VERSION, "node_modules", ".pnpm", "gentle-pi@4.1.0", "node_modules", "gentle-pi");
|
|
46
|
+
const PI_PACKAGE = (version: string, path = posix) => path.join(version, "node_modules", "@earendil-works", "pi-coding-agent");
|
|
47
|
+
|
|
48
|
+
function posixInstall(overrides: { current?: string; marker?: string; link?: string } = {}) {
|
|
49
|
+
return fakeFs({
|
|
50
|
+
real: { [posix.join(POSIX_VERSION, "node_modules", "gentle-pi")]: overrides.link ?? POSIX_PACKAGE },
|
|
51
|
+
files: { [posix.join(POSIX_VERSION, ".gentle-shell-version")]: overrides.marker ?? `${ID}\n`, [posix.join(POSIX_PACKAGE, "package.json")]: "{}" },
|
|
52
|
+
links: { [posix.join(POSIX_ROOT, "current")]: overrides.current ?? `versions/${ID}` },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("resolveBundledInstall finds the version folder current names from the real package root", () => {
|
|
57
|
+
assert.deepEqual(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall() }),
|
|
58
|
+
{ root: POSIX_ROOT, id: ID, versionDir: POSIX_VERSION });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("resolveBundledInstall is off unless current names this marked version and its gentle-pi is this package", () => {
|
|
62
|
+
// Kept for rollback, but not the active one.
|
|
63
|
+
assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall({ current: "versions/4.0.0-1.0.0" }) }), undefined);
|
|
64
|
+
// An unfinished install, or a folder without our marker.
|
|
65
|
+
assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall({ marker: `installing ${ID} 42\n` }) }), undefined);
|
|
66
|
+
// The version's gentle-pi is another copy, not the running one.
|
|
67
|
+
assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall({ link: "/elsewhere/gentle-pi" }) }), undefined);
|
|
68
|
+
// A global npm or pnpm install, or a source checkout.
|
|
69
|
+
for (const packageRoot of ["/usr/lib/node_modules/gentle-pi", "/home/ana/.local/share/pnpm/global/5/node_modules/gentle-pi", "/home/ana/src/gentle-shell"]) {
|
|
70
|
+
assert.equal(resolveBundledInstall({ packageRoot, platform: "linux", fs: posixInstall() }), undefined, packageRoot);
|
|
71
|
+
}
|
|
72
|
+
// An id the bundled install never writes.
|
|
73
|
+
const unsafe = posix.join(POSIX_ROOT, "versions", "-x", "node_modules", "gentle-pi");
|
|
74
|
+
assert.equal(resolveBundledInstall({ packageRoot: unsafe, platform: "linux", fs: fakeFs({
|
|
75
|
+
files: { [posix.join(POSIX_ROOT, "versions", "-x", ".gentle-shell-version")]: "-x\n" }, links: { [posix.join(POSIX_ROOT, "current")]: "versions/-x" } }) }), undefined);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("resolveBundledInstall treats only the version being verified as current, for that check", () => {
|
|
79
|
+
const kept = posixInstall({ current: "versions/4.0.0-1.0.0" });
|
|
80
|
+
assert.deepEqual(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: kept, verifying: ID }), { root: POSIX_ROOT, id: ID, versionDir: POSIX_VERSION });
|
|
81
|
+
assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: kept, verifying: "4.0.0-1.0.0" }), undefined);
|
|
82
|
+
// The marker and the package location are still required.
|
|
83
|
+
assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall({ current: "versions/x", marker: "x\n" }), verifying: ID }), undefined);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("resolveBundledInstall on Windows reads the current pointer file, never a link", () => {
|
|
87
|
+
const root = "C:\\Users\\Ana\\AppData\\Local\\gentle-shell";
|
|
88
|
+
const version = win32.join(root, "versions", ID);
|
|
89
|
+
const packageRoot = win32.join(version, "node_modules", ".pnpm", "gentle-pi@4.1.0", "node_modules", "gentle-pi");
|
|
90
|
+
const files = { [win32.join(version, ".gentle-shell-version")]: `${ID}\n`, [win32.join(root, "current")]: `${ID}\n` };
|
|
91
|
+
const real = { [win32.join(version, "node_modules", "gentle-pi")]: packageRoot };
|
|
92
|
+
assert.deepEqual(resolveBundledInstall({ packageRoot, platform: "win32", fs: fakeFs({ real, files }) }), { root, id: ID, versionDir: version });
|
|
93
|
+
const linked = fakeFs({ real, files: { [win32.join(version, ".gentle-shell-version")]: `${ID}\n` }, links: { [win32.join(root, "current")]: `versions/${ID}` } });
|
|
94
|
+
assert.equal(resolveBundledInstall({ packageRoot, platform: "win32", fs: linked }), undefined);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function piFiles(pinned: string, installed: string, path = posix, version = POSIX_VERSION) {
|
|
98
|
+
const pi = PI_PACKAGE(version, path);
|
|
99
|
+
return {
|
|
100
|
+
[path.join(version, "package.json")]: JSON.stringify({ dependencies: { "gentle-pi": "4.1.0", "@earendil-works/pi-coding-agent": pinned } }),
|
|
101
|
+
[path.join(pi, "package.json")]: JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: installed, bin: { pi: "dist/bundle/cli.js" } }),
|
|
102
|
+
[path.join(pi, "dist", "bundle", "cli.js")]: "",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const install = { root: POSIX_ROOT, id: ID, versionDir: POSIX_VERSION };
|
|
106
|
+
const messageOf = (pi: BundledPi) => ("message" in pi ? pi.message : "");
|
|
107
|
+
|
|
108
|
+
test("bundledPi returns only the Pi the version pins, inside the version folder", () => {
|
|
109
|
+
assert.deepEqual(bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.2.0", "1.2.0") }) }),
|
|
110
|
+
{ ok: true, version: "1.2.0", cli: posix.join(PI_PACKAGE(POSIX_VERSION), "dist", "bundle", "cli.js") });
|
|
111
|
+
const windows = { root: "C:\\G", id: ID, versionDir: `C:\\G\\versions\\${ID}` };
|
|
112
|
+
assert.deepEqual(bundledPi(windows, { platform: "win32", fs: fakeFs({ files: piFiles("1.2.0", "1.2.0", win32, windows.versionDir) }) }),
|
|
113
|
+
{ ok: true, version: "1.2.0", cli: win32.join(PI_PACKAGE(windows.versionDir, win32), "dist", "bundle", "cli.js") });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("bundledPi fails with the version, the pinned Pi and the way to repair it", () => {
|
|
117
|
+
const repair = /Gentle Shell 4\.1\.0-1\.2\.0 .*Pi 1\.2\.0.*gentle-shell upgrade.*installer/s;
|
|
118
|
+
const mismatch = bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.2.0", "1.0.0") }) });
|
|
119
|
+
assert.equal(mismatch.ok, false);
|
|
120
|
+
assert.match(messageOf(mismatch), repair);
|
|
121
|
+
assert.match(messageOf(mismatch), /found Pi 1\.0\.0/);
|
|
122
|
+
const files = piFiles("1.2.0", "1.2.0");
|
|
123
|
+
delete files[posix.join(PI_PACKAGE(POSIX_VERSION), "dist", "bundle", "cli.js")];
|
|
124
|
+
const missing = bundledPi(install, { platform: "linux", fs: fakeFs({ files }) });
|
|
125
|
+
assert.match(messageOf(missing), repair);
|
|
126
|
+
// A Pi linked from outside the version folder is not the bundled one.
|
|
127
|
+
const outside = fakeFs({ real: { [PI_PACKAGE(POSIX_VERSION)]: "/usr/lib/node_modules/@earendil-works/pi-coding-agent" }, files: piFiles("1.2.0", "1.2.0") });
|
|
128
|
+
assert.equal(bundledPi(install, { platform: "linux", fs: outside }).ok, false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const ISOLATION = { prefix: "/g", engram: "/g/runtime/engram-3.4.0/engram" };
|
|
132
|
+
|
|
133
|
+
test("bundledChildEnv keeps the user's environment and adds only the bundled Pi, the prefix and Engram, no Pi override and no Pi update check", () => {
|
|
134
|
+
const user = { PATH: "/home/ana/.nvm/bin:/usr/bin", NODE_OPTIONS: "--max-old-space-size=4096", npm_config_registry: "https://npm.example", npm_config_prefix: "/home/ana/.npm-global",
|
|
135
|
+
GENTLE_SHELL_PI: "/usr/local/bin/pi", GENTLE_PI_AGENTS_PI: "pi", [BUNDLED_VERIFY_ENV]: ID, GENTLE_PI_CONFIG_HOME: "/home/ana/.pi/gentle-ai" };
|
|
136
|
+
const env = bundledChildEnv(user, { node: "/g/node", cli: "/g/cli.js", ...ISOLATION });
|
|
137
|
+
// S9: Gentle AI's own variables never reach Pi (its shell would pass them to
|
|
138
|
+
// a gentle-ai the user runs); gentle-pi adds them to its own gentle-ai runs.
|
|
139
|
+
// GENTLE_PI_CONFIG_HOME stays the user's: gentle-pi derives its bundled config
|
|
140
|
+
// folder from the prefix, and Gentle AI 4.0.0 reads that variable as a parent.
|
|
141
|
+
assert.deepEqual(env, { PATH: user.PATH, NODE_OPTIONS: user.NODE_OPTIONS, npm_config_registry: user.npm_config_registry, npm_config_prefix: user.npm_config_prefix,
|
|
142
|
+
GENTLE_PI_CONFIG_HOME: user.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_PI: "/g/node /g/cli.js", PI_SKIP_VERSION_CHECK: "1", GENTLE_SHELL_PREFIX: "/g",
|
|
143
|
+
ENGRAM_BIN: "/g/runtime/engram-3.4.0/engram" });
|
|
144
|
+
// The subagent runner splits that variable on whitespace: a spaced path falls
|
|
145
|
+
// back to its own default, the running node and cli.js.
|
|
146
|
+
const spaced = bundledChildEnv({ GENTLE_PI_AGENTS_PI: "pi" }, { node: "C:\\Users\\Ana Li\\node.exe", cli: "C:\\cli.js", ...ISOLATION });
|
|
147
|
+
assert.equal("GENTLE_PI_AGENTS_PI" in spaced, false);
|
|
148
|
+
// gentle-engram reads ENGRAM_BIN: the user's own choice is kept, and an
|
|
149
|
+
// Engram that is not published is never named.
|
|
150
|
+
assert.equal(bundledChildEnv({ ENGRAM_BIN: "/opt/engram" }, { node: "/g/node", cli: "/g/cli.js", ...ISOLATION }).ENGRAM_BIN, "/opt/engram");
|
|
151
|
+
assert.equal("ENGRAM_BIN" in bundledChildEnv({}, { node: "/g/node", cli: "/g/cli.js", ...ISOLATION, engram: undefined }), false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("bundledSetupEnv puts only our node and the version's bin ahead of the user's PATH", () => {
|
|
155
|
+
const env = bundledSetupEnv({ PATH: "/usr/bin", NODE_OPTIONS: "--x", npm_config_registry: "r" }, { dirs: ["/g/node-bin", "/g/v/node_modules/.bin"], platform: "linux" });
|
|
156
|
+
assert.deepEqual(env, { PATH: "/g/node-bin:/g/v/node_modules/.bin:/usr/bin", NODE_OPTIONS: "--x", npm_config_registry: "r" });
|
|
157
|
+
const windows = bundledSetupEnv({ Path: "C:\\Windows" }, { dirs: ["C:\\G\\node"], platform: "win32" });
|
|
158
|
+
assert.deepEqual(windows, { Path: "C:\\G\\node;C:\\Windows" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("withBundledNpmCommand sets only npmCommand and leaves an already bundled settings file as it is", () => {
|
|
162
|
+
const command = ["/g/node", "/g/npm-cli.js", "--userconfig=/g/npmrc"];
|
|
163
|
+
const text = '{\n "theme": "gentle",\n "packages": ["npm:x"]\n}\n';
|
|
164
|
+
const added = withBundledNpmCommand(text, command);
|
|
165
|
+
assert.equal(added.status, "write");
|
|
166
|
+
const written = added.status === "write" ? added.text : "";
|
|
167
|
+
assert.deepEqual(JSON.parse(written), { theme: "gentle", packages: ["npm:x"], npmCommand: command });
|
|
168
|
+
assert.match(written, /^\{\n {4}"theme"/);
|
|
169
|
+
assert.ok(written.endsWith("}\n"));
|
|
170
|
+
assert.deepEqual(withBundledNpmCommand(written, command), { status: "unchanged" });
|
|
171
|
+
const replaced = withBundledNpmCommand('{"npmCommand":["npm"]}', command);
|
|
172
|
+
assert.deepEqual(JSON.parse(replaced.status === "write" ? replaced.text : "{}"), { npmCommand: command });
|
|
173
|
+
assert.deepEqual(JSON.parse((withBundledNpmCommand(undefined, command) as { text: string }).text), { npmCommand: command });
|
|
174
|
+
for (const malformed of ["{", "[]", "null"]) assert.deepEqual(withBundledNpmCommand(malformed, command), { status: "malformed" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("bundledUpdatePlan redirects Pi's own update and keeps the extension updates", () => {
|
|
178
|
+
assert.deepEqual(bundledUpdatePlan(["update"]), { self: true });
|
|
179
|
+
assert.deepEqual(bundledUpdatePlan(["update", "--self"]), { self: true });
|
|
180
|
+
assert.deepEqual(bundledUpdatePlan(["update", "pi"]), { self: true });
|
|
181
|
+
assert.deepEqual(bundledUpdatePlan(["update", "self"]), { self: true });
|
|
182
|
+
assert.deepEqual(bundledUpdatePlan(["update", "--all"]), { self: true, piArgs: ["update", "--extensions"] });
|
|
183
|
+
assert.deepEqual(bundledUpdatePlan(["update", "--self", "--extensions", "--approve"]), { self: true, piArgs: ["update", "--extensions", "--approve"] });
|
|
184
|
+
// Pi reads a self positional with --extensions as "all" too.
|
|
185
|
+
for (const target of ["pi", "self"]) {
|
|
186
|
+
assert.deepEqual(bundledUpdatePlan(["update", target, "--extensions"]), { self: true, piArgs: ["update", "--extensions"] }, target);
|
|
187
|
+
assert.deepEqual(bundledUpdatePlan(["update", "--extensions", target, "-a"]), { self: true, piArgs: ["update", "--extensions", "-a"] }, target);
|
|
188
|
+
}
|
|
189
|
+
// Pi itself refuses conflicting or unknown forms before updating anything.
|
|
190
|
+
for (const passthrough of [["update", "--extensions"], ["update", "npm:x"], ["update", "--models"], ["update", "--extension", "npm:x"], ["update", "--help"],
|
|
191
|
+
["update", "--all", "--self"], ["update", "pi", "--all"], ["update", "pi", "npm:x"], ["update", "--bogus"]]) {
|
|
192
|
+
assert.deepEqual(bundledUpdatePlan(passthrough), { self: false, piArgs: passthrough }, passthrough.join(" "));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("bundledUpdateNext still updates extensions when the upgrade did not complete", () => {
|
|
197
|
+
assert.deepEqual(bundledUpdateNext({ self: false, piArgs: ["update", "npm:x"] }, undefined), { piArgs: ["update", "npm:x"] });
|
|
198
|
+
assert.deepEqual(bundledUpdateNext({ self: true }, 0), { exitCode: 0 });
|
|
199
|
+
assert.deepEqual(bundledUpdateNext({ self: true }, 1), { exitCode: 1 });
|
|
200
|
+
assert.deepEqual(bundledUpdateNext({ self: true }, undefined), { exitCode: 0 });
|
|
201
|
+
assert.deepEqual(bundledUpdateNext({ self: true, piArgs: ["update", "--extensions"] }, 0), { piArgs: ["update", "--extensions"] });
|
|
202
|
+
const failed = bundledUpdateNext({ self: true, piArgs: ["update", "--extensions"] }, 1);
|
|
203
|
+
assert.deepEqual("piArgs" in failed ? failed.piArgs : [], ["update", "--extensions"]);
|
|
204
|
+
assert.match("note" in failed ? String(failed.note) : "", /upgrade did not complete.*extensions/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => {
|
|
208
|
+
const interactive = bundledUpdateNotice({ piVersion: "1.2.0", interactive: true }).join("\n");
|
|
209
|
+
assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
|
|
210
|
+
assert.match(interactive, /Running 'gentle-shell upgrade'/);
|
|
211
|
+
assert.match(bundledUpdateNotice({ piVersion: "1.2.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("parseBundledUpgradeArgs accepts the release channel and --rollback and refuses the main channel", () => {
|
|
215
|
+
assert.deepEqual(parseBundledUpgradeArgs([]), { action: "upgrade" });
|
|
216
|
+
assert.deepEqual(parseBundledUpgradeArgs(["--channel", "release"]), { action: "upgrade" });
|
|
217
|
+
assert.deepEqual(parseBundledUpgradeArgs(["--channel=release"]), { action: "upgrade" });
|
|
218
|
+
assert.deepEqual(parseBundledUpgradeArgs(["--rollback"]), { action: "rollback" });
|
|
219
|
+
for (const args of [["--channel", "main"], ["--channel=main"]]) {
|
|
220
|
+
const parsed = parseBundledUpgradeArgs(args);
|
|
221
|
+
assert.equal("error" in parsed && parsed.usage, false);
|
|
222
|
+
assert.match("error" in parsed ? parsed.error : "", /release channel only/);
|
|
223
|
+
}
|
|
224
|
+
for (const args of [["--rollback", "--channel", "release"], ["--channel"], ["--nope"]]) {
|
|
225
|
+
const parsed = parseBundledUpgradeArgs(args);
|
|
226
|
+
assert.equal("error" in parsed && parsed.usage, true, args.join(" "));
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// The real bin, run from a bundled prefix: a copy of bin/ and package.json
|
|
231
|
+
// placed where pnpm puts gentle-pi (runtime/ and scripts/ linked from this
|
|
232
|
+
// checkout), a fake exact Pi beside it, and `current` naming the version.
|
|
233
|
+
const repository = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
234
|
+
const posixHost = process.platform === "win32" ? "the bundled prefix fixture uses a POSIX current symlink" : false;
|
|
235
|
+
const SHELL = JSON.parse(readFileSync(join(repository, "package.json"), "utf8")).version;
|
|
236
|
+
const FIXTURE_ID = `${SHELL}-1.2.0`;
|
|
237
|
+
const FAKE_PI = [
|
|
238
|
+
"import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join, dirname} from 'node:path';",
|
|
239
|
+
"const args = process.argv.slice(2);",
|
|
240
|
+
"if (args[0] === 'install' && args[1] === 'npm:gentle-engram@0.4.2' && process.env.FIXTURE_INSTALL_CAPABILITY !== '0') {",
|
|
241
|
+
" const home = process.env.PI_CODING_AGENT_DIR; const metadata = join(home, 'npm/node_modules/gentle-engram/package.json'); mkdirSync(dirname(metadata), {recursive:true});",
|
|
242
|
+
" writeFileSync(metadata, JSON.stringify({name:'gentle-engram',version:'0.4.2',engram:{startupPolicy:1}}));",
|
|
243
|
+
" const path = join(home,'settings.json'); let settings={}; try {settings=JSON.parse(readFileSync(path,'utf8'));} catch {}",
|
|
244
|
+
" if (process.env.FIXTURE_INSTALL_CAPABILITY !== 'metadata-only') { const packages = settings.packages ?? []; const index = packages.findIndex(p => /^npm:gentle-engram(?:@|$)/.test(typeof p === 'string' ? p : p.source));",
|
|
245
|
+
" if (index < 0) packages.push(args[1]); else packages[index] = typeof packages[index] === 'string' ? args[1] : {...packages[index], source:args[1]}; settings.packages = packages; writeFileSync(path, JSON.stringify(settings)); }",
|
|
246
|
+
"}",
|
|
247
|
+
"if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
|
|
248
|
+
// Stands in for gentle-engram creating the store through the bundled core during the session.
|
|
249
|
+
"if (process.env.FIXTURE_CREATE_STORE && args[0] !== 'install' && args[0] !== 'list') {",
|
|
250
|
+
" const data = join(process.env.HOME, '.engram');",
|
|
251
|
+
" if (process.env.ENGRAM_BIN?.includes('engram-guard') !== true) { mkdirSync(data, {recursive:true});",
|
|
252
|
+
" if (!existsSync(join(data, '.instance-id'))) writeFileSync(join(data, '.instance-id'), process.env.FIXTURE_CREATE_STORE + '\\n');",
|
|
253
|
+
" const header = Buffer.alloc(4096); header.write('SQLite format 3\\0', 0, 'latin1'); header.writeUInt16BE(4096, 16); header[18] = 2; header[19] = 2;",
|
|
254
|
+
" writeFileSync(join(data, 'engram.db'), header); writeFileSync(join(data, 'engram.db-wal'), '');",
|
|
255
|
+
" }",
|
|
256
|
+
"}",
|
|
257
|
+
"const pick = ['PATH', 'GENTLE_SHELL_PI', 'GENTLE_PI_AGENTS_PI', 'PI_SKIP_VERSION_CHECK', 'npm_config_prefix', 'npm_config_registry', 'NODE_OPTIONS', 'PI_CODING_AGENT_DIR',",
|
|
258
|
+
"\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK'];",
|
|
259
|
+
"const record = join(process.env.GENTLE_SHELL_PREFIX ?? '', 'config', 'engram-provenance.json');",
|
|
260
|
+
"const provenance = (() => { try { return JSON.parse(readFileSync(record, 'utf8')); } catch { return null; } })();",
|
|
261
|
+
"console.log(JSON.stringify({ cli: process.argv[1], args, provenance, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));",
|
|
262
|
+
// Stands in for a crash, kill or power loss of the launcher before its exit handler runs.
|
|
263
|
+
"if (process.env.FIXTURE_KILL_LAUNCHER && args[0] !== 'install') process.kill(process.ppid, 'SIGKILL');",
|
|
264
|
+
].join("\n");
|
|
265
|
+
const FIXTURE_CORE = [
|
|
266
|
+
"#!/bin/sh",
|
|
267
|
+
'if [ "$1" != instance-id ]; then echo engram; exit 0; fi',
|
|
268
|
+
'[ -z "$FIXTURE_PROVISION_ENV" ] || env > "$FIXTURE_PROVISION_ENV"',
|
|
269
|
+
'[ -z "$FIXTURE_INSTANCE_ID_FAIL" ] || exit 3',
|
|
270
|
+
'mkdir -p "$HOME/.engram" || exit 1',
|
|
271
|
+
'[ -f "$HOME/.engram/.instance-id" ] || printf \'%s\\n\' "${FIXTURE_INSTANCE_ID:-0123456789abcdef0123456789abcdef}" > "$HOME/.engram/.instance-id"',
|
|
272
|
+
'cat "$HOME/.engram/.instance-id"',
|
|
273
|
+
"",
|
|
274
|
+
].join("\n");
|
|
275
|
+
|
|
276
|
+
function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
|
|
277
|
+
const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-shell-bundled-")));
|
|
278
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
279
|
+
const home = join(base, "home");
|
|
280
|
+
const root = join(home, ".gentle-shell");
|
|
281
|
+
const version = join(root, "versions", FIXTURE_ID);
|
|
282
|
+
const modules = join(version, "node_modules");
|
|
283
|
+
const shell = join(modules, ".pnpm", `gentle-pi@${SHELL}`, "node_modules", "gentle-pi");
|
|
284
|
+
mkdirSync(join(shell, "bin"), { recursive: true });
|
|
285
|
+
writeFileSync(join(shell, "bin", "gentle-shell.mjs"), readFileSync(join(repository, "bin", "gentle-shell.mjs")));
|
|
286
|
+
const guard = join(shell, "bin", "engram-guard");
|
|
287
|
+
if (existsSync(join(repository, "bin", "engram-guard"))) {
|
|
288
|
+
copyFileSync(join(repository, "bin", "engram-guard"), guard);
|
|
289
|
+
chmodSync(guard, 0o755);
|
|
290
|
+
}
|
|
291
|
+
writeFileSync(join(shell, "package.json"), readFileSync(join(repository, "package.json")));
|
|
292
|
+
for (const dir of ["runtime", "scripts"]) symlinkSync(join(repository, dir), join(shell, dir));
|
|
293
|
+
symlinkSync(shell, join(modules, "gentle-pi"));
|
|
294
|
+
const piDir = join(modules, "@earendil-works", "pi-coding-agent");
|
|
295
|
+
mkdirSync(join(piDir, "dist", "bundle"), { recursive: true });
|
|
296
|
+
writeFileSync(join(piDir, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: pi, bin: { pi: "dist/bundle/cli.js" } }));
|
|
297
|
+
writeFileSync(join(piDir, "dist", "bundle", "cli.js"), FAKE_PI);
|
|
298
|
+
writeFileSync(join(version, "package.json"), JSON.stringify({ dependencies: { "gentle-pi": SHELL, "@earendil-works/pi-coding-agent": "1.2.0" } }));
|
|
299
|
+
writeFileSync(join(version, ".gentle-shell-version"), `${FIXTURE_ID}\n`);
|
|
300
|
+
symlinkSync(`versions/${FIXTURE_ID}`, join(root, "current"));
|
|
301
|
+
// The bundled npm the npmCommand entry loads.
|
|
302
|
+
const npmCli = join(root, "runtime", "node-24.21.0", "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
|
303
|
+
mkdirSync(dirname(npmCli), { recursive: true });
|
|
304
|
+
writeFileSync(npmCli, "console.log('11.19.0');\n");
|
|
305
|
+
// The pinned Engram, published as ensureEngram leaves it (reused, never downloaded here).
|
|
306
|
+
// With `core: false` the prefix is as the wizard leaves it, without Engram.
|
|
307
|
+
const engramDir = join(root, "runtime", `engram-${engramPinVersion}`);
|
|
308
|
+
const engram = join(engramDir, "engram");
|
|
309
|
+
if (core) {
|
|
310
|
+
mkdirSync(engramDir, { recursive: true });
|
|
311
|
+
writeFileSync(engram, FIXTURE_CORE, { mode: 0o700 });
|
|
312
|
+
writeFileSync(join(engramDir, ".gentle-shell-runtime"), `${artifactFor("engram", process.platform, process.arch).url}\n`);
|
|
313
|
+
}
|
|
314
|
+
// The launcher's ensureEngram never downloads here: a module hook gives it a
|
|
315
|
+
// stand-in that publishes the same files and marker a verified download would.
|
|
316
|
+
const publisher = join(base, "engram-publisher.mjs");
|
|
317
|
+
writeFileSync(publisher, `export * from ${JSON.stringify(pathToFileURL(join(repository, "scripts", "bundled-install.mjs")).href)};
|
|
318
|
+
import { artifactFor } from ${JSON.stringify(pathToFileURL(join(repository, "scripts", "installer-downloads.mjs")).href)};
|
|
319
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
320
|
+
export async function ensureEngram({ layout, platform, arch }) {
|
|
321
|
+
if (existsSync(layout.engram)) return false;
|
|
322
|
+
mkdirSync(layout.engramDir, { recursive: true });
|
|
323
|
+
writeFileSync(layout.engram, ${JSON.stringify(FIXTURE_CORE)}, { mode: 0o700 });
|
|
324
|
+
writeFileSync(layout.engramDir + '/.gentle-shell-runtime', artifactFor('engram', platform, arch).url + '\\n');
|
|
325
|
+
return true;
|
|
326
|
+
}`);
|
|
327
|
+
const hooks = join(base, "engram-hooks.mjs");
|
|
328
|
+
writeFileSync(hooks, `export async function resolve(specifier, context, next) {
|
|
329
|
+
if (specifier.endsWith('scripts/bundled-install.mjs') && context.parentURL?.endsWith('/bin/gentle-shell.mjs')) {
|
|
330
|
+
return { url: ${JSON.stringify(pathToFileURL(publisher).href)}, shortCircuit: true };
|
|
331
|
+
}
|
|
332
|
+
return next(specifier, context);
|
|
333
|
+
}`);
|
|
334
|
+
const registerHooks = join(base, "engram-register.mjs");
|
|
335
|
+
writeFileSync(registerHooks, `import { register } from 'node:module'; register(${JSON.stringify(pathToFileURL(hooks).href)});`);
|
|
336
|
+
// A Pi on PATH and in GENTLE_SHELL_PI that must never run.
|
|
337
|
+
const userBin = join(base, "user-bin");
|
|
338
|
+
mkdirSync(userBin);
|
|
339
|
+
writeFileSync(join(userBin, "pi"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 });
|
|
340
|
+
// No test contacts the actual default Engram endpoint: connections to 7437
|
|
341
|
+
// are redirected to a closed ephemeral port (refused) or to a local fixture
|
|
342
|
+
// server (FIXTURE_ENGRAM_PORT). FIXTURE_NO_PROBE makes any probe fail the test.
|
|
343
|
+
const network = join(base, "network.mjs");
|
|
344
|
+
writeFileSync(network, `import net from 'node:net'; import { syncBuiltinESMExports } from 'node:module';
|
|
345
|
+
const original = net.createConnection;
|
|
346
|
+
const probe = net.createServer(); await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve));
|
|
347
|
+
const closed = probe.address().port; await new Promise((resolve) => probe.close(resolve));
|
|
348
|
+
net.createConnection = (options, ...rest) => {
|
|
349
|
+
if (Number(options?.port ?? options) !== 7437) return original(options, ...rest);
|
|
350
|
+
if (process.env.FIXTURE_NO_PROBE === '1') { process.stderr.write('unexpected Engram probe\\n'); process.exit(97); }
|
|
351
|
+
return original({ ...options, port: Number(process.env.FIXTURE_ENGRAM_PORT || closed) }, ...rest);
|
|
352
|
+
};
|
|
353
|
+
syncBuiltinESMExports();`);
|
|
354
|
+
const metadata = join(home, ".gentle-shell", "agent", "npm", "node_modules", "gentle-engram", "package.json");
|
|
355
|
+
mkdirSync(dirname(metadata), { recursive: true });
|
|
356
|
+
writeFileSync(metadata, JSON.stringify({ name: "gentle-engram", version: "0.4.2", engram: { startupPolicy: 1 } }));
|
|
357
|
+
const env = { HOME: home, PATH: `${userBin}:/usr/bin:/bin`, GENTLE_SHELL_PI: join(userBin, "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1",
|
|
358
|
+
NODE_OPTIONS: `--no-warnings --import=${network} --import=${registerHooks}`, npm_config_registry: "https://npm.example" };
|
|
359
|
+
const entry = join(root, "current", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs");
|
|
360
|
+
const run = (args: string[], extra: Record<string, string> = {}) => spawnSync(process.execPath, [entry, ...args], { env: { ...env, ...extra }, encoding: "utf8", input: "" });
|
|
361
|
+
return { root, home, userBin, version, piDir, npmCli, engram, metadata, network, guard, run, env, entry: join(shell, "bin", "gentle-shell.mjs") };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const STORE_ID = "0123456789abcdef0123456789abcdef";
|
|
365
|
+
const OTHER_STORE_ID = "fedcba9876543210fedcba9876543210";
|
|
366
|
+
const lastLaunch = (result: { stdout: string }) => JSON.parse(result.stdout.trim().split("\n").at(-1)!);
|
|
367
|
+
const memoryOf = (result: { stdout: string }) => [lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN];
|
|
368
|
+
const provenancePath = (prefix: { root: string }) => join(prefix.root, "config", "engram-provenance.json");
|
|
369
|
+
const sha256File = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
370
|
+
function writeHeader(path: string, userVersion: number, magic = "SQLite format 3\0") {
|
|
371
|
+
const header = Buffer.alloc(4096);
|
|
372
|
+
header.write(magic, 0, "latin1");
|
|
373
|
+
header.writeUInt16BE(4096, 16);
|
|
374
|
+
header[18] = 2;
|
|
375
|
+
header[19] = 2;
|
|
376
|
+
header.writeUInt32BE(userVersion, 60);
|
|
377
|
+
writeFileSync(path, header);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// A local fixture standing in for a running Engram server on an ephemeral port;
|
|
381
|
+
// the launcher's probe of the default port is redirected to it.
|
|
382
|
+
async function engramServer(t: TestContext, routes: Record<string, [number, unknown]>): Promise<string> {
|
|
383
|
+
const script = `const routes = ${JSON.stringify(routes)};
|
|
384
|
+
require('node:http').createServer((request, response) => { const [status, body] = routes[request.url] ?? [404, { error: 'not found' }];
|
|
385
|
+
response.writeHead(status, { 'content-type': 'application/json' }); response.end(JSON.stringify(body)); })
|
|
386
|
+
.listen(0, '127.0.0.1', function () { console.log(this.address().port); });`;
|
|
387
|
+
const child = spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "inherit"] });
|
|
388
|
+
t.after(() => { child.kill(); });
|
|
389
|
+
return new Promise((resolve) => child.stdout.once("data", (chunk) => resolve(String(chunk).trim())));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
test("a fresh launch provisions the store identity and finalizes provenance before Pi starts", { skip: posixHost }, (t) => {
|
|
393
|
+
const prefix = bundledPrefix(t);
|
|
394
|
+
const provisioning = join(prefix.home, "provisioning-env");
|
|
395
|
+
const result = prefix.run([], { FIXTURE_PROVISION_ENV: provisioning, GH_TOKEN: "fixture-token", GITHUB_TOKEN: "fixture-token" });
|
|
396
|
+
assert.equal(result.status, 0, result.stderr);
|
|
397
|
+
const seen = lastLaunch(result);
|
|
398
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["allow", prefix.engram, undefined]);
|
|
399
|
+
// Already finalized while Pi runs, from the identity file the core wrote.
|
|
400
|
+
const record = { schema: 1, dataDir: join(prefix.home, ".engram"), instanceId: STORE_ID, coreSha256: sha256File(prefix.engram) };
|
|
401
|
+
assert.deepEqual(seen.provenance, record);
|
|
402
|
+
assert.deepEqual(JSON.parse(readFileSync(provenancePath(prefix), "utf8")), record);
|
|
403
|
+
// Only the provisioning child skips Engram's update check and loses GitHub tokens; Pi keeps the user's environment.
|
|
404
|
+
const child = Object.fromEntries(readFileSync(provisioning, "utf8").trim().split("\n").map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]));
|
|
405
|
+
assert.deepEqual([child.ENGRAM_NO_UPDATE_CHECK, child.GH_TOKEN, child.GITHUB_TOKEN, child.ENGRAM_DATA_DIR, child.HOME], ["1", undefined, undefined, undefined, prefix.home]);
|
|
406
|
+
assert.deepEqual([seen.env.GH_TOKEN, seen.env.ENGRAM_NO_UPDATE_CHECK], ["fixture-token", undefined]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("a session that never creates a database keeps Shell's identity; a foreign store with another identity is never adopted", { skip: posixHost }, (t) => {
|
|
410
|
+
const prefix = bundledPrefix(t);
|
|
411
|
+
assert.equal(prefix.run([]).status, 0);
|
|
412
|
+
const data = join(prefix.home, ".engram");
|
|
413
|
+
assert.deepEqual(readdirSync(data).sort(), [".instance-id"]);
|
|
414
|
+
assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
|
|
415
|
+
// Another Engram client replaces the shared directory with its own store.
|
|
416
|
+
rmSync(data, { recursive: true });
|
|
417
|
+
mkdirSync(data);
|
|
418
|
+
writeFileSync(join(data, ".instance-id"), `${OTHER_STORE_ID}\n`);
|
|
419
|
+
writeHeader(join(data, "engram.db"), 1);
|
|
420
|
+
const foreign = prefix.run([]);
|
|
421
|
+
assert.deepEqual(memoryOf(foreign), ["disabled", prefix.guard]);
|
|
422
|
+
assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
|
|
423
|
+
});
|
|
424
|
+
test("a later launch reuses Shell's own store, including a stale WAL header, without rewriting provenance", { skip: posixHost }, (t) => {
|
|
425
|
+
const prefix = bundledPrefix(t);
|
|
426
|
+
assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0);
|
|
427
|
+
const recorded = readFileSync(provenancePath(prefix), "utf8");
|
|
428
|
+
for (const userVersion of [0, 1]) {
|
|
429
|
+
writeHeader(join(prefix.home, ".engram", "engram.db"), userVersion);
|
|
430
|
+
const second = prefix.run([]);
|
|
431
|
+
assert.equal(second.status, 0, second.stderr);
|
|
432
|
+
const seen = lastLaunch(second);
|
|
433
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["allow", prefix.engram, undefined], String(userVersion));
|
|
434
|
+
}
|
|
435
|
+
assert.equal(readFileSync(provenancePath(prefix), "utf8"), recorded);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("provenance mismatches disable memory behind the guard: instance, core digest, newer or malformed header", { skip: posixHost }, (t) => {
|
|
439
|
+
const mismatches: Array<[string, (prefix: ReturnType<typeof bundledPrefix>) => void]> = [
|
|
440
|
+
["instance", (prefix) => writeFileSync(join(prefix.home, ".engram", ".instance-id"), `${OTHER_STORE_ID}\n`)],
|
|
441
|
+
["invalid identity", (prefix) => writeFileSync(join(prefix.home, ".engram", ".instance-id"), "not-an-id\n")],
|
|
442
|
+
["core digest", (prefix) => writeFileSync(prefix.engram, "#!/bin/sh\necho upgraded engram\n", { mode: 0o700 })],
|
|
443
|
+
["newer schema", (prefix) => writeHeader(join(prefix.home, ".engram", "engram.db"), 2)],
|
|
444
|
+
["malformed header", (prefix) => writeHeader(join(prefix.home, ".engram", "engram.db"), 1, "not sqlite at all")],
|
|
445
|
+
["symlinked database", (prefix) => {
|
|
446
|
+
const db = join(prefix.home, ".engram", "engram.db");
|
|
447
|
+
renameSync(db, `${db}.real`);
|
|
448
|
+
symlinkSync(`${db}.real`, db);
|
|
449
|
+
}],
|
|
450
|
+
["corrupt provenance", (prefix) => writeFileSync(provenancePath(prefix), "{")],
|
|
451
|
+
];
|
|
452
|
+
for (const [name, change] of mismatches) {
|
|
453
|
+
const prefix = bundledPrefix(t);
|
|
454
|
+
assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0, name);
|
|
455
|
+
change(prefix);
|
|
456
|
+
const result = prefix.run([]);
|
|
457
|
+
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
|
458
|
+
const seen = lastLaunch(result);
|
|
459
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard], name);
|
|
460
|
+
assert.match(result.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s, name);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("public help explains compatibility evidence without promising update recovery", { skip: posixHost }, (t) => {
|
|
465
|
+
const prefix = bundledPrefix(t);
|
|
466
|
+
const result = prefix.run(["--help"]);
|
|
467
|
+
assert.equal(result.status, 0, result.stderr);
|
|
468
|
+
assert.equal(result.stderr, "");
|
|
469
|
+
assert.match(result.stdout, /Matching versions or updating alone do not prove compatibility/);
|
|
470
|
+
assert.match(result.stdout, /\/compatibility/);
|
|
471
|
+
assert.doesNotMatch(result.stdout, /update Engram, restart/);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("a foreign pre-existing store stays disabled and never receives provenance", { skip: posixHost }, (t) => {
|
|
475
|
+
const prefix = bundledPrefix(t);
|
|
476
|
+
const data = join(prefix.home, ".engram");
|
|
477
|
+
mkdirSync(data);
|
|
478
|
+
writeFileSync(join(data, ".instance-id"), `${STORE_ID}\n`);
|
|
479
|
+
writeHeader(join(data, "engram.db"), 1);
|
|
480
|
+
const result = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
|
|
481
|
+
assert.equal(result.status, 0, result.stderr);
|
|
482
|
+
assert.deepEqual([lastLaunch(result).env.ENGRAM_STARTUP_POLICY, lastLaunch(result).env.ENGRAM_BIN], ["disabled", prefix.guard]);
|
|
483
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("a listening server is reused for Shell's store only with the recorded /health instance and the exact core version", { skip: posixHost }, async (t) => {
|
|
487
|
+
const cases: Array<[string, unknown, string]> = [
|
|
488
|
+
["match", { status: "ok", instance_id: STORE_ID, version: engramPinVersion }, "allow"],
|
|
489
|
+
["other instance", { status: "ok", instance_id: OTHER_STORE_ID, version: engramPinVersion }, "disabled"],
|
|
490
|
+
["other version", { status: "ok", instance_id: STORE_ID, version: "9.9.9" }, "disabled"],
|
|
491
|
+
];
|
|
492
|
+
for (const [name, health, policy] of cases) {
|
|
493
|
+
const prefix = bundledPrefix(t);
|
|
494
|
+
assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0, name);
|
|
495
|
+
const port = await engramServer(t, { "/health": [200, health] });
|
|
496
|
+
const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
|
|
497
|
+
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
|
498
|
+
const seen = lastLaunch(result);
|
|
499
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL],
|
|
500
|
+
[policy, policy === "allow" ? prefix.engram : prefix.guard, undefined], name);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("a verified /compatibility server is reused through an explicit URL and the guard, without provenance", { skip: posixHost }, async (t) => {
|
|
505
|
+
const ready = { revision: 1, source: "active", status: "ready", offline_automatic_opening_authorized: false, active_reuse_authorized: true,
|
|
506
|
+
instance_id: STORE_ID, runtime_id: "a".repeat(32), capability: "startup-verified-store-v1" };
|
|
507
|
+
const prefix = bundledPrefix(t);
|
|
508
|
+
const data = join(prefix.home, ".engram");
|
|
509
|
+
mkdirSync(data);
|
|
510
|
+
writeHeader(join(data, "engram.db"), 1);
|
|
511
|
+
const port = await engramServer(t, { "/compatibility": [200, ready] });
|
|
512
|
+
const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
|
|
513
|
+
assert.equal(result.status, 0, result.stderr);
|
|
514
|
+
const seen = lastLaunch(result);
|
|
515
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_URL, seen.env.ENGRAM_BIN], ["allow", "http://127.0.0.1:7437", prefix.guard]);
|
|
516
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
517
|
+
const unready = bundledPrefix(t);
|
|
518
|
+
mkdirSync(join(unready.home, ".engram"));
|
|
519
|
+
const notReady = await engramServer(t, { "/compatibility": [200, { ...ready, status: "unknown", active_reuse_authorized: false }] });
|
|
520
|
+
assert.equal(lastLaunch(unready.run([], { FIXTURE_ENGRAM_PORT: notReady })).env.ENGRAM_STARTUP_POLICY, "disabled");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("a foreign store behind a 3.3.2 server stays guarded and the launch names the server version to update", { skip: posixHost }, async (t) => {
|
|
524
|
+
const prefix = bundledPrefix(t);
|
|
525
|
+
const data = join(prefix.home, ".engram");
|
|
526
|
+
mkdirSync(data);
|
|
527
|
+
writeHeader(join(data, "engram.db"), 1);
|
|
528
|
+
const before = readFileSync(join(data, "engram.db"));
|
|
529
|
+
// Engram 3.3.2 has no /compatibility route (the fixture answers 404).
|
|
530
|
+
const port = await engramServer(t, { "/health": [200, { status: "ok", service: "engram", version: "3.3.2", instance_id: OTHER_STORE_ID }] });
|
|
531
|
+
const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
|
|
532
|
+
assert.equal(result.status, 0, result.stderr);
|
|
533
|
+
const seen = lastLaunch(result);
|
|
534
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
|
|
535
|
+
assert.match(result.stderr, /memory disabled.*reports version 3\.3\.2.*update that server to 3\.4\.0 or later/s);
|
|
536
|
+
assert.deepEqual(readFileSync(join(data, "engram.db")), before);
|
|
537
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("a fresh launch whose launcher is killed during Pi already has finalized provenance, and the next launch allows memory", { skip: posixHost }, (t) => {
|
|
541
|
+
const prefix = bundledPrefix(t);
|
|
542
|
+
const first = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID, FIXTURE_KILL_LAUNCHER: "1" });
|
|
543
|
+
assert.equal(first.signal, "SIGKILL");
|
|
544
|
+
assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
|
|
545
|
+
assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
|
|
546
|
+
});
|
|
547
|
+
test("a failed identity provisioning disables memory for that launch and records nothing", { skip: posixHost }, (t) => {
|
|
548
|
+
const cases: Array<[string, Record<string, string>, (prefix: ReturnType<typeof bundledPrefix>) => void]> = [
|
|
549
|
+
["instance-id failure", { FIXTURE_INSTANCE_ID_FAIL: "1" }, () => {}],
|
|
550
|
+
["invalid identity file", { FIXTURE_INSTANCE_ID: "not-an-id" }, () => {}],
|
|
551
|
+
["provenance write failure", {}, (prefix) => mkdirSync(provenancePath(prefix), { recursive: true })],
|
|
552
|
+
];
|
|
553
|
+
for (const [name, extra, prepare] of cases) {
|
|
554
|
+
const prefix = bundledPrefix(t);
|
|
555
|
+
prepare(prefix);
|
|
556
|
+
const result = prefix.run([], extra);
|
|
557
|
+
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
|
558
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard], name);
|
|
559
|
+
assert.match(result.stderr, /could not provision/, name);
|
|
560
|
+
assert.equal(lastLaunch(result).provenance, null, name);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("--version and forwarded Pi version or help flags never probe Engram, install the companion or print memory notices", { skip: posixHost }, (t) => {
|
|
565
|
+
const prefix = bundledPrefix(t);
|
|
566
|
+
writeFileSync(prefix.metadata, '{"name":"gentle-engram","version":"0.4.1"}');
|
|
567
|
+
mkdirSync(join(prefix.home, ".engram"));
|
|
568
|
+
assert.equal(prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "0" }).status, 0);
|
|
569
|
+
const settings = join(prefix.home, ".gentle-shell", "agent", "settings.json");
|
|
570
|
+
const before = readFileSync(settings, "utf8");
|
|
571
|
+
for (const args of [["--version"], ["--", "--version"], ["--", "-v"], ["--", "--help"], ["--", "explain this", "-h"]]) {
|
|
572
|
+
const result = prefix.run(args, { FIXTURE_NO_PROBE: "1" });
|
|
573
|
+
assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`);
|
|
574
|
+
assert.doesNotMatch(result.stderr, /memory disabled|installing policy-capable|startupPolicy/, args.join(" "));
|
|
575
|
+
assert.equal(readFileSync(settings, "utf8"), before, args.join(" "));
|
|
576
|
+
assert.equal(JSON.parse(readFileSync(prefix.metadata, "utf8")).version, "0.4.1", args.join(" "));
|
|
577
|
+
}
|
|
578
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("-v or --help as a prompt or an option value never switches memory off", { skip: posixHost }, (t) => {
|
|
582
|
+
for (const args of [["--", "--", "-v"], ["--", "--model", "-v", "hello"], ["--", "--name", "--help"], ["--", "explain the -v flag"]]) {
|
|
583
|
+
const prefix = bundledPrefix(t);
|
|
584
|
+
const result = prefix.run(args);
|
|
585
|
+
assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`);
|
|
586
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.engram], args.join(" "));
|
|
587
|
+
assert.equal(lastLaunch(result).provenance.instanceId, STORE_ID, args.join(" "));
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
test("a missing bundled core uses the guard, never an engram on PATH", { skip: posixHost }, (t) => {
|
|
591
|
+
const prefix = bundledPrefix(t);
|
|
592
|
+
rmSync(prefix.engram);
|
|
593
|
+
const marker = join(prefix.home, "path-engram-ran");
|
|
594
|
+
writeFileSync(join(prefix.userBin, "engram"), `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 });
|
|
595
|
+
const result = prefix.run([]);
|
|
596
|
+
assert.equal(result.status, 0, result.stderr);
|
|
597
|
+
const seen = lastLaunch(result);
|
|
598
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard]);
|
|
599
|
+
assert.match(seen.env.ENGRAM_STARTUP_NOTICE, /bundled Engram core/);
|
|
600
|
+
assert.equal(spawnSync(seen.env.ENGRAM_BIN, ["instance-id"], { env: { ...prefix.env, ...seen.env } }).status === 0, false);
|
|
601
|
+
assert.equal(existsSync(marker), false);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// A Gentle AI stand-in for setup: counts its runs and changes nothing else.
|
|
605
|
+
function countingGentleAi(prefix: ReturnType<typeof bundledPrefix>) {
|
|
606
|
+
const log = join(prefix.home, "installer-runs");
|
|
607
|
+
const bin = join(prefix.userBin, "gentle-ai");
|
|
608
|
+
writeFileSync(bin, `#!/bin/sh\necho run >> '${log}'\n`, { mode: 0o755 });
|
|
609
|
+
return { bin, runs: () => (existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean).length : 0) };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
test("setup on a fresh machine publishes the bundled core; the next launch is fresh, records provenance and is reused", { skip: posixHost }, (t) => {
|
|
613
|
+
const prefix = bundledPrefix(t, { core: false });
|
|
614
|
+
const installer = countingGentleAi(prefix);
|
|
615
|
+
const setup = prefix.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: installer.bin });
|
|
616
|
+
assert.equal(setup.status, 0, setup.stderr);
|
|
617
|
+
assert.equal(installer.runs(), 1);
|
|
618
|
+
assert.equal(existsSync(prefix.engram), true);
|
|
619
|
+
const first = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
|
|
620
|
+
assert.equal(first.status, 0, first.stderr);
|
|
621
|
+
assert.deepEqual([lastLaunch(first).env.ENGRAM_STARTUP_POLICY, lastLaunch(first).env.ENGRAM_BIN], ["allow", prefix.engram]);
|
|
622
|
+
assert.equal(JSON.parse(readFileSync(provenancePath(prefix), "utf8")).instanceId, STORE_ID);
|
|
623
|
+
const second = prefix.run([]);
|
|
624
|
+
assert.equal(second.status, 0, second.stderr);
|
|
625
|
+
assert.deepEqual([lastLaunch(second).env.ENGRAM_STARTUP_POLICY, lastLaunch(second).env.ENGRAM_BIN], ["allow", prefix.engram]);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("an explicit ENGRAM_URL alone keeps the verified core, or the guard without it, as ENGRAM_BIN", { skip: posixHost }, (t) => {
|
|
629
|
+
const prefix = bundledPrefix(t);
|
|
630
|
+
const url = { ENGRAM_URL: "https://do-not-contact.invalid" };
|
|
631
|
+
const seen = lastLaunch(prefix.run([], url)).env;
|
|
632
|
+
assert.deepEqual([seen.ENGRAM_BIN, seen.ENGRAM_URL, seen.ENGRAM_STARTUP_POLICY], [prefix.engram, url.ENGRAM_URL, "disabled"]);
|
|
633
|
+
rmSync(prefix.engram);
|
|
634
|
+
assert.equal(lastLaunch(prefix.run([], url)).env.ENGRAM_BIN, prefix.guard);
|
|
635
|
+
assert.equal(lastLaunch(prefix.run([], { ...url, ENGRAM_BIN: "/chosen/binary" })).env.ENGRAM_BIN, "/chosen/binary");
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("a failed companion check after setup is a warning, not a setup failure", { skip: posixHost }, (t) => {
|
|
639
|
+
const prefix = bundledPrefix(t);
|
|
640
|
+
writeFileSync(prefix.metadata, '{"name":"gentle-engram","version":"0.4.1"}');
|
|
641
|
+
const data = join(prefix.home, ".engram");
|
|
642
|
+
mkdirSync(data);
|
|
643
|
+
writeFileSync(join(data, "engram.db"), "untouched database sentinel");
|
|
644
|
+
const installer = countingGentleAi(prefix);
|
|
645
|
+
const setup = prefix.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: installer.bin, FIXTURE_INSTALL_CAPABILITY: "0" });
|
|
646
|
+
assert.equal(setup.status, 0, setup.stderr);
|
|
647
|
+
assert.equal(installer.runs(), 1);
|
|
648
|
+
assert.match(setup.stderr, /startupPolicy/);
|
|
649
|
+
assert.deepEqual(memoryOf(prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "0" })), ["disabled", prefix.guard]);
|
|
650
|
+
assert.deepEqual(readdirSync(data), ["engram.db"]);
|
|
651
|
+
assert.equal(readFileSync(join(data, "engram.db"), "utf8"), "untouched database sentinel");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("setup with a foreign existing store runs the installer like the baseline, while memory stays behind the guard", { skip: posixHost }, (t) => {
|
|
655
|
+
const prefix = bundledPrefix(t, { core: false });
|
|
656
|
+
const data = join(prefix.home, ".engram");
|
|
657
|
+
mkdirSync(data);
|
|
658
|
+
writeFileSync(join(data, "engram.db"), "untouched database sentinel");
|
|
659
|
+
const installer = countingGentleAi(prefix);
|
|
660
|
+
const setup = prefix.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: installer.bin });
|
|
661
|
+
assert.equal(setup.status, 0, setup.stderr);
|
|
662
|
+
assert.equal(installer.runs(), 1);
|
|
663
|
+
assert.equal(existsSync(prefix.engram), true);
|
|
664
|
+
assert.match(setup.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s);
|
|
665
|
+
assert.doesNotMatch(setup.stderr, /update Engram and restart/);
|
|
666
|
+
assert.doesNotMatch(setup.stderr, /deferred/);
|
|
667
|
+
const guarded = prefix.run([]);
|
|
668
|
+
assert.equal(guarded.status, 0, guarded.stderr);
|
|
669
|
+
const env = lastLaunch(guarded).env;
|
|
670
|
+
assert.deepEqual([env.ENGRAM_STARTUP_POLICY, env.ENGRAM_BIN, env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
|
|
671
|
+
// Subagents receive the same environment (GENTLE_PI_AGENTS_PI runs the same Pi with it).
|
|
672
|
+
assert.equal(env.GENTLE_PI_AGENTS_PI.endsWith(join("dist", "bundle", "cli.js")), true);
|
|
673
|
+
const provider = spawnSync(env.ENGRAM_BIN, ["instance-id"], { env: { ...prefix.env, ...env }, encoding: "utf8" });
|
|
674
|
+
assert.notEqual(provider.status, 0);
|
|
675
|
+
assert.match(provider.stderr, /Back up/);
|
|
676
|
+
const result = prefix.run([], { ENGRAM_BIN: "/chosen/binary", ENGRAM_URL: "https://do-not-contact.invalid" });
|
|
677
|
+
assert.equal(result.status, 0, result.stderr);
|
|
678
|
+
const seen = lastLaunch(result);
|
|
679
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["disabled", "/chosen/binary", "https://do-not-contact.invalid"]);
|
|
680
|
+
assert.equal(readFileSync(join(data, "engram.db"), "utf8"), "untouched database sentinel");
|
|
681
|
+
assert.deepEqual(readdirSync(data), ["engram.db"]);
|
|
682
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("a gentle-pi version change re-provisions Shell's own store once and keeps its memory", { skip: posixHost }, (t) => {
|
|
686
|
+
const prefix = bundledPrefix(t, { core: false });
|
|
687
|
+
const installer = countingGentleAi(prefix);
|
|
688
|
+
const auto = { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: installer.bin };
|
|
689
|
+
const first = prefix.run([], { ...auto, FIXTURE_CREATE_STORE: STORE_ID });
|
|
690
|
+
assert.equal(first.status, 0, first.stderr);
|
|
691
|
+
assert.equal(installer.runs(), 1);
|
|
692
|
+
assert.deepEqual([lastLaunch(first).env.ENGRAM_STARTUP_POLICY, lastLaunch(first).env.ENGRAM_BIN], ["allow", prefix.engram]);
|
|
693
|
+
assert.equal(existsSync(provenancePath(prefix)), true);
|
|
694
|
+
assert.equal(prefix.run([], auto).status, 0);
|
|
695
|
+
assert.equal(installer.runs(), 1);
|
|
696
|
+
const manifest = join(dirname(dirname(prefix.entry)), "package.json");
|
|
697
|
+
writeFileSync(manifest, JSON.stringify({ ...JSON.parse(readFileSync(manifest, "utf8")), version: "999.0.0" }));
|
|
698
|
+
const changed = prefix.run([], auto);
|
|
699
|
+
assert.equal(changed.status, 0, changed.stderr);
|
|
700
|
+
assert.equal(installer.runs(), 2);
|
|
701
|
+
assert.doesNotMatch(changed.stderr, /automatic setup failed|deferred/);
|
|
702
|
+
assert.deepEqual([lastLaunch(changed).env.ENGRAM_STARTUP_POLICY, lastLaunch(changed).env.ENGRAM_BIN], ["allow", prefix.engram]);
|
|
703
|
+
assert.equal(prefix.run([], auto).status, 0);
|
|
704
|
+
assert.equal(installer.runs(), 2);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("an unsupported companion is pinned through Pi configuration; a failed readback warns and the guard still protects memory", { skip: posixHost }, (t) => {
|
|
708
|
+
const prefix = bundledPrefix(t);
|
|
709
|
+
writeFileSync(prefix.metadata, '{"name":"gentle-engram","version":"0.4.1"}');
|
|
710
|
+
mkdirSync(join(prefix.home, ".engram"));
|
|
711
|
+
const result = prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "0" });
|
|
712
|
+
assert.equal(result.status, 0, result.stderr);
|
|
713
|
+
assert.match(result.stderr, /install.*npm:gentle-engram@0.4.2/s);
|
|
714
|
+
assert.match(result.stderr, /startupPolicy/);
|
|
715
|
+
assert.equal(lastLaunch(result).env.ENGRAM_BIN, prefix.guard);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("capability readback also requires Pi to declare the selected pinned companion", { skip: posixHost }, (t) => {
|
|
719
|
+
const prefix = bundledPrefix(t);
|
|
720
|
+
const result = prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "metadata-only" });
|
|
721
|
+
assert.equal(result.status, 0, result.stderr);
|
|
722
|
+
assert.match(result.stderr, /startupPolicy/);
|
|
723
|
+
// Only the identity Shell provisions before Pi: no database is opened.
|
|
724
|
+
assert.deepEqual(readdirSync(join(prefix.home, ".engram")), [".instance-id"]);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("pinning preserves resource filters and unrelated settings and does not reinstall next launch", { skip: posixHost }, (t) => {
|
|
728
|
+
const prefix = bundledPrefix(t);
|
|
729
|
+
const settings = join(prefix.home, ".gentle-shell", "agent", "settings.json");
|
|
730
|
+
const other = { source: "npm:unrelated", extensions: ["keep.ts"] };
|
|
731
|
+
writeFileSync(settings, JSON.stringify({ theme: "chosen", packages: [other, { source: "npm:gentle-engram@0.4.1", autoload: false, extensions: [] }] }));
|
|
732
|
+
const first = prefix.run([]);
|
|
733
|
+
assert.equal(first.status, 0, first.stderr);
|
|
734
|
+
const updated = JSON.parse(readFileSync(settings, "utf8"));
|
|
735
|
+
assert.equal(updated.theme, "chosen");
|
|
736
|
+
assert.deepEqual(updated.packages, [other, { source: "npm:gentle-engram@0.4.2", autoload: false, extensions: [] }]);
|
|
737
|
+
assert.doesNotMatch(prefix.run([]).stderr, /installing policy-capable companion/);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("final startup rechecks data created during automatic setup and records no provenance for it", { skip: posixHost }, (t) => {
|
|
741
|
+
const prefix = bundledPrefix(t);
|
|
742
|
+
const fake = join(prefix.userBin, "gentle-ai");
|
|
743
|
+
writeFileSync(fake, `#!/bin/sh\nmkdir -p "$HOME/.engram"\nprintf '${STORE_ID}\\n' > "$HOME/.engram/.instance-id"\nprintf fixture > "$HOME/.engram/engram.db"\n`, { mode: 0o755 });
|
|
744
|
+
const result = prefix.run([], { GENTLE_SHELL_NO_AUTO_SETUP: "", GENTLE_SHELL_GENTLE_AI_BIN: fake });
|
|
745
|
+
assert.equal(result.status, 0, result.stderr);
|
|
746
|
+
const seen = lastLaunch(result);
|
|
747
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard]);
|
|
748
|
+
assert.equal(readFileSync(join(prefix.home, ".engram", "engram.db"), "utf8"), "fixture");
|
|
749
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("explicit endpoints and invalid policies never trigger availability probes or override executables", { skip: posixHost }, (t) => {
|
|
753
|
+
const prefix = bundledPrefix(t);
|
|
754
|
+
for (const value of ["", "disabled", "invalid"]) {
|
|
755
|
+
const result = prefix.run([], { FIXTURE_NO_PROBE: "1", ENGRAM_STARTUP_POLICY: value, ENGRAM_URL: "https://do-not-contact.invalid", ENGRAM_BIN: "/never-execute" });
|
|
756
|
+
assert.equal(result.status, 0, result.stderr);
|
|
757
|
+
const seen = lastLaunch(result);
|
|
758
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["disabled", "/never-execute", "https://do-not-contact.invalid"]);
|
|
759
|
+
}
|
|
760
|
+
const policyOnly = prefix.run([], { FIXTURE_NO_PROBE: "1", ENGRAM_STARTUP_POLICY: "invalid" });
|
|
761
|
+
assert.equal(policyOnly.status, 0, policyOnly.stderr);
|
|
762
|
+
assert.equal(lastLaunch(policyOnly).env.ENGRAM_BIN, prefix.guard);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test("startup offer precedes Pi, not-now preserves config and omit suppresses just that release", { skip: posixHost }, (t) => {
|
|
766
|
+
const prefix = bundledPrefix(t);
|
|
767
|
+
const config = join(prefix.root, "config.json");
|
|
768
|
+
writeFileSync(config, '{"other":"preserved"}\n');
|
|
769
|
+
const preload = (latest: string) => `
|
|
770
|
+
Object.defineProperty(process.stdin, 'isTTY', {value:true});
|
|
771
|
+
Object.defineProperty(process.stdout, 'isTTY', {value:true});
|
|
772
|
+
process.stdin.setRawMode = (raw) => { process.stdin.isRaw = raw; };
|
|
773
|
+
globalThis.fetch = async () => new Response(JSON.stringify({version:${JSON.stringify(latest)}}));
|
|
774
|
+
`;
|
|
775
|
+
const run = (latest: string, input: string, args: string[] = []) => spawnSync(process.execPath, [prefix.entry, ...args], {
|
|
776
|
+
env: { ...prefix.env, NODE_OPTIONS: `${prefix.env.NODE_OPTIONS} --import=data:text/javascript,${encodeURIComponent(preload(latest))}` },
|
|
777
|
+
encoding: "utf8", input, timeout: 10000,
|
|
778
|
+
});
|
|
779
|
+
const skipped = run("999.0.0", "2\r");
|
|
780
|
+
assert.equal(skipped.status, 0, skipped.stderr);
|
|
781
|
+
assert.match(skipped.stdout, /Gentle Shell update available/);
|
|
782
|
+
assert.ok(skipped.stdout.indexOf("Gentle Shell update available") < skipped.stdout.indexOf('"cli":'));
|
|
783
|
+
assert.equal(readFileSync(config, "utf8"), '{"other":"preserved"}\n');
|
|
784
|
+
const omitted = run("999.0.0", "3\r");
|
|
785
|
+
assert.equal(omitted.status, 0, omitted.stderr);
|
|
786
|
+
assert.deepEqual(JSON.parse(readFileSync(config, "utf8")), { other: "preserved", skippedStartupRelease: "999.0.0" });
|
|
787
|
+
assert.doesNotMatch(run("999.0.0", "").stdout, /Gentle Shell update available/);
|
|
788
|
+
assert.match(run("999.0.1", "2\r").stdout, /Gentle Shell update available/);
|
|
789
|
+
const command = run("999.0.1", "", ["list"]);
|
|
790
|
+
assert.equal(command.status, 0, command.stderr);
|
|
791
|
+
assert.doesNotMatch(command.stdout, /Gentle Shell update available/);
|
|
792
|
+
assert.deepEqual(JSON.parse(command.stdout.trim().split("\n").at(-1)).args, ["list"]);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("the bundled launcher runs only its version's Pi, with the user's environment and the bundled npm in its own home", { skip: posixHost }, (t) => {
|
|
796
|
+
const prefix = bundledPrefix(t);
|
|
797
|
+
const version = prefix.run(["--version"]);
|
|
798
|
+
assert.equal(version.status, 0, version.stderr);
|
|
799
|
+
assert.match(version.stdout, new RegExp(`^gentle-shell ${SHELL.replaceAll(".", "\\.")}\npi 1\\.2\\.0\n`));
|
|
800
|
+
const launched = prefix.run(["list"]);
|
|
801
|
+
assert.equal(launched.status, 0, launched.stderr);
|
|
802
|
+
const seen = JSON.parse(launched.stdout.trim().split("\n").at(-1) ?? "{}");
|
|
803
|
+
assert.equal(seen.cli, join(realpathSync(prefix.piDir), "dist", "bundle", "cli.js"));
|
|
804
|
+
assert.deepEqual(seen.args, ["list"]);
|
|
805
|
+
assert.equal(seen.env.GENTLE_SHELL_PI, undefined);
|
|
806
|
+
assert.equal(seen.env.PI_SKIP_VERSION_CHECK, "1");
|
|
807
|
+
assert.equal(seen.env.GENTLE_PI_AGENTS_PI, `${process.execPath} ${seen.cli}`);
|
|
808
|
+
// S9: what the agent runs in the user's projects sees the user's tools and settings.
|
|
809
|
+
assert.equal(seen.env.PATH, prefix.env.PATH);
|
|
810
|
+
assert.equal(seen.env.NODE_OPTIONS, prefix.env.NODE_OPTIONS);
|
|
811
|
+
assert.equal(seen.env.npm_config_registry, "https://npm.example");
|
|
812
|
+
assert.equal(seen.env.npm_config_prefix, undefined);
|
|
813
|
+
// S9: gentle-pi's config folder inside the prefix, the pinned Engram for
|
|
814
|
+
// gentle-engram, and none of Gentle AI's own variables.
|
|
815
|
+
assert.equal(seen.env.GENTLE_SHELL_PREFIX, prefix.root);
|
|
816
|
+
assert.equal(seen.env.GENTLE_PI_CONFIG_HOME, undefined);
|
|
817
|
+
assert.equal(seen.env.ENGRAM_BIN, prefix.engram);
|
|
818
|
+
for (const name of ["GENTLE_AI_HOME", "GENTLE_AI_PI_CONFIG_DIR", "GENTLE_AI_ENGRAM_BIN", "GENTLE_AI_NO_SELF_UPDATE"]) assert.equal(seen.env[name], undefined, name);
|
|
819
|
+
// Pi's own npm is ours, configured only from the prefix, through the isolated home's settings.
|
|
820
|
+
const settings = JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8"));
|
|
821
|
+
const shim = join(prefix.root, "runtime", "node-24.21.0", "gentle-shell", "npm");
|
|
822
|
+
assert.deepEqual(settings.npmCommand.slice(0, 2), [join(prefix.root, "runtime", "node-24.21.0", "bin", "node"), shim]);
|
|
823
|
+
// The entry named npm exists before Pi is given it, and runs our npm.
|
|
824
|
+
assert.equal(spawnSync(process.execPath, [shim, "--version"], { encoding: "utf8" }).stdout, "11.19.0\n");
|
|
825
|
+
assert.ok(settings.npmCommand.includes(`--userconfig=${join(prefix.root, "pnpm", "config", "npmrc")}`));
|
|
826
|
+
assert.equal(settings.npmCommand.some((part: string) => part.startsWith("--prefix")), false);
|
|
827
|
+
// --link is the user's own Pi home: never given our npm.
|
|
828
|
+
const linked = join(prefix.home, ".pi", "agent");
|
|
829
|
+
mkdirSync(linked, { recursive: true });
|
|
830
|
+
writeFileSync(join(linked, "settings.json"), "{}\n");
|
|
831
|
+
assert.equal(prefix.run(["--link", "list"]).status, 0);
|
|
832
|
+
assert.equal(readFileSync(join(linked, "settings.json"), "utf8"), "{}\n");
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// S9: `gentle-shell setup` gives the pinned Gentle AI the prefix's state folder,
|
|
836
|
+
// gentle-pi config folder and Engram, and no self-update; the personas and the
|
|
837
|
+
// managed-asset record it may rewrite are put back.
|
|
838
|
+
test("bundled setup runs Gentle AI with the prefix's isolation variables and keeps both personas", { skip: posixHost }, (t) => {
|
|
839
|
+
const prefix = bundledPrefix(t);
|
|
840
|
+
const probe = join(prefix.home, "gentle-ai-env.txt");
|
|
841
|
+
const fakeGentleAi = join(prefix.userBin, "gentle-ai");
|
|
842
|
+
// Writes what a Gentle AI with and without #5462 would: the prefix persona and the user's.
|
|
843
|
+
// Without the variable (a launcher that does not set it) it writes inside the temporary HOME, never elsewhere.
|
|
844
|
+
writeFileSync(fakeGentleAi, ["#!/bin/sh", `env > "${probe}"`, 'config="${GENTLE_AI_PI_CONFIG_DIR:-$HOME/no-config-dir}"', 'mkdir -p "$config" "$HOME/.pi/gentle-ai"',
|
|
845
|
+
'echo rewritten > "$config/persona.json"', 'echo rewritten > "$HOME/.pi/gentle-ai/persona.json"', ""].join("\n"), { mode: 0o755 });
|
|
846
|
+
const userPersona = join(prefix.home, ".pi", "gentle-ai", "persona.json");
|
|
847
|
+
const prefixPersona = join(prefix.root, "config", "gentle-ai", "persona.json");
|
|
848
|
+
for (const path of [userPersona, prefixPersona]) {
|
|
849
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
850
|
+
writeFileSync(path, "{\"mode\":\"chosen\"}\n");
|
|
851
|
+
}
|
|
852
|
+
const result = prefix.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: fakeGentleAi, ENGRAM_BIN: "", GENTLE_AI_HOME: "/home/ana/.gentle-ai" });
|
|
853
|
+
assert.equal(result.status, 0, result.stderr);
|
|
854
|
+
const seen = Object.fromEntries(readFileSync(probe, "utf8").trim().split("\n").map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]));
|
|
855
|
+
assert.equal(seen.GENTLE_AI_HOME, join(prefix.root, "gentle-ai"));
|
|
856
|
+
assert.equal(seen.GENTLE_AI_PI_CONFIG_DIR, join(prefix.root, "config", "gentle-ai"));
|
|
857
|
+
assert.equal(seen.GENTLE_AI_ENGRAM_BIN, prefix.engram);
|
|
858
|
+
// #2105: the pinned Engram is the first `engram` on Gentle AI's PATH, so no `brew` is needed.
|
|
859
|
+
assert.equal(seen.PATH.split(":")[0], dirname(prefix.engram));
|
|
860
|
+
assert.equal(seen.GENTLE_AI_NO_SELF_UPDATE, "1");
|
|
861
|
+
assert.equal(seen.PI_CODING_AGENT_DIR, join(prefix.home, ".gentle-shell", "agent"));
|
|
862
|
+
// Only setup's Gentle AI gets it, as the parent Gentle AI 4.0.0 reads it as.
|
|
863
|
+
assert.equal(seen.GENTLE_PI_CONFIG_HOME, join(prefix.root, "config"));
|
|
864
|
+
for (const path of [userPersona, prefixPersona]) assert.equal(readFileSync(path, "utf8"), "{\"mode\":\"chosen\"}\n", path);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// B2a: a Gentle AI that knows no GENTLE_AI_PI_CONFIG_DIR (the pinned 4.0.0,
|
|
868
|
+
// internal/cli/pi_background.go piBackgroundPolicyPath) writes the managed
|
|
869
|
+
// background-subagents policy to <GENTLE_PI_CONFIG_HOME>/gentle-ai, else to
|
|
870
|
+
// ~/.pi/gentle-ai. Bundled setup makes that the bundled gentle-pi's own config
|
|
871
|
+
// folder, and the user's ~/.pi/gentle-ai stays untouched.
|
|
872
|
+
test("bundled setup puts Gentle AI 4.0.0's background-subagents policy where the bundled gentle-pi reads it", { skip: posixHost }, async (t) => {
|
|
873
|
+
const prefix = bundledPrefix(t);
|
|
874
|
+
const fakeGentleAi = join(prefix.userBin, "gentle-ai");
|
|
875
|
+
writeFileSync(fakeGentleAi, ["#!/bin/sh", 'base="${GENTLE_PI_CONFIG_HOME:-$HOME/.pi}"', 'mkdir -p "$base/gentle-ai"',
|
|
876
|
+
`printf '%s' '{"schema":"gentle-pi.background-subagents/v1","policy":"on"}' > "$base/gentle-ai/background-subagents.json"`, ""].join("\n"), { mode: 0o755 });
|
|
877
|
+
const result = prefix.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: fakeGentleAi });
|
|
878
|
+
assert.equal(result.status, 0, result.stderr);
|
|
879
|
+
assert.equal(existsSync(join(prefix.home, ".pi", "gentle-ai")), false, "the user's config folder is untouched");
|
|
880
|
+
// The bundled gentle-pi's own reading: its config home is derived from the prefix.
|
|
881
|
+
setBundledInstallForTesting({ root: prefix.root });
|
|
882
|
+
t.after(() => setBundledInstallForTesting(undefined));
|
|
883
|
+
const env = { GENTLE_SHELL_PREFIX: prefix.root };
|
|
884
|
+
const resolution = resolveBackgroundSubagentsPolicy(prefix.home, { env, gentlePiConfigHome: gentlePiConfigHome(env) });
|
|
885
|
+
assert.deepEqual([resolution.policy, resolution.source], ["on", "global_file"]);
|
|
886
|
+
assert.equal(resolution.globalFile, join(prefix.root, "config", "gentle-ai", "background-subagents.json"));
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("a home holding the npmCommand written before the npm entry existed gets only that key updated", { skip: posixHost }, (t) => {
|
|
890
|
+
const prefix = bundledPrefix(t);
|
|
891
|
+
const node = join(prefix.root, "runtime", "node-24.21.0");
|
|
892
|
+
const flags = [`--userconfig=${join(prefix.root, "pnpm", "config", "npmrc")}`, `--globalconfig=${join(prefix.root, "npm", "npmrc")}`,
|
|
893
|
+
`--cache=${join(prefix.root, "npm", "cache")}`, "--update-notifier=false"];
|
|
894
|
+
// The value written before N1: npm-cli.js directly, and a --prefix flag.
|
|
895
|
+
const previous = [join(node, "bin", "node"), prefix.npmCli, ...flags.slice(0, 3), `--prefix=${join(prefix.root, "npm", "prefix")}`, flags[3]];
|
|
896
|
+
const agent = join(prefix.home, ".gentle-shell", "agent");
|
|
897
|
+
mkdirSync(agent, { recursive: true });
|
|
898
|
+
writeFileSync(join(agent, "settings.json"), `${JSON.stringify({ theme: "gentle", npmCommand: previous, packages: ["npm:x"] }, null, 2)}\n`);
|
|
899
|
+
assert.equal(prefix.run(["list"]).status, 0);
|
|
900
|
+
const settings = JSON.parse(readFileSync(join(agent, "settings.json"), "utf8"));
|
|
901
|
+
assert.deepEqual(settings, { theme: "gentle", npmCommand: [join(node, "bin", "node"), join(node, "gentle-shell", "npm"), ...flags], packages: ["npm:x"] });
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("a runtime without our npm gives Pi no npmCommand and says so", { skip: posixHost }, (t) => {
|
|
905
|
+
const prefix = bundledPrefix(t);
|
|
906
|
+
rmSync(prefix.npmCli);
|
|
907
|
+
const result = prefix.run(["list"]);
|
|
908
|
+
assert.equal(result.status, 0, result.stderr);
|
|
909
|
+
assert.match(result.stderr, /could not give Pi the bundled npm.*Bundled npm missing/);
|
|
910
|
+
const settings = join(prefix.home, ".gentle-shell", "agent", "settings.json");
|
|
911
|
+
assert.equal(existsSync(settings) && "npmCommand" in JSON.parse(readFileSync(settings, "utf8")), false);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("the version check before activation runs that version in bundled mode, never a PATH Pi", { skip: posixHost }, (t) => {
|
|
915
|
+
const prefix = bundledPrefix(t);
|
|
916
|
+
const other = join(prefix.root, "versions", "0.0.1-1.0.0");
|
|
917
|
+
mkdirSync(other);
|
|
918
|
+
writeFileSync(join(other, ".gentle-shell-version"), "0.0.1-1.0.0\n");
|
|
919
|
+
rmSync(join(prefix.root, "current"));
|
|
920
|
+
symlinkSync("versions/0.0.1-1.0.0", join(prefix.root, "current"));
|
|
921
|
+
const verify = (env: Record<string, string>) => spawnSync(process.execPath, [prefix.entry, "--version"], { env: { HOME: prefix.home, PATH: `${prefix.userBin}:/usr/bin:/bin`, ...env },
|
|
922
|
+
encoding: "utf8", input: "" });
|
|
923
|
+
const good = verify({ [BUNDLED_VERIFY_ENV]: FIXTURE_ID });
|
|
924
|
+
assert.equal(good.status, 0, good.stderr);
|
|
925
|
+
assert.match(good.stdout, /\npi 1\.2\.0\n/);
|
|
926
|
+
// The version's own Pi is broken, and the user's Pi prints the expected version.
|
|
927
|
+
writeFileSync(join(prefix.userBin, "pi"), "#!/bin/sh\necho 1.2.0\n", { mode: 0o755 });
|
|
928
|
+
rmSync(join(prefix.piDir, "dist", "bundle", "cli.js"));
|
|
929
|
+
const broken = verify({ [BUNDLED_VERIFY_ENV]: FIXTURE_ID });
|
|
930
|
+
assert.equal(broken.status, 1);
|
|
931
|
+
assert.match(broken.stderr, /bundled Pi 1\.2\.0/);
|
|
932
|
+
// Without the verification input, the same entry is an ordinary launcher that would pass.
|
|
933
|
+
assert.match(verify({}).stdout, /\npi 1\.2\.0\n/);
|
|
934
|
+
// Only --version honors it.
|
|
935
|
+
const list = spawnSync(process.execPath, [prefix.entry, "list"], { env: { HOME: prefix.home, PATH: `${prefix.userBin}:/usr/bin:/bin`, [BUNDLED_VERIFY_ENV]: FIXTURE_ID,
|
|
936
|
+
GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8", input: "" });
|
|
937
|
+
assert.equal(list.stdout, "1.2.0\n");
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
test("the bundled launcher refuses a Pi that is not the pinned one", { skip: posixHost }, (t) => {
|
|
941
|
+
const result = bundledPrefix(t, { pi: "1.0.0" }).run(["--version"]);
|
|
942
|
+
assert.equal(result.status, 1);
|
|
943
|
+
assert.match(result.stderr, new RegExp(`Gentle Shell ${FIXTURE_ID.replaceAll(".", "\\.")} .*gentle-shell upgrade`, "s"));
|
|
944
|
+
assert.doesNotMatch(result.stdout, /9\.9\.9/);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test("gentle-shell update in a bundled install explains the pinned Pi instead of updating it", { skip: posixHost }, (t) => {
|
|
948
|
+
const result = bundledPrefix(t).run(["update"]);
|
|
949
|
+
assert.equal(result.status, 0, result.stderr);
|
|
950
|
+
assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
|
|
951
|
+
assert.match(result.stdout, /Run 'gentle-shell upgrade'/);
|
|
952
|
+
assert.doesNotMatch(result.stdout, /"args"/);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
test("gentle-shell update pi --extensions in a bundled install redirects Pi's part and still updates the extensions with the bundled npm", { skip: posixHost }, (t) => {
|
|
956
|
+
const prefix = bundledPrefix(t);
|
|
957
|
+
const result = prefix.run(["update", "pi", "--extensions"]);
|
|
958
|
+
assert.equal(result.status, 0, result.stderr);
|
|
959
|
+
assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
|
|
960
|
+
const seen = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}");
|
|
961
|
+
assert.deepEqual(seen.args, ["update", "--extensions"]);
|
|
962
|
+
assert.ok(JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8")).npmCommand);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test("a kept version that current does not name keeps the ordinary Pi override and refuses an unowned update", { skip: posixHost }, (t) => {
|
|
966
|
+
const prefix = bundledPrefix(t);
|
|
967
|
+
const other = join(prefix.root, "versions", "0.0.1-1.0.0");
|
|
968
|
+
mkdirSync(other);
|
|
969
|
+
writeFileSync(join(other, ".gentle-shell-version"), "0.0.1-1.0.0\n");
|
|
970
|
+
rmSync(join(prefix.root, "current"));
|
|
971
|
+
symlinkSync("versions/0.0.1-1.0.0", join(prefix.root, "current"));
|
|
972
|
+
const options = { env: { HOME: dirname(prefix.root), PATH: "/usr/bin:/bin",
|
|
973
|
+
GENTLE_SHELL_PI: join(dirname(dirname(prefix.root)), "user-bin", "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8" as const, input: "" };
|
|
974
|
+
const version = spawnSync(process.execPath, [prefix.entry, "--version"], options);
|
|
975
|
+
assert.equal(version.status, 0, version.stderr);
|
|
976
|
+
assert.match(version.stdout, /pi 9\.9\.9/);
|
|
977
|
+
const update = spawnSync(process.execPath, [prefix.entry, "update"], options);
|
|
978
|
+
assert.equal(update.status, 1);
|
|
979
|
+
assert.equal(update.stdout, "");
|
|
980
|
+
// The fake override is a plain script: neither a Pi entry nor a package-manager shim.
|
|
981
|
+
assert.match(update.stderr, /ownership selected executable is not a Pi entry or shim.*No installation changed\./);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("gentle-shell upgrade in a bundled install refuses the main channel and rolls back to the kept version", { skip: posixHost }, (t) => {
|
|
985
|
+
const prefix = bundledPrefix(t);
|
|
986
|
+
const main = prefix.run(["upgrade", "--channel", "main"]);
|
|
987
|
+
assert.equal(main.status, 1);
|
|
988
|
+
assert.match(main.stderr, /release channel only/);
|
|
989
|
+
const none = prefix.run(["upgrade", "--rollback"]);
|
|
990
|
+
assert.equal(none.status, 1);
|
|
991
|
+
assert.match(none.stderr, /No previous Gentle Shell version/);
|
|
992
|
+
assert.equal(readlinkSync(join(prefix.root, "current")), `versions/${FIXTURE_ID}`);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// T6 (S6, S7): the bundled launcher's self-uninstall, end to end through the real bin.
|
|
996
|
+
test("gentle-shell self-uninstall in a bundled install removes the recorded PATH line and the prefix's own folders, and keeps the Gentle Shell home", { skip: posixHost }, (t) => {
|
|
997
|
+
const prefix = bundledPrefix(t);
|
|
998
|
+
const zshrc = join(prefix.home, ".zshrc");
|
|
999
|
+
const line = `export PATH="${join(prefix.root, "bin")}:$PATH" # gentle-shell bundled install`;
|
|
1000
|
+
writeFileSync(zshrc, `alias ll='ls -l'\n${line}\n`);
|
|
1001
|
+
writeFileSync(join(prefix.root, "path-entry.json"), JSON.stringify({ schema: 1, kind: "profile", path: zshrc, line, create: false, separator: false }));
|
|
1002
|
+
mkdirSync(join(prefix.root, "bin"));
|
|
1003
|
+
writeFileSync(join(prefix.root, "bin", "gentle-shell"), "#!/bin/sh\n");
|
|
1004
|
+
const agent = join(prefix.home, ".gentle-shell", "agent");
|
|
1005
|
+
mkdirSync(agent, { recursive: true });
|
|
1006
|
+
writeFileSync(join(agent, "auth.json"), "{}");
|
|
1007
|
+
const dry = prefix.run(["self-uninstall", "--dry-run"]);
|
|
1008
|
+
assert.equal(dry.status, 0, dry.stderr);
|
|
1009
|
+
assert.match(dry.stdout, /bundled install in /);
|
|
1010
|
+
assert.ok(dry.stdout.includes(`the line ${line} in ${zshrc}`));
|
|
1011
|
+
assert.equal(readFileSync(zshrc, "utf8"), `alias ll='ls -l'\n${line}\n`);
|
|
1012
|
+
const result = prefix.run(["self-uninstall", "--yes"]);
|
|
1013
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1014
|
+
assert.match(result.stdout, /Gentle Shell was removed\./);
|
|
1015
|
+
assert.equal(readFileSync(zshrc, "utf8"), "alias ll='ls -l'\n");
|
|
1016
|
+
assert.deepEqual(readdirSync(prefix.root), ["agent"]);
|
|
1017
|
+
assert.equal(readFileSync(join(agent, "auth.json"), "utf8"), "{}");
|
|
1018
|
+
// The fixture links this checkout's runtime/ and scripts/ into the version: links go, never their targets.
|
|
1019
|
+
assert.equal(existsSync(join(repository, "scripts", "bundled-install.mjs")), true);
|
|
1020
|
+
assert.equal(existsSync(join(repository, "runtime", "gentle-shell-uninstall.mjs")), true);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
// A1 (T6 verifier): a receipt naming a file the PATH plan never edits is not acted on.
|
|
1024
|
+
test("gentle-shell self-uninstall in a bundled install never edits or deletes a file a forged PATH receipt names, and says to remove the entry by hand", { skip: posixHost }, (t) => {
|
|
1025
|
+
const prefix = bundledPrefix(t);
|
|
1026
|
+
const line = `export PATH="${join(prefix.root, "bin")}:$PATH" # gentle-shell bundled install`;
|
|
1027
|
+
const dotfile = join(prefix.home, "dotfiles", "path.sh");
|
|
1028
|
+
mkdirSync(dirname(dotfile), { recursive: true });
|
|
1029
|
+
writeFileSync(dotfile, `${line}\n`);
|
|
1030
|
+
writeFileSync(join(prefix.root, "path-entry.json"), JSON.stringify({ schema: 1, kind: "profile", path: dotfile, line, create: true, separator: false }));
|
|
1031
|
+
const result = prefix.run(["self-uninstall", "--yes"]);
|
|
1032
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1033
|
+
assert.equal(readFileSync(dotfile, "utf8"), `${line}\n`);
|
|
1034
|
+
assert.match(result.stdout, /does not describe this install's PATH entry/);
|
|
1035
|
+
assert.match(result.stdout, /\(none recorded; see below\)/);
|
|
1036
|
+
});
|