gentle-pi 4.2.0 → 4.4.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 +340 -138
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +485 -7
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +645 -21
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +147 -3
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +70 -10
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -653,13 +653,14 @@ test("native Windows entry: spaces, Unicode, CMD metacharacters and early missin
|
|
|
653
653
|
const scripts = join(f.root, "scripts"); mkdirSync(scripts);
|
|
654
654
|
// Observe the copied production entry's first PowerShell handle so the
|
|
655
655
|
// existing ownership-checked guard can reap it if the fixture times out.
|
|
656
|
-
writeFileSync(join(scripts, "bootstrap.cmd"), observeStage(readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8")));
|
|
656
|
+
writeFileSync(join(scripts, "bootstrap.cmd"), observeStage(readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"), true));
|
|
657
657
|
const local = join(f.root, "home"); mkdirSync(local);
|
|
658
658
|
const env = { ...nativeEnv(f.root), LOCALAPPDATA: local };
|
|
659
659
|
// The same outer bound as every other production fragment (nativeCmd): a cold
|
|
660
660
|
// cmd.exe plus Windows PowerShell 5.1 start can exceed 5 s while the CI step runs
|
|
661
661
|
// the installer suites in parallel. The bundle stage itself has no deadline.
|
|
662
662
|
console.error("WINDOWS_ENTRY_PROBE_START timeoutMs=14000");
|
|
663
|
+
const startedAt = Date.now();
|
|
663
664
|
const started = performance.now();
|
|
664
665
|
const result = await nativeCmdFile(f.root, "scripts\\bootstrap.cmd", env, 14000);
|
|
665
666
|
console.error("WINDOWS_ENTRY_PROBE_RESULT", JSON.stringify({ elapsedMs: performance.now() - started, status: result.status, guardKilled: result.guardKilled, expectedDiagnostic: /No acquisition attempted/.test(result.stderr) }));
|
|
@@ -668,6 +669,8 @@ test("native Windows entry: spaces, Unicode, CMD metacharacters and early missin
|
|
|
668
669
|
assertNative(result, 1);
|
|
669
670
|
assert.match(result.stderr, /No acquisition attempted/);
|
|
670
671
|
assert.match(readFileSync(join(f.root, "process-records"), "utf8").trim(), /^[1-9][0-9]*\|[1-9][0-9]*$/, "the copied entry must expose its first PowerShell handle to fixture cleanup");
|
|
672
|
+
const readyMs = Number(readFileSync(join(f.root, "powershell-ready-ms"), "utf8"));
|
|
673
|
+
assert.ok(Number.isSafeInteger(readyMs) && readyMs >= startedAt && readyMs <= Date.now(), "the fixture must timestamp PowerShell executing its body");
|
|
671
674
|
assert.deepEqual(readdirSync(local), []);
|
|
672
675
|
} finally {
|
|
673
676
|
try { cleanNativeProcesses(f.root); } finally { f.cleanup(); }
|
|
@@ -1051,6 +1054,55 @@ test("the one-launch walk applies the single-path walk, per path, with its own f
|
|
|
1051
1054
|
assert.doesNotMatch(many, /Invoke-Expression|\biex\b|ScriptBlock\]::Create|EncodedCommand/);
|
|
1052
1055
|
});
|
|
1053
1056
|
|
|
1057
|
+
test("storage metadata public adapters preserve verdicts and unexpected failures", () => {
|
|
1058
|
+
const path = "D:\\metadata fixture\\tool.exe";
|
|
1059
|
+
const env = { SystemRoot: "C:\\Windows" };
|
|
1060
|
+
let calls = 0;
|
|
1061
|
+
const single = (output: string) => verifyWindowsStorage(path, env, (_command: string, _args: string[], data: Record<string, string>) => {
|
|
1062
|
+
calls++; assert.equal(data.GENTLE_WINDOWS_CHECK, path); return output;
|
|
1063
|
+
});
|
|
1064
|
+
if (process.platform === "win32") {
|
|
1065
|
+
assert.equal(single("safe"), undefined);
|
|
1066
|
+
for (const check of ["target-reparse", "parent-owner", "ancestor-acl-mask"]) {
|
|
1067
|
+
assert.throws(() => single(`unsafe:${check}|${encoded(`${path}|||`)}`), (error: Error & { check?: string; detail?: { at: string } }) => error.check === check && error.detail?.at === path);
|
|
1068
|
+
}
|
|
1069
|
+
assert.throws(() => single("malformed"), /Windows ACL evidence rejected/);
|
|
1070
|
+
assert.equal(calls, 5);
|
|
1071
|
+
const failure = Object.assign(new Error("metadata read failed"), { code: "EACCES" });
|
|
1072
|
+
assert.throws(() => verifyWindowsStorage(path, env, () => { throw failure; }), (error: unknown) => error === failure);
|
|
1073
|
+
} else {
|
|
1074
|
+
assert.throws(() => single("safe"), /Native Windows storage verification unavailable/);
|
|
1075
|
+
assert.equal(calls, 0, "unsupported platforms reject before invoking the runner");
|
|
1076
|
+
}
|
|
1077
|
+
const paths = [path, "D:\\metadata fixture\\next.cmd"];
|
|
1078
|
+
assert.deepEqual(windowsModule.verifyWindowsStorageMany(paths, env, { platform: "win32", processAdapter: (_command: string, _args: string[], data: Record<string, string>) => {
|
|
1079
|
+
assert.equal(data.GENTLE_WINDOWS_CHECKS, paths.join("|")); return "unknown\nsafe";
|
|
1080
|
+
} }), [{ check: "unchecked", at: path }, null]);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
// Inspect scripts through the public process seam; never execute PowerShell.
|
|
1084
|
+
for (const mode of ["single", "batch"] as const) {
|
|
1085
|
+
test(`storage metadata ${mode} uses throwing static attributes before ACL reads`, { skip: mode === "single" && process.platform !== "win32" ? "single-path script requires the real Windows platform" : false }, () => {
|
|
1086
|
+
let script = "";
|
|
1087
|
+
const processAdapter = (_command: string, args: string[], env: Record<string, string>) => {
|
|
1088
|
+
script = args[4];
|
|
1089
|
+
assert.equal(env[mode === "single" ? "GENTLE_WINDOWS_CHECK" : "GENTLE_WINDOWS_CHECKS"], "C:\\metadata\\node.exe");
|
|
1090
|
+
return "safe";
|
|
1091
|
+
};
|
|
1092
|
+
if (mode === "single") verifyWindowsStorage("C:\\metadata\\node.exe", { SystemRoot: "C:\\Windows" }, processAdapter);
|
|
1093
|
+
else windowsModule.verifyWindowsStorageMany(["C:\\metadata\\node.exe"], { SystemRoot: "C:\\Windows" }, { platform: "win32", processAdapter });
|
|
1094
|
+
assert.ok(script.includes("$attributes = [IO.File]::GetAttributes($path);"));
|
|
1095
|
+
const reparse = "if ($attributes -band [IO.FileAttributes]::ReparsePoint) { $detail = $path + '|||'; throw ($role + '-reparse') };";
|
|
1096
|
+
const branch = "if ($attributes -band [IO.FileAttributes]::Directory) { $acl = [IO.Directory]::GetAccessControl($path) } else { $acl = [IO.File]::GetAccessControl($path) };";
|
|
1097
|
+
assert.ok(script.includes(reparse));
|
|
1098
|
+
assert.ok(script.includes(branch));
|
|
1099
|
+
assert.ok(script.indexOf("GetAttributes($path)") < script.indexOf(reparse));
|
|
1100
|
+
assert.ok(script.indexOf(reparse) < script.indexOf(branch));
|
|
1101
|
+
assert.doesNotMatch(script, /Get-Item|\[IO\.(?:File|Directory)\]::Exists|FileInfo|DirectoryInfo|Refresh/);
|
|
1102
|
+
assert.ok(script.includes(mode === "single" ? "} else { throw } }" : "} else { 'unknown' } }"));
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1054
1106
|
// S6 decision fixtures: an in-memory folder tree and an injected walk verdict per
|
|
1055
1107
|
// path, the same way the helper tests inject storage. `weak` maps a walked path
|
|
1056
1108
|
// to the check its walk fails; every other walk passes.
|
|
@@ -1194,16 +1246,20 @@ test("production and fixture PowerShell never depend on autoloading Microsoft.Po
|
|
|
1194
1246
|
assert.match(source, /\[IO\.Directory\]::SetAccessControl\(/);
|
|
1195
1247
|
}
|
|
1196
1248
|
// Walks that may reach a file read it as a file, never through a directory API.
|
|
1197
|
-
|
|
1198
|
-
|
|
1249
|
+
assert.match(productionAclSources()[1], /if \(\$item\.PSIsContainer\) \{ \$acl = \[IO\.Directory\]::GetAccessControl\(\$path\) \} else \{ \$acl = \[IO\.File\]::GetAccessControl\(\$path\) \}/);
|
|
1250
|
+
for (const source of [productionAclSources()[2], manyCheck()]) {
|
|
1251
|
+
assert.match(source, /if \(\$attributes -band \[IO\.FileAttributes\]::Directory\) \{ \$acl = \[IO\.Directory\]::GetAccessControl\(\$path\) \} else \{ \$acl = \[IO\.File\]::GetAccessControl\(\$path\) \}/);
|
|
1199
1252
|
}
|
|
1200
1253
|
});
|
|
1201
1254
|
const psRecordLine = ' "$record = [Diagnostics.Process]::GetCurrentProcess(); [IO.File]::AppendAllText($env:GENTLE_FIXTURE_RECORDS,([string]$record.Id + [char]124 + [string]$record.StartTime.ToUniversalTime().Ticks + [Environment]::NewLine));" ^';
|
|
1202
|
-
|
|
1255
|
+
// Entry-only telemetry: this timestamps execution of the PowerShell body, not
|
|
1256
|
+
// merely process creation. Keep it separate from the guard's PID/ticks records.
|
|
1257
|
+
const psReadyLine = ' "[IO.File]::WriteAllText([IO.Path]::Combine($env:GENTLE_FIXTURE_ROOT,\'powershell-ready-ms\'),[string][DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds());" ^';
|
|
1258
|
+
function observeStage(stage: string, entryReadiness = false) {
|
|
1203
1259
|
const lines = stage.split("\n");
|
|
1204
1260
|
const index = lines.findIndex((line) => line.includes("LanguageMode"));
|
|
1205
1261
|
assert.ok(index >= 0);
|
|
1206
|
-
lines.splice(index + 1, 0, psRecordLine);
|
|
1262
|
+
lines.splice(index + 1, 0, psRecordLine, ...(entryReadiness ? [psReadyLine] : []));
|
|
1207
1263
|
return lines.join("\n");
|
|
1208
1264
|
}
|
|
1209
1265
|
function cmdComposition(stages: string[]) {
|
|
@@ -1234,6 +1290,16 @@ test("native entry observation preserves the complete production batch", () => {
|
|
|
1234
1290
|
assert.equal(observed.filter((line) => line !== psRecordLine).join("\n"), source);
|
|
1235
1291
|
});
|
|
1236
1292
|
|
|
1293
|
+
test("native entry readiness observation is opt-in and preserves production and ownership records", () => {
|
|
1294
|
+
const source = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8");
|
|
1295
|
+
const observed = observeStage(source, true).split("\n");
|
|
1296
|
+
assert.equal(observed.filter((line) => line.includes("powershell-ready-ms")).length, 1);
|
|
1297
|
+
assert.equal(observed.filter((line) => line === psRecordLine).length, 1);
|
|
1298
|
+
assert.equal(observed.filter((line) => line !== psRecordLine && !line.includes("powershell-ready-ms")).join("\n"), source);
|
|
1299
|
+
assert.doesNotMatch(observeStage(source), /powershell-ready-ms/);
|
|
1300
|
+
assert.ok(observed.findIndex((line) => line.includes("powershell-ready-ms")) > observed.indexOf(psRecordLine));
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1237
1303
|
test("native fixture compositions retain exact production fragments and no transport command", () => {
|
|
1238
1304
|
for (const marker of Object.values(stageMarkers)) {
|
|
1239
1305
|
const original = cmdStage(marker);
|
|
@@ -1301,9 +1367,17 @@ if (Test-Path -LiteralPath $env:GENTLE_FIXTURE_RECORDS) {
|
|
|
1301
1367
|
if ($reaped) { [Console]::WriteLine('fixture guard reaped residual owned process') };
|
|
1302
1368
|
exit 0;
|
|
1303
1369
|
`;
|
|
1370
|
+
const GUARD_TIMEOUT_MS = 5000;
|
|
1304
1371
|
function cleanNativeProcesses(root: string) {
|
|
1305
1372
|
const ps = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe");
|
|
1306
|
-
const
|
|
1373
|
+
const spawnGuard = (timeoutMs: number) => spawnSync(ps, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", guardCommand], { env: nativeEnv(root), timeout: timeoutMs, killSignal: "SIGKILL", encoding: "utf8", maxBuffer: 65536 });
|
|
1374
|
+
// A hosted Windows runner can cold-start PowerShell slower than any fixed
|
|
1375
|
+
// bound under load (run 40000806368: ETIMEDOUT at 5s, same commit green on
|
|
1376
|
+
// rerun). The guard's job is cleanup, not a load measurement, and the guard
|
|
1377
|
+
// is idempotent — it kills only verified owned processes and exits 0 — so a
|
|
1378
|
+
// timeout is retried once with a wider bound before failing the fixture.
|
|
1379
|
+
let result = spawnGuard(GUARD_TIMEOUT_MS);
|
|
1380
|
+
if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") result = spawnGuard(GUARD_TIMEOUT_MS * 4);
|
|
1307
1381
|
assert.equal(result.error, undefined, "fixture guard must be bounded and available");
|
|
1308
1382
|
assert.equal(result.status, 0, `fixture guard must only clean verified owned processes: ${(result.stderr ?? "").slice(0, 4000)}`);
|
|
1309
1383
|
assert.ok(["", "fixture guard reaped residual owned process"].includes(result.stdout.trim()), "unexpected fixture guard output");
|
|
@@ -1314,10 +1388,22 @@ async function nativeCmd(root: string, stages: string[], env: NodeJS.ProcessEnv
|
|
|
1314
1388
|
return nativeCmdFile(root, "fixture.cmd", env, limit);
|
|
1315
1389
|
}
|
|
1316
1390
|
async function nativeCmdFile(root: string, file: string, env: NodeJS.ProcessEnv, limit = 5000): Promise<NativeResult> {
|
|
1391
|
+
const startedAt = Date.now();
|
|
1317
1392
|
const started = performance.now();
|
|
1318
1393
|
const trace = (phase: string, detail: Record<string, unknown> = {}) => {
|
|
1319
1394
|
if (file === "scripts\\bootstrap.cmd") console.error("WINDOWS_ENTRY_PHASE", JSON.stringify({ phase, elapsedMs: performance.now() - started, limit, ...detail }));
|
|
1320
1395
|
};
|
|
1396
|
+
// Wall-clock timestamps share an epoch with PowerShell. These measurements
|
|
1397
|
+
// are diagnostic only; the monotonic outer deadline is never reset. Emit
|
|
1398
|
+
// the split before synchronous guard cleanup can inflate execution time.
|
|
1399
|
+
const traceEntryTiming = () => {
|
|
1400
|
+
if (file !== "scripts\\bootstrap.cmd") return;
|
|
1401
|
+
const readyPath = join(root, "powershell-ready-ms");
|
|
1402
|
+
const finishedAt = Date.now();
|
|
1403
|
+
const readyMs = existsSync(readyPath) ? Number(readFileSync(readyPath, "utf8")) : NaN;
|
|
1404
|
+
const valid = Number.isSafeInteger(readyMs) && readyMs >= startedAt && readyMs <= finishedAt;
|
|
1405
|
+
trace("entry-timing", { readyObserved: valid, startupMs: valid ? readyMs - startedAt : null, executionMs: valid ? finishedAt - readyMs : null });
|
|
1406
|
+
};
|
|
1321
1407
|
trace("spawn-request");
|
|
1322
1408
|
const child = spawn(join(process.env.SystemRoot!, "System32/cmd.exe"), ["/d", "/c", file], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1323
1409
|
let stdout = ""; let stderr = ""; let guardKilled = false;
|
|
@@ -1327,6 +1413,7 @@ async function nativeCmdFile(root: string, file: string, env: NodeJS.ProcessEnv,
|
|
|
1327
1413
|
const result = await new Promise<NativeResult>((resolveChild, reject) => {
|
|
1328
1414
|
const timer = setTimeout(() => {
|
|
1329
1415
|
guardKilled = true;
|
|
1416
|
+
traceEntryTiming();
|
|
1330
1417
|
trace("guard-fired", { recordExists: existsSync(join(root, "process-records")), diagnosticSeen: /No acquisition attempted/.test(stderr) });
|
|
1331
1418
|
try { cleanNativeProcesses(root); } catch (error) { guardError = error; }
|
|
1332
1419
|
child.kill("SIGKILL");
|
|
@@ -1341,7 +1428,7 @@ async function nativeCmdFile(root: string, file: string, env: NodeJS.ProcessEnv,
|
|
|
1341
1428
|
if (!/No acquisition attempted/.test(before) && /No acquisition attempted/.test(stderr)) trace("missing-bundle-diagnostic");
|
|
1342
1429
|
if (stderr.length > 1048576) { guardError = new Error("Fixture output limit"); child.kill("SIGKILL"); } });
|
|
1343
1430
|
child.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
1344
|
-
child.once("close", (status) => { trace("cmd-close", { status, guardKilled }); clearTimeout(timer); resolveChild({ status, stdout, stderr, guardKilled }); });
|
|
1431
|
+
child.once("close", (status) => { if (!guardKilled) traceEntryTiming(); trace("cmd-close", { status, guardKilled }); clearTimeout(timer); resolveChild({ status, stdout, stderr, guardKilled }); });
|
|
1345
1432
|
});
|
|
1346
1433
|
const residualReaped = cleanNativeProcesses(root);
|
|
1347
1434
|
assert.equal(residualReaped, false, "production must reap its own recorded children; fixture cleanup cannot mask a failure");
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, parse, resolve } from 'node:path';
|
|
5
|
+
import { getSystemErrorName } from 'node:util';
|
|
6
|
+
import { windowsProcessCheck, windowsBootstrapReason } from '../scripts/installer-windows.mjs';
|
|
7
|
+
|
|
8
|
+
// Only owned inert Node children. Never invoke PowerShell, ACL/storage or setup.
|
|
9
|
+
const base = resolve('odd/checks/issue-1921-windows-prerequisite-diagnostics');
|
|
10
|
+
mkdirSync(base, { recursive: true });
|
|
11
|
+
const root = mkdtempSync(join(base, 'inert-'));
|
|
12
|
+
const fixture = join(root, 'child.mjs');
|
|
13
|
+
writeFileSync(fixture, `import { writeSync } from 'node:fs';
|
|
14
|
+
const mode = process.argv[2];
|
|
15
|
+
if (mode === 'cap') { writeSync(2, 'overflow fixture\\n'); writeSync(1, 'x'.repeat(2*1024*1024)); }
|
|
16
|
+
else if (mode === 'success') { writeSync(1, ' ' + JSON.stringify({cwd:process.cwd()}) + ' \\n'); }
|
|
17
|
+
else { writeSync(1, process.argv[3]); writeSync(2, process.argv[4] ?? ''); process.exit(7); }
|
|
18
|
+
`);
|
|
19
|
+
const env = { SystemRoot: process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows',
|
|
20
|
+
PATH: dirname(process.execPath), DIAGNOSTIC_SECRET: 'must-not-retain-env' };
|
|
21
|
+
function failure(command: string, args: string[]): any {
|
|
22
|
+
let caught: any;
|
|
23
|
+
try { windowsProcessCheck(command, args, env); } catch (error) { caught = error; }
|
|
24
|
+
assert.equal(caught?.message, 'Windows prerequisite process failed');
|
|
25
|
+
assert.equal(windowsBootstrapReason(caught, 'tools-storage'), 'process-failed (tools-storage)');
|
|
26
|
+
return caught;
|
|
27
|
+
}
|
|
28
|
+
function causeOf(error: any): any {
|
|
29
|
+
assert.ok(error.cause instanceof Error, 'standard cause Error must survive existing cause.stack observer');
|
|
30
|
+
const cause = error.cause;
|
|
31
|
+
assert.deepEqual(Object.keys(cause).sort(), ['error', 'signal', 'status', 'stderr', 'stdout']);
|
|
32
|
+
assert.ok(cause.stack.length <= 8192);
|
|
33
|
+
assert.ok(cause.stdout.length <= 1024 && cause.stderr.length <= 1024);
|
|
34
|
+
assert.ok(!cause.stack.includes(root) && !cause.stack.includes('installer-windows.mjs'));
|
|
35
|
+
assert.ok(!cause.error?.message?.includes(root));
|
|
36
|
+
assert.ok(!JSON.stringify(cause).includes(env.DIAGNOSTIC_SECRET));
|
|
37
|
+
return cause;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('metadata: nonzero exit keeps bounded streams and usable cause.stack', () => {
|
|
41
|
+
const args = [fixture, 'text', 'controlled stdout\n', 'controlled stderr\n', 'private-argv-sentinel'];
|
|
42
|
+
const before = structuredClone({ args, env });
|
|
43
|
+
const error = failure(process.execPath, args), cause = causeOf(error);
|
|
44
|
+
assert.equal(cause.status, 7); assert.equal(cause.signal, null); assert.equal(cause.error, null);
|
|
45
|
+
assert.equal(cause.stdout, 'controlled stdout\n'); assert.equal(cause.stderr, 'controlled stderr\n');
|
|
46
|
+
assert.match(error.cause?.stack, /"status":7/);
|
|
47
|
+
assert.match(error.cause?.stack, /controlled stderr/);
|
|
48
|
+
assert.ok(!JSON.stringify(cause).includes('private-argv-sentinel'));
|
|
49
|
+
assert.deepEqual({ args, env }, before);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('metadata: missing synthetic executable preserves ENOENT without raw spawn error', () => {
|
|
53
|
+
const cause = causeOf(failure(join(root, 'missing.exe'), ['private-argv-sentinel']));
|
|
54
|
+
assert.equal(cause.status, null); assert.equal(cause.signal, null);
|
|
55
|
+
assert.deepEqual(Object.keys(cause.error).sort(), ['code', 'errno', 'message', 'name']);
|
|
56
|
+
assert.equal(cause.error.code, 'ENOENT'); assert.equal(cause.error.name, 'Error');
|
|
57
|
+
assert.equal(getSystemErrorName(cause.error.errno), 'ENOENT');
|
|
58
|
+
assert.ok(cause.error.message.length <= 256);
|
|
59
|
+
assert.equal(cause.stdout, ''); assert.equal(cause.stderr, '');
|
|
60
|
+
assert.match(cause.stack, /ENOENT/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('metadata: output overflow preserves ENOBUFS and actual kill signal without waiting15s', () => {
|
|
64
|
+
const cause = causeOf(failure(process.execPath, [fixture, 'cap']));
|
|
65
|
+
assert.equal(cause.status, null); assert.equal(cause.signal, 'SIGKILL');
|
|
66
|
+
assert.equal(cause.error.code, 'ENOBUFS'); assert.equal(cause.error.name, 'Error');
|
|
67
|
+
assert.equal(getSystemErrorName(cause.error.errno), 'ENOBUFS');
|
|
68
|
+
assert.equal(cause.stderr, 'overflow fixture\n');
|
|
69
|
+
assert.match(cause.stack, /ENOBUFS/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('privacy: private paths, SID/encoded ACL detail and sensitive output are suppressed; caps hold', () => {
|
|
73
|
+
const privateLines = ['visible', 'C:\\Users\\Synthetic Person\\private.txt', '/home/synthetic/private.txt',
|
|
74
|
+
'S-1-5-21-111-222-333-1001', 'DOMAIN\\synthetic', 'unsafe:target-owner|YWJjZA=='];
|
|
75
|
+
const cause = causeOf(failure(process.execPath, [fixture, 'text', privateLines.join('\n'),
|
|
76
|
+
'Authorization: Bearer synthetic-secret\ncontinuation-must-not-survive']));
|
|
77
|
+
assert.match(cause.stdout, /visible/);
|
|
78
|
+
for (const value of privateLines.slice(1)) assert.ok(!cause.stdout.includes(value));
|
|
79
|
+
assert.ok(!cause.stack.includes('synthetic-secret') && !cause.stack.includes('continuation-must-not-survive'));
|
|
80
|
+
const bounded = causeOf(failure(process.execPath, [fixture, 'text', 'safe line\n'.repeat(300), 'safe error\n'.repeat(300)]));
|
|
81
|
+
assert.equal(bounded.stdout.length, 1024); assert.equal(bounded.stderr.length, 1024);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('PR privacy: credential markers recognize underscore separators without changing API_KEY handling', () => {
|
|
85
|
+
for (const marker of ['GITHUB_TOKEN', 'PREFIX_API_KEY_SUFFIX', 'AWS_ACCESS_KEY_ID', 'API_KEY', 'access_key']) {
|
|
86
|
+
const payload = `${marker}=synthetic-value-1921\ncontinuation-value-1921`;
|
|
87
|
+
assert.equal(payload.includes('\\'), false, 'actual argv bytes must not trigger the path filter');
|
|
88
|
+
const cause = causeOf(failure(process.execPath,
|
|
89
|
+
['-e', 'process.stderr.write(process.argv[1]);process.exit(7)', payload]));
|
|
90
|
+
assert.equal(cause.status, 7);
|
|
91
|
+
assert.equal(cause.stderr, '[redacted]');
|
|
92
|
+
assert.ok(!cause.stack.includes('synthetic-value-1921') && !cause.stack.includes('continuation-value-1921'));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('PR privacy: missing bare executable identity never enters cause message or stack', () => {
|
|
97
|
+
const command = `fixture-private-executable-1921-${basename(root)}`;
|
|
98
|
+
assert.equal(parse(command).root, '', 'a path-based probe would miss the bare-name leak');
|
|
99
|
+
const cause = causeOf(failure(command, ['private-argv-sentinel']));
|
|
100
|
+
assert.equal(cause.status, null); assert.equal(cause.signal, null);
|
|
101
|
+
assert.equal(cause.error.code, 'ENOENT'); assert.equal(cause.error.name, 'Error');
|
|
102
|
+
assert.equal(getSystemErrorName(cause.error.errno), 'ENOENT');
|
|
103
|
+
assert.ok(!JSON.stringify(cause).includes(command) && !cause.stack.includes(command));
|
|
104
|
+
assert.match(cause.error.message, /ENOENT/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('legacy: neutral cwd, trimmed success and pre-spawn CMD/BAT rejection stay exact', () => {
|
|
108
|
+
assert.equal(windowsProcessCheck(process.execPath, [fixture, 'success'], env), JSON.stringify({ cwd: parse(process.execPath).root }));
|
|
109
|
+
for (const suffix of ['.CMD', '.bat']) assert.throws(() => windowsProcessCheck(join(root, `absent${suffix}`), [], env),
|
|
110
|
+
error => error instanceof Error && error.message === 'Unproven command interpreter target' && error.cause === undefined);
|
|
111
|
+
});
|
|
@@ -4,7 +4,7 @@ import * as fsPromises from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
|
-
import { CHANNEL_SCHEMA, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
|
|
7
|
+
import { CHANNEL_SCHEMA, GENTLE_AI_MODULE, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
|
|
8
8
|
import { requirements } from "../scripts/installer-preflight.mjs";
|
|
9
9
|
|
|
10
10
|
const AI_SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
|
|
@@ -19,7 +19,8 @@ type Invocation = { command: string; prefix: string[] } | null;
|
|
|
19
19
|
* `which` finds `<bin>/<name><suffix>`; `invocation`, when given, is the host's adapter. */
|
|
20
20
|
function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell: SHELL_SHA }, tools = ["go", "pnpm", "npm"], platform = "darwin",
|
|
21
21
|
bin = "/usr/bin", suffix = "", invocation = undefined as ((name: string) => Promise<Invocation>) | undefined, fs = fsPromises as typeof fsPromises,
|
|
22
|
-
migrate = undefined as ((input: { latest: string; owner: () => Promise<unknown> }) => Promise<unknown>) | undefined
|
|
22
|
+
migrate = undefined as ((input: { latest: string; owner: () => Promise<unknown> }) => Promise<unknown>) | undefined,
|
|
23
|
+
failGoInstall = false, failShellInstall = false } = {}) {
|
|
23
24
|
// owner "linked": `npm link` of a source checkout, so npm's global entry points outside npm's root.
|
|
24
25
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "upgrade-")));
|
|
25
26
|
const home = join(root, "home");
|
|
@@ -50,13 +51,22 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
|
|
|
50
51
|
}
|
|
51
52
|
calls.push({ command, argv });
|
|
52
53
|
if (argv[0] === "install" && argv[1]?.includes("gentle-ai/v4/cmd/gentle-ai@")) {
|
|
54
|
+
if (failGoInstall) return { code: 1, stdout: "" };
|
|
53
55
|
mkdirSync(options.env!.GOBIN, { recursive: true });
|
|
54
56
|
for (const name of ["gentle-ai", "gentle-ai.exe"]) writeFileSync(join(options.env!.GOBIN, name), "#!/bin/sh\n", { mode: 0o755 });
|
|
55
57
|
return { code: 0, stdout: "" };
|
|
56
58
|
}
|
|
57
59
|
if (argv[0] === "version" && command === `${bin}/go`) {
|
|
60
|
+
if (argv[1] === "-m") {
|
|
61
|
+
const commit = String(argv[2] ?? "").split("/").at(-2) ?? "";
|
|
62
|
+
return { code: 0, stdout: `${command} (binary)\n\tmod\t${GENTLE_AI_MODULE}\tv4.0.1-0.20261008202137-${commit.slice(0, 12)}\th1:J8EluS7QrfM+m3vmQlF26B/shZRjD6rBfsKtrABL5mc=\n` };
|
|
63
|
+
}
|
|
58
64
|
return { code: 0, stdout: `go version go${requirements.go} ${platform}/amd64\n` };
|
|
59
65
|
}
|
|
66
|
+
if (argv[0] === "list" && command === `${bin}/go`) {
|
|
67
|
+
const commit = String(argv[3] ?? "").slice(`${GENTLE_AI_MODULE}@`.length);
|
|
68
|
+
return { code: 0, stdout: JSON.stringify({ Path: GENTLE_AI_MODULE, Version: `v4.0.1-0.20261008202137-${commit.slice(0, 12)}`, Origin: { VCS: "git", URL: "https://github.com/gentleman-programming/gentle-ai", Hash: commit } }) };
|
|
69
|
+
}
|
|
60
70
|
if (argv[0] === "version") {
|
|
61
71
|
const sha = command.split("/").at(-2) ?? "";
|
|
62
72
|
return { code: 0, stdout: `gentle-ai 4.0.1-0.20261008202137-${sha.slice(0, 12)}\n` };
|
|
@@ -65,6 +75,7 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
|
|
|
65
75
|
writeFileSync(join(argv[argv.indexOf("-C") + 1], "package.json"), JSON.stringify({ name: "gentle-pi", version: "4.1.0", scripts: { prepack: "x" } }));
|
|
66
76
|
return { code: 0, stdout: "" };
|
|
67
77
|
}
|
|
78
|
+
if (failShellInstall && argv.includes("-g") && argv.some((arg) => arg.endsWith(".tgz"))) return { code: 1, stdout: "" };
|
|
68
79
|
if (argv.includes("pack")) {
|
|
69
80
|
const version = JSON.parse(readFileSync(join(options.cwd!, "package.json"), "utf8")).version;
|
|
70
81
|
writeFileSync(join(argv[argv.indexOf("--pack-destination") + 1], `gentle-pi-${version}.tgz`), "tgz");
|
|
@@ -186,6 +197,225 @@ test("a main install rebuilds only what moved on main and records the new commit
|
|
|
186
197
|
} finally { w.cleanup(); }
|
|
187
198
|
});
|
|
188
199
|
|
|
200
|
+
// #2356: a Gentle AI step that fails still installs a main Shell that moved, so the
|
|
201
|
+
// retry runs the new Shell's code; the channel is recorded only when both succeed.
|
|
202
|
+
const GO_INSTALL_FAILED = `main-gentle-ai-build-failed: go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@${NEW_AI} failed`;
|
|
203
|
+
const PRIOR_REGISTRATION = JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: "/prior/main/gentle-ai" });
|
|
204
|
+
const NEW_SHELL_TGZ = (home: string) => join(home, ".pi", "gentle-ai", "main", "packages", "gentle-pi-4.1.0-main.333333333333.tgz");
|
|
205
|
+
async function onPriorMain(w: ReturnType<typeof world>) {
|
|
206
|
+
await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
|
|
207
|
+
writeFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), PRIOR_REGISTRATION);
|
|
208
|
+
return () => ({ channel: readFileSync(join(w.home, ".pi", "gentle-ai", "channel.json"), "utf8"), registration: readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8") });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
test("a failed Gentle AI build still installs the main Shell that moved, records no channel and names the retry", posixOnly, async () => {
|
|
212
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true });
|
|
213
|
+
try {
|
|
214
|
+
const files = await onPriorMain(w);
|
|
215
|
+
const before = files();
|
|
216
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
217
|
+
assert.equal(error.code, "main-gentle-ai-build-failed");
|
|
218
|
+
assert.equal(error.message, `${GO_INSTALL_FAILED}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`);
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
222
|
+
assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
|
|
223
|
+
assert.deepEqual(files(), before);
|
|
224
|
+
assert.deepEqual(JSON.parse(before.registration), JSON.parse(PRIOR_REGISTRATION));
|
|
225
|
+
} finally { w.cleanup(); }
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("a Go that cannot be obtained for the Gentle AI build still installs the main Shell that moved", posixOnly, async () => {
|
|
229
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, tools: ["pnpm", "npm"] });
|
|
230
|
+
try {
|
|
231
|
+
const files = await onPriorMain(w);
|
|
232
|
+
const before = files();
|
|
233
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
234
|
+
assert.equal(error.code, "main-requires-tools");
|
|
235
|
+
assert.match(error.message, /^main-requires-tools: the main channel builds Gentle AI with Go .*Unexpected Go acquisition in sandboxed upgrade fixture.*; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish$/);
|
|
236
|
+
return true;
|
|
237
|
+
});
|
|
238
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
239
|
+
assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
|
|
240
|
+
assert.deepEqual(files(), before);
|
|
241
|
+
} finally { w.cleanup(); }
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("after a fail-forward, the retry from the new Shell builds Gentle AI and records both commits", posixOnly, async () => {
|
|
245
|
+
// The first copy of the built binary fails (as a rejected build would); the retry's succeeds.
|
|
246
|
+
let copies = 0;
|
|
247
|
+
const fs = { ...fsPromises, copyFile: async (...args: Parameters<typeof fsPromises.copyFile>) => {
|
|
248
|
+
if (copies++ === 0) throw new MainChannelError("main-gentle-ai-unverified", "first attempt rejected");
|
|
249
|
+
return fsPromises.copyFile(...args);
|
|
250
|
+
} } as typeof fsPromises;
|
|
251
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs });
|
|
252
|
+
try {
|
|
253
|
+
const files = await onPriorMain(w);
|
|
254
|
+
const before = files();
|
|
255
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => error.code === "main-gentle-ai-unverified");
|
|
256
|
+
assert.deepEqual(files(), before);
|
|
257
|
+
// The retry is simulated as the new Shell's run: the same runUpgrade with the new
|
|
258
|
+
// version (the installed package itself is not executed). No channel was recorded,
|
|
259
|
+
// so it rebuilds Gentle AI and repacks the Shell before recording both commits.
|
|
260
|
+
w.lines.length = 0;
|
|
261
|
+
assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0);
|
|
262
|
+
assert.deepEqual(w.lines, ["Updated to the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]);
|
|
263
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`, `/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
264
|
+
assert.deepEqual(await readChannel(w.ctx, fsPromises), { channel: "main", shellCommit: NEW_SHELL, gentleAiCommit: NEW_AI });
|
|
265
|
+
assert.equal(JSON.parse(readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8")).path,
|
|
266
|
+
join(w.home, ".pi", "gentle-ai", "main", "gentle-ai", NEW_AI, "gentle-ai"));
|
|
267
|
+
// A third run is a no-op: both commits are current.
|
|
268
|
+
w.lines.length = 0;
|
|
269
|
+
assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0);
|
|
270
|
+
assert.deepEqual(w.lines, ["gentle-shell is already at the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]);
|
|
271
|
+
} finally { w.cleanup(); }
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a failed Gentle AI build with the main Shell already current fails as before: nothing installed or recorded", posixOnly, async () => {
|
|
275
|
+
const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, failGoInstall: true });
|
|
276
|
+
try {
|
|
277
|
+
const files = await onPriorMain(w);
|
|
278
|
+
const before = files();
|
|
279
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
280
|
+
assert.equal(error.code, "main-gentle-ai-build-failed");
|
|
281
|
+
assert.equal(error.message, GO_INSTALL_FAILED);
|
|
282
|
+
return true;
|
|
283
|
+
});
|
|
284
|
+
assert.deepEqual(w.installs(), []);
|
|
285
|
+
assert.deepEqual(w.lines, []);
|
|
286
|
+
assert.deepEqual(files(), before);
|
|
287
|
+
} finally { w.cleanup(); }
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("when the Gentle AI build and the Shell install both fail, the Shell failure is reported with the Gentle AI one", posixOnly, async () => {
|
|
291
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true, failShellInstall: true });
|
|
292
|
+
try {
|
|
293
|
+
const files = await onPriorMain(w);
|
|
294
|
+
const before = files();
|
|
295
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
296
|
+
assert.equal(error.code, "upgrade-install-failed");
|
|
297
|
+
assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (${GO_INSTALL_FAILED})`);
|
|
298
|
+
return true;
|
|
299
|
+
});
|
|
300
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
301
|
+
assert.deepEqual(w.lines, []);
|
|
302
|
+
assert.deepEqual(files(), before);
|
|
303
|
+
} finally { w.cleanup(); }
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// An ordinary failure that is not a MainChannelError (EACCES) keeps the retry advice and both errors.
|
|
307
|
+
const eacces = (what: string) => Object.assign(new Error(`EACCES: permission denied, ${what}`), { code: "EACCES" });
|
|
308
|
+
const COPY_DENIED = eacces("copyfile");
|
|
309
|
+
const MANIFEST_DENIED = eacces("open package.json");
|
|
310
|
+
const deniedFs = ({ copy = false, manifest = false }) => ({ ...fsPromises,
|
|
311
|
+
copyFile: async (...args: Parameters<typeof fsPromises.copyFile>) => { if (copy) throw COPY_DENIED; return fsPromises.copyFile(...args); },
|
|
312
|
+
readFile: (async (path: string, options?: unknown) => {
|
|
313
|
+
if (manifest && String(path).endsWith(join(".source", "src", "package.json"))) throw MANIFEST_DENIED;
|
|
314
|
+
return fsPromises.readFile(path, options as never);
|
|
315
|
+
}) as typeof fsPromises.readFile }) as typeof fsPromises;
|
|
316
|
+
|
|
317
|
+
test("an ordinary error in the Gentle AI step still installs the main Shell that moved and names the retry", posixOnly, async () => {
|
|
318
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true }) });
|
|
319
|
+
try {
|
|
320
|
+
const files = await onPriorMain(w);
|
|
321
|
+
const before = files();
|
|
322
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
323
|
+
assert.ok(error instanceof MainChannelError);
|
|
324
|
+
assert.equal(error.code, "main-upgrade-incomplete");
|
|
325
|
+
assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, copyfile; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish");
|
|
326
|
+
assert.equal(error.cause, COPY_DENIED);
|
|
327
|
+
return true;
|
|
328
|
+
});
|
|
329
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
330
|
+
assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
|
|
331
|
+
assert.deepEqual(files(), before);
|
|
332
|
+
} finally { w.cleanup(); }
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("ordinary errors in both steps report the Shell error with the Gentle AI one", posixOnly, async () => {
|
|
336
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true, manifest: true }) });
|
|
337
|
+
try {
|
|
338
|
+
const files = await onPriorMain(w);
|
|
339
|
+
const before = files();
|
|
340
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
341
|
+
assert.ok(error instanceof MainChannelError);
|
|
342
|
+
assert.equal(error.code, "main-upgrade-incomplete");
|
|
343
|
+
assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, open package.json; Gentle AI was not updated either (EACCES: permission denied, copyfile)");
|
|
344
|
+
assert.equal(error.cause, MANIFEST_DENIED);
|
|
345
|
+
return true;
|
|
346
|
+
});
|
|
347
|
+
assert.deepEqual(w.installs(), []);
|
|
348
|
+
assert.deepEqual(w.lines, []);
|
|
349
|
+
assert.deepEqual(files(), before);
|
|
350
|
+
} finally { w.cleanup(); }
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("an ordinary error in the Gentle AI step with the main Shell already current is rethrown unchanged", posixOnly, async () => {
|
|
354
|
+
const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, fs: deniedFs({ copy: true }) });
|
|
355
|
+
try {
|
|
356
|
+
const files = await onPriorMain(w);
|
|
357
|
+
const before = files();
|
|
358
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: Error) => error === COPY_DENIED);
|
|
359
|
+
assert.deepEqual(w.installs(), []);
|
|
360
|
+
assert.deepEqual(w.lines, []);
|
|
361
|
+
assert.deepEqual(files(), before);
|
|
362
|
+
} finally { w.cleanup(); }
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// A rejection that is not an Error, even a falsy one, is still a failed Gentle AI step.
|
|
366
|
+
const rejectingCopy = (value: unknown) => ({ ...fsPromises, copyFile: async () => { throw value; } }) as typeof fsPromises;
|
|
367
|
+
|
|
368
|
+
test("a falsy rejection in the Gentle AI step still records no channel and names the retry", posixOnly, async () => {
|
|
369
|
+
for (const value of [undefined, null, 0, false]) {
|
|
370
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(value) });
|
|
371
|
+
try {
|
|
372
|
+
const files = await onPriorMain(w);
|
|
373
|
+
const before = files();
|
|
374
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
375
|
+
assert.ok(error instanceof MainChannelError);
|
|
376
|
+
assert.equal(error.code, "main-upgrade-incomplete");
|
|
377
|
+
assert.equal(error.message, `main-upgrade-incomplete: ${String(value)}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`);
|
|
378
|
+
return true;
|
|
379
|
+
});
|
|
380
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
381
|
+
assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
|
|
382
|
+
assert.deepEqual(files(), before);
|
|
383
|
+
} finally { w.cleanup(); }
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("a rejection that is not an Error but has a message keeps that message", posixOnly, async () => {
|
|
388
|
+
for (const failShellInstall of [false, true]) {
|
|
389
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy({ message: "EACCES: copy denied", code: "EACCES" }), failShellInstall });
|
|
390
|
+
try {
|
|
391
|
+
const files = await onPriorMain(w);
|
|
392
|
+
const before = files();
|
|
393
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
394
|
+
assert.equal(error.message, failShellInstall
|
|
395
|
+
? `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (EACCES: copy denied)`
|
|
396
|
+
: "main-upgrade-incomplete: EACCES: copy denied; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish");
|
|
397
|
+
return true;
|
|
398
|
+
});
|
|
399
|
+
assert.deepEqual(files(), before);
|
|
400
|
+
} finally { w.cleanup(); }
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("a null rejection in the Gentle AI step is kept when the Shell install fails too", posixOnly, async () => {
|
|
405
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(null), failShellInstall: true });
|
|
406
|
+
try {
|
|
407
|
+
const files = await onPriorMain(w);
|
|
408
|
+
const before = files();
|
|
409
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
410
|
+
assert.equal(error.code, "upgrade-install-failed");
|
|
411
|
+
assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (null)`);
|
|
412
|
+
return true;
|
|
413
|
+
});
|
|
414
|
+
assert.deepEqual(w.lines, []);
|
|
415
|
+
assert.deepEqual(files(), before);
|
|
416
|
+
} finally { w.cleanup(); }
|
|
417
|
+
});
|
|
418
|
+
|
|
189
419
|
test("a main install whose Gentle AI did not move rebuilds only Gentle Shell", posixOnly, async () => {
|
|
190
420
|
const w = world({ commits: { ai: AI_SHA, shell: NEW_SHELL } });
|
|
191
421
|
try {
|