gentle-pi 4.3.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.
Files changed (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -0,0 +1,236 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { afterEach } from "node:test";
3
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
4
+ import { resetCapabilitiesCache, setCapabilities, stripTerminalSequences as stripAnsi } from "@earendil-works/pi-tui";
5
+ import { GentlePromptEditor, imageAttachmentsWidget, modelAcceptsImages } from "../extensions/gentle-shell.ts";
6
+ import type { ClipboardImage } from "../lib/clipboard-image.ts";
7
+ import { ImageAttachments } from "../lib/image-attachments.ts";
8
+ import { createVimEditorAdapter } from "../lib/vim-editor-adapter.ts";
9
+ import { createRequire } from "node:module";
10
+
11
+ const INSTALLED_PI = (createRequire(import.meta.url)("@earendil-works/pi-tui/package.json") as { version: string }).version;
12
+
13
+ // Prompt chips for pasted images (gentle-shell#2464): the paste key inserts an
14
+ // atomic `[Image#N]`, one Backspace removes it, and Pi's own paste handler
15
+ // stays the fallback for files, text and unsupported formats.
16
+
17
+ type CtorParams = ConstructorParameters<typeof CustomEditor>;
18
+
19
+ const PNG_2X1 = new Uint8Array([
20
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
21
+ 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
22
+ 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01,
23
+ 0x08, 0x06, 0x00, 0x00, 0x00,
24
+ ]);
25
+ const PASTE_KEY = "\x1bv";
26
+ const BACKSPACE = "\x7f";
27
+ const png = (): ClipboardImage => ({ bytes: PNG_2X1, mimeType: "image/png" });
28
+ const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
29
+
30
+ afterEach(() => resetCapabilitiesCache());
31
+
32
+ function chipEditor(read: (() => Promise<ClipboardImage | null>) | undefined, attachments: ImageAttachments | undefined = new ImageAttachments()) {
33
+ const tui = { terminal: { rows: 30, columns: 100 }, requestRender() {} } as unknown as CtorParams[0];
34
+ const identity = (text: string) => text;
35
+ const theme = { borderColor: identity, selectList: {
36
+ selectedPrefix: identity, selectedText: identity, description: identity, scrollInfo: identity, noMatch: identity,
37
+ } } as unknown as CtorParams[1];
38
+ const keybindings = { matches: (data: string, action: string) => action === "app.clipboard.pasteImage" && data === PASTE_KEY } as unknown as CtorParams[2];
39
+ const editor = new GentlePromptEditor(tui, theme, keybindings, {
40
+ fg: (_role, text) => text, bold: identity, requestRender() {}, pending: () => false,
41
+ now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {},
42
+ attachments: read ? attachments : undefined, readClipboardImage: read,
43
+ notifyCompatibility: (message) => notices.push(message),
44
+ });
45
+ editor.focused = true;
46
+ const piPastes: string[] = [];
47
+ const notices: string[] = [];
48
+ // Pi's interactive mode installs its handler only when none is set yet.
49
+ if (!editor.onPasteImage) editor.onPasteImage = () => piPastes.push(editor.getText());
50
+ return { editor, attachments, piPastes, notices };
51
+ }
52
+
53
+ async function pastedChip(text: string) {
54
+ const chip = chipEditor(async () => png());
55
+ chip.editor.setText(text);
56
+ chip.editor.handleInput(PASTE_KEY);
57
+ await settle();
58
+ return chip;
59
+ }
60
+
61
+ const CTRL_W = "\x17";
62
+ const DELETE = "\x1b[3~";
63
+ const CTRL_LEFT = "\x1b[1;5D";
64
+ const HOME = "\x1b[H";
65
+
66
+ test("the paste key inserts an image chip, spaced from adjacent text", async () => {
67
+ const { editor, attachments, piPastes } = chipEditor(async () => png());
68
+ editor.setText("look");
69
+ editor.handleInput(PASTE_KEY);
70
+ await settle();
71
+ assert.equal(editor.getText(), "look [Image#1]");
72
+ assert.deepEqual(piPastes, []);
73
+ assert.equal(attachments.size, 1);
74
+ });
75
+
76
+ test("one Backspace removes the whole chip; typed look-alike text stays ordinary", async () => {
77
+ const { editor } = chipEditor(async () => png());
78
+ editor.setText("look");
79
+ editor.handleInput(PASTE_KEY);
80
+ await settle();
81
+ editor.handleInput(BACKSPACE);
82
+ assert.equal(editor.getText(), "look ");
83
+ editor.setText("[Image#7]");
84
+ editor.handleInput(BACKSPACE);
85
+ assert.equal(editor.getText(), "[Image#7");
86
+ });
87
+
88
+ test("word deletion and word motion treat a chip as one word", async () => {
89
+ const { editor } = await pastedChip("see");
90
+ assert.equal(editor.getText(), "see [Image#1]");
91
+ editor.handleInput(CTRL_W);
92
+ assert.equal(editor.getText(), "see ", "Ctrl+W removes the chip, never the word before it");
93
+ const moved = await pastedChip("see");
94
+ moved.editor.handleInput(CTRL_LEFT);
95
+ assert.deepEqual(moved.editor.getCursor(), { line: 0, col: 4 }, "Ctrl+Left stops at the chip start");
96
+ });
97
+
98
+ test("Delete in front of a chip removes the whole chip", async () => {
99
+ const { editor } = await pastedChip("");
100
+ editor.handleInput(HOME);
101
+ editor.handleInput(DELETE);
102
+ assert.equal(editor.getText(), "");
103
+ });
104
+
105
+ test("the Vim adapter keeps NORMAL boundaries out of a registered chip", async () => {
106
+ const { editor } = await pastedChip("a");
107
+ assert.deepEqual(editor.atomicSpans("a [Image#1]"), [{ start: 2, end: 11 }]);
108
+ assert.deepEqual(editor.atomicSpans("a [Image#9]"), [], "unregistered look-alikes stay ordinary text");
109
+ const adapter = createVimEditorAdapter(editor, INSTALLED_PI);
110
+ assert.deepEqual(adapter.motionBoundaries()[0], [0, 1, 2, 11]);
111
+ });
112
+
113
+ test("a clipboard read that finishes after the prompt is disposed changes nothing", async () => {
114
+ let resolve!: (image: ClipboardImage) => void;
115
+ const { editor, attachments, piPastes } = chipEditor(() => new Promise((done) => { resolve = done; }));
116
+ editor.handleInput(PASTE_KEY);
117
+ editor.dispose();
118
+ resolve(png());
119
+ await settle();
120
+ assert.equal(editor.getText(), "");
121
+ assert.equal(attachments.size, 0);
122
+ assert.deepEqual(piPastes, []);
123
+ });
124
+
125
+ test("a clipboard read that finishes after the draft was sent is discarded with a notice", async () => {
126
+ let resolve!: (image: ClipboardImage) => void;
127
+ const { editor, attachments, piPastes, notices } = chipEditor(() => new Promise((done) => { resolve = done; }));
128
+ const sent: string[] = [];
129
+ editor.onSubmit = (text) => sent.push(text);
130
+ editor.setText("what is this?");
131
+ editor.handleInput(PASTE_KEY);
132
+ editor.handleInput("\r");
133
+ resolve(png());
134
+ await settle();
135
+ assert.deepEqual(sent, ["what is this?"]);
136
+ assert.equal(editor.getText(), "", "the next draft stays empty");
137
+ assert.equal(attachments.size, 0, "no image waits to ride along with the next message");
138
+ assert.deepEqual(piPastes, []);
139
+ assert.deepEqual(notices, ["Image paste discarded: the message was sent before the clipboard finished reading. Paste again to attach it."]);
140
+ });
141
+
142
+ test("a clipboard read during an unsent draft still lands in that draft", async () => {
143
+ let resolve!: (image: ClipboardImage) => void;
144
+ const { editor, notices } = chipEditor(() => new Promise((done) => { resolve = done; }));
145
+ editor.setText("look");
146
+ editor.handleInput(PASTE_KEY);
147
+ editor.handleInput("!");
148
+ resolve(png());
149
+ await settle();
150
+ assert.equal(editor.getText(), "look! [Image#1]");
151
+ assert.deepEqual(notices, []);
152
+ });
153
+
154
+ test("a failure after the clipboard read is reported, never an unhandled rejection", async () => {
155
+ const attachments = new ImageAttachments();
156
+ attachments.add = () => { throw new Error("out of memory"); };
157
+ const { editor, notices } = chipEditor(async () => png(), attachments);
158
+ const unhandled: unknown[] = [];
159
+ const onUnhandled = (reason: unknown) => unhandled.push(reason);
160
+ process.on("unhandledRejection", onUnhandled);
161
+ try {
162
+ editor.handleInput(PASTE_KEY);
163
+ await settle();
164
+ await settle();
165
+ } finally {
166
+ process.off("unhandledRejection", onUnhandled);
167
+ }
168
+ assert.deepEqual(unhandled, []);
169
+ assert.deepEqual(notices, ["Image paste failed: out of memory"]);
170
+ assert.equal(editor.getText(), "");
171
+ });
172
+
173
+ test("without a clipboard image Pi's own paste handler runs unchanged", async () => {
174
+ const { editor, piPastes } = chipEditor(async () => null);
175
+ editor.setText("draft");
176
+ editor.handleInput(PASTE_KEY);
177
+ await settle();
178
+ assert.equal(editor.getText(), "draft");
179
+ assert.deepEqual(piPastes, ["draft"]);
180
+ const failing = chipEditor(async () => { throw new Error("clipboard busy"); });
181
+ failing.editor.handleInput(PASTE_KEY);
182
+ await settle();
183
+ assert.equal(failing.piPastes.length, 1);
184
+ });
185
+
186
+ test("a repeated paste key during a slow clipboard read inserts one chip", async () => {
187
+ let resolve!: (image: ClipboardImage) => void;
188
+ const { editor, piPastes } = chipEditor(() => new Promise((done) => { resolve = done; }));
189
+ editor.handleInput(PASTE_KEY);
190
+ editor.handleInput(PASTE_KEY);
191
+ resolve(png());
192
+ await settle();
193
+ assert.equal(editor.getText(), "[Image#1]");
194
+ assert.deepEqual(piPastes, []);
195
+ });
196
+
197
+ test("an editor without attachments keeps Pi's paste handler as a plain property", async () => {
198
+ const { editor, piPastes } = chipEditor(undefined);
199
+ editor.handleInput(PASTE_KEY);
200
+ await settle();
201
+ assert.equal(piPastes.length, 1);
202
+ assert.equal(editor.getText(), "");
203
+ });
204
+
205
+ test("the attachment strip lists chips still in the draft and falls back to text", () => {
206
+ setCapabilities({ images: null, trueColor: false, hyperlinks: false });
207
+ const attachments = new ImageAttachments();
208
+ attachments.add(png());
209
+ let draft = "see [Image#1]";
210
+ let vision = true;
211
+ const theme = { fg: (role: string, text: string) => `<${role}>${text}</${role}>` };
212
+ const strip = imageAttachmentsWidget(attachments, () => draft, theme, () => vision);
213
+ assert.deepEqual(strip.render(80), ["<accent>[Image#1]</accent> <dim>png · 2×1 · 29 B</dim>"]);
214
+ vision = false;
215
+ assert.match(strip.render(200)[0] ?? "", /<warning> · not sent: the current model does not accept images<\/warning>/);
216
+ draft = "chip deleted";
217
+ assert.deepEqual(strip.render(80), []);
218
+ });
219
+
220
+ test("terminals with an image protocol get a thumbnail under the chip label", () => {
221
+ setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
222
+ const attachments = new ImageAttachments();
223
+ attachments.add(png());
224
+ const strip = imageAttachmentsWidget(attachments, () => "[Image#1]", { fg: (_role, text) => text }, () => true, () => true);
225
+ const lines = strip.render(80);
226
+ assert.equal(stripAnsi(lines[0] ?? ""), "[Image#1] png · 2×1 · 29 B");
227
+ assert.ok(lines.length > 1, "a preview row follows the label");
228
+ assert.ok(lines.slice(1).some((line) => line.includes("\x1b_G")), "the preview uses the Kitty graphics protocol");
229
+ });
230
+
231
+ test("models without declared input capabilities are treated as image-capable", () => {
232
+ assert.equal(modelAcceptsImages(undefined), true);
233
+ assert.equal(modelAcceptsImages({}), true);
234
+ assert.equal(modelAcceptsImages({ input: ["text", "image"] }), true);
235
+ assert.equal(modelAcceptsImages({ input: ["text"] }), false);
236
+ });
@@ -221,7 +221,7 @@ test("refuses when the reviewer attempts a tool call", async () => {
221
221
  assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED);
222
222
  });
223
223
 
224
- test("refuses empty assistant text with stopReason evidence", async () => {
224
+ test("refuses empty assistant text with safe completion evidence", async () => {
225
225
  const assistant = assistantText("", { content: [], stopReason: "stop" });
226
226
  const outcome = await runInProcessReviewer(baseRequest(), {
227
227
  registry: fakeRegistry([fakeModel()]),
@@ -229,7 +229,60 @@ test("refuses empty assistant text with stopReason evidence", async () => {
229
229
  });
230
230
  const refused = expectRefused(outcome);
231
231
  assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
232
- assert.deepEqual(refused.evidence, { stopReason: "stop" });
232
+ assert.deepEqual(refused.evidence, {
233
+ reviewerModel: "openai/gpt-5", stopReason: "stop",
234
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
235
+ content: { blocks: 0, textBlocks: 0, thinkingBlocks: 0, textChars: 0, thinkingChars: 0 },
236
+ });
237
+ });
238
+
239
+ test("empty-output diagnostics allowlist facts, not provider secrets, and complete once", async () => {
240
+ const model = fakeModel({ provider: "nan", id: "deepseek-v4-flash" });
241
+ const assistant = assistantText("", {
242
+ content: [{ type: "text", text: "" }, { type: "thinking", thinking: "PRIVATE THINKING", thinkingSignature: "PRIVATE SIGNATURE" }],
243
+ stopReason: "length", errorMessage: "PRIVATE ERROR", rawStopReason: "PRIVATE STOP",
244
+ usage: { input: 12, output: 64, reasoning: 64, cacheRead: 0, cacheWrite: 0, totalTokens: 76, unknown: "PRIVATE USAGE", cost: { secret: "PRIVATE COST" } } as never,
245
+ });
246
+ const { complete, calls } = capturingComplete(assistant);
247
+ const refused = expectRefused(await runInProcessReviewer(baseRequest(), {
248
+ registry: { ...fakeRegistry([model]), find: () => model }, complete,
249
+ }));
250
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
251
+ assert.equal(calls.length, 1);
252
+ assert.deepEqual(refused.evidence, {
253
+ reviewerModel: "nan/deepseek-v4-flash", stopReason: "length",
254
+ usage: { input: 12, output: 64, reasoning: 64, cacheRead: 0, cacheWrite: 0, totalTokens: 76 },
255
+ content: { blocks: 2, textBlocks: 1, thinkingBlocks: 1, textChars: 0, thinkingChars: 16 },
256
+ });
257
+ assert.doesNotMatch(JSON.stringify(refused), /PRIVATE|errorMessage|rawStopReason|Signature|unknown|cost/);
258
+ });
259
+
260
+ test("empty-output evidence omits unknown or invalid counters and bounds resolved model identity", async () => {
261
+ for (const invalid of [undefined, NaN, Infinity, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, "12"]) {
262
+ const assistant = assistantText("", {
263
+ content: [], stopReason: "PRIVATE STOP" as never,
264
+ usage: Object.fromEntries(["input", "output", "reasoning", "cacheRead", "cacheWrite", "totalTokens"].map((key) => [key, invalid])) as never,
265
+ });
266
+ const refused = expectRefused(await runInProcessReviewer(baseRequest(), {
267
+ registry: { ...fakeRegistry([]), find: () => fakeModel({ id: "x".repeat(2_000) }) },
268
+ complete: async () => assistant,
269
+ }));
270
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
271
+ assert.equal(String(refused.evidence?.reviewerModel).length, 512);
272
+ assert.ok(!Object.hasOwn(refused.evidence!, "usage"));
273
+ assert.ok(!Object.hasOwn(refused.evidence!, "stopReason"));
274
+ assert.doesNotMatch(JSON.stringify(refused), /PRIVATE STOP/);
275
+ }
276
+ });
277
+
278
+ test("empty-output evidence does not invent usage or missing thinking lengths", async () => {
279
+ const refused = expectRefused(await runInProcessReviewer(baseRequest(), {
280
+ registry: fakeRegistry([fakeModel()]),
281
+ complete: async () => assistantText("", { content: [{ type: "thinking" } as never], usage: undefined }),
282
+ }));
283
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
284
+ assert.ok(!Object.hasOwn(refused.evidence!, "usage"));
285
+ assert.deepEqual(refused.evidence?.content, { blocks: 1, textBlocks: 0, thinkingBlocks: 1, textChars: 0 });
233
286
  });
234
287
 
235
288
  test("refuses with PROVIDER_FAILED when the provider itself reports an aborted completion", async () => {
@@ -10,7 +10,13 @@ import { INSTALLER_ASSETS, buildInstallerBundles, bundleFiles, relativeReference
10
10
  import { installerPaths } from "../scripts/verify-package-files.mjs";
11
11
 
12
12
  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
13
- const posixOnly = { skip: process.platform === "win32" };
13
+ // The builder shells out to the zip binary for the archives. Repository CI
14
+ // installs zip, but a dev machine without it cannot run the archive tests:
15
+ // skip with a clear reason instead of failing on spawnSync ENOENT.
16
+ const zipAvailable = spawnSync("zip", ["--version"], { stdio: "ignore" }).status === 0;
17
+ const bundleArchive = {
18
+ skip: process.platform === "win32" ? "Windows host" : zipAvailable ? false : "the zip binary is not on PATH",
19
+ };
14
20
  // Independent of the builder's scanner on purpose: imports and file URLs next to the module.
15
21
  const relativeImports = (text: string) => [...text.matchAll(/(?:from\s+|import\s*\(\s*|new URL\(\s*)["'](\.{1,2}\/[^"']+\.[a-z]+)["']/g)].map((match) => match[1]);
16
22
 
@@ -45,7 +51,7 @@ function build() {
45
51
  }
46
52
  const listing = (command: string, args: string[]) => spawnSync(command, args, { encoding: "utf8" }).stdout;
47
53
 
48
- test("one download per system, with stable names and SHA-256 checksums", posixOnly, () => {
54
+ test("one download per system, with stable names and SHA-256 checksums", bundleArchive, () => {
49
55
  const b = build();
50
56
  try {
51
57
  assert.deepEqual(INSTALLER_ASSETS, ["gentle-shell-installer-macos.zip", "gentle-shell-installer-windows.zip", "gentle-shell-installer-linux.tar.gz"]);
@@ -58,7 +64,7 @@ test("one download per system, with stable names and SHA-256 checksums", posixOn
58
64
  } finally { b.cleanup(); }
59
65
  });
60
66
 
61
- test("macOS and Linux double-click launchers are executable and start the bundled bootstrap", posixOnly, () => {
67
+ test("macOS and Linux double-click launchers are executable and start the bundled bootstrap", bundleArchive, () => {
62
68
  const b = build();
63
69
  try {
64
70
  const mac = listing("zipinfo", [join(b.out, "gentle-shell-installer-macos.zip")]);
@@ -74,7 +80,7 @@ test("macOS and Linux double-click launchers are executable and start the bundle
74
80
  } finally { b.cleanup(); }
75
81
  });
76
82
 
77
- test("the Windows launcher is a CRLF batch file that calls the bundled bootstrap and keeps its window open", posixOnly, () => {
83
+ test("the Windows launcher is a CRLF batch file that calls the bundled bootstrap and keeps its window open", bundleArchive, () => {
78
84
  const b = build();
79
85
  try {
80
86
  const zip = join(b.out, "gentle-shell-installer-windows.zip");
@@ -88,7 +94,7 @@ test("the Windows launcher is a CRLF batch file that calls the bundled bootstrap
88
94
  } finally { b.cleanup(); }
89
95
  });
90
96
 
91
- test("an extracted bundle runs on its own: real files only, and every wizard module loads", posixOnly, () => {
97
+ test("an extracted bundle runs on its own: real files only, and every wizard module loads", bundleArchive, () => {
92
98
  const b = build();
93
99
  const target = realpathSync(mkdtempSync(join(tmpdir(), "installer-extract-")));
94
100
  try {
@@ -111,7 +117,7 @@ test("an extracted bundle runs on its own: real files only, and every wizard mod
111
117
  }
112
118
  });
113
119
 
114
- test("the builder command writes the downloads to the requested folder", posixOnly, () => {
120
+ test("the builder command writes the downloads to the requested folder", bundleArchive, () => {
115
121
  const out = realpathSync(mkdtempSync(join(tmpdir(), "installer-cli-")));
116
122
  try {
117
123
  mkdirSync(join(out, "dist"));
@@ -934,19 +934,19 @@ test("Go has no descriptor for other targets", () => {
934
934
  // The Engram release the bundled install ships (S9), from its checksums.txt and
935
935
  // the GitHub asset digests; every archive was downloaded and re-hashed.
936
936
  const engramPins = {
937
- "darwin-arm64": ["darwin_arm64.tar.gz", "d0aa59c9a83d17ae3fc7f5dd31fdab6a26fd8c00fc5e692195753cd0719294f7", 7470202],
938
- "darwin-x64": ["darwin_amd64.tar.gz", "3c5bdc73d226af5629cf4f4a781e223142e37c1ebb443c4b596f2e284352e381", 7961674],
939
- "linux-x64": ["linux_amd64.tar.gz", "715fda625556e46d65ddb4526bb7f88a3fee5339ca3c44ce44cc652f17fa57f1", 7821191],
940
- "linux-arm64": ["linux_arm64.tar.gz", "3309e9c0bbb539d183fd5aa9f9a13cd7aa0e81ec2512b5bedefb8b2bdd35b3e5", 7176726],
941
- "win32-x64": ["windows_amd64.zip", "3065736ec7ff8137eb3fc607275f542ea13bb743a5157318c8a8770f11ce3289", 8021225],
942
- "win32-arm64": ["windows_arm64.zip", "7463872a85a5e5434cf958bdffc71dda1926bced30d3b6b38c80fec9bb98fbba", 7231641],
937
+ "darwin-arm64": ["darwin_arm64.tar.gz", "43d398b2fe3327a4a6436c26b2a17d3285ba80bf2f11a22d0796ea2a7fd2b78c", 7749520],
938
+ "darwin-x64": ["darwin_amd64.tar.gz", "aea28ebf461a4ea86c52b793c29311539d7123875354ed7411eae982fc82ec87", 8181300],
939
+ "linux-x64": ["linux_amd64.tar.gz", "106a43ca8f43a27c2c260a6787151c3be6d91c5ed3d5e454e6140527adf92e01", 8064598],
940
+ "linux-arm64": ["linux_arm64.tar.gz", "b0ba49cf5a7d5af436c9744a9a0d638b3f9ebb2a7321c8736d30e96305c2993c", 7472279],
941
+ "win32-x64": ["windows_amd64.zip", "ba56a36d37b2da85fcb1581bbdbed9ae1ee690e642aab8dbe1e18209bf264afe", 8272809],
942
+ "win32-arm64": ["windows_arm64.zip", "df5acc5dd6ee407d484074e570c77a79567c0b8dccf55e25ae25a644c1e7db5f", 7530282],
943
943
  } as const;
944
944
  test("fixed Engram descriptors are the GitHub release archives and checksums, and nothing else", () => {
945
945
  for (const [target, [suffix, sha256, size]] of Object.entries(engramPins)) {
946
946
  const [platform, arch] = target.split("-");
947
947
  const artifact = artifactFor("engram", platform, arch);
948
- assert.equal(artifact.version, "3.4.0");
949
- assert.equal(artifact.url, `https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_${suffix}`);
948
+ assert.equal(artifact.version, "3.5.2");
949
+ assert.equal(artifact.url, `https://github.com/Gentleman-Programming/engram/releases/download/v3.5.2/engram_3.5.2_${suffix}`);
950
950
  assert.equal(artifact.integrity, `sha256-${sha256}`);
951
951
  assert.deepEqual([artifact.size, artifact.maxBytes], [size, size]);
952
952
  // GitHub serves release assets from its asset hosts.
@@ -172,6 +172,22 @@ test("Windows requires compatible Go only before a missing native binary", () =>
172
172
  assert.deepEqual(older.actions.slice(0, 2), goActions);
173
173
  });
174
174
 
175
+ test("Windows acquires the pinned Go for a Shell install even when Gentle AI is already installed", () => {
176
+ // gentle-pi's postinstall always builds Gentle AI from source on Windows, so an
177
+ // existing compatible gentle-ai never removes the need for Go.
178
+ const compatible = { ...tool(requirements.gentleAi), compatible: true };
179
+ for (const inventory of [{ ...clean("win32"), gentleAi: compatible },
180
+ { ...installed("win32"), shell: absent, setup: false, go: absent }]) {
181
+ const plan = planPreflight(inventory);
182
+ assert.deepEqual(plan.blockers, []);
183
+ assert.deepEqual(plan.tools.go, { status: "needs-acquire", required: requirements.go, version: goAcquisition.version });
184
+ assert.ok(ids(inventory).includes("install-shell"));
185
+ assert.deepEqual(plan.actions.filter((action: { target: string }) => action.target === "go"), goActions);
186
+ }
187
+ // A set-up Windows stack builds nothing, so it still needs no Go.
188
+ assert.equal(planPreflight({ ...installed("win32"), go: absent }).tools.go.status, "not-required");
189
+ });
190
+
175
191
  for (const [name, version] of [["node", "22.18.0"], ["shell", "3.9.0"], ["node", "banana"], ["pi", "1.0.0-rc.1"]]) {
176
192
  test(`${name} ${version} blocks rather than replacing an existing tool`, () => {
177
193
  const inventory = { ...installed(), [name]: tool(version) };
@@ -306,8 +306,8 @@ test("/api/plan names the found and required version of an older Gentle Shell an
306
306
  const view = await plan(port, await login());
307
307
  assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["incompatible-tool", "shell"]]);
308
308
  assert.equal(view.blockers[0].guidance,
309
- "Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.3.0 or newer. Nothing was replaced. " +
310
- "Update it with `pnpm add -g gentle-pi@4.3.0`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
309
+ "Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.4.0 or newer. Nothing was replaced. " +
310
+ "Update it with `pnpm add -g gentle-pi@4.4.0`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
311
311
  assert.deepEqual(view.actions, []);
312
312
  } finally {
313
313
  await host.close("test");
@@ -1054,6 +1054,55 @@ test("the one-launch walk applies the single-path walk, per path, with its own f
1054
1054
  assert.doesNotMatch(many, /Invoke-Expression|\biex\b|ScriptBlock\]::Create|EncodedCommand/);
1055
1055
  });
1056
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
+
1057
1106
  // S6 decision fixtures: an in-memory folder tree and an injected walk verdict per
1058
1107
  // path, the same way the helper tests inject storage. `weak` maps a walked path
1059
1108
  // to the check its walk fails; every other walk passes.
@@ -1197,8 +1246,9 @@ test("production and fixture PowerShell never depend on autoloading Microsoft.Po
1197
1246
  assert.match(source, /\[IO\.Directory\]::SetAccessControl\(/);
1198
1247
  }
1199
1248
  // Walks that may reach a file read it as a file, never through a directory API.
1200
- for (const source of [productionAclSources()[1], productionAclSources()[2]]) {
1201
- assert.match(source, /if \(\$item\.PSIsContainer\) \{ \$acl = \[IO\.Directory\]::GetAccessControl\(\$path\) \} else \{ \$acl = \[IO\.File\]::GetAccessControl\(\$path\) \}/);
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\) \}/);
1202
1252
  }
1203
1253
  });
1204
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));" ^';
@@ -1317,9 +1367,17 @@ if (Test-Path -LiteralPath $env:GENTLE_FIXTURE_RECORDS) {
1317
1367
  if ($reaped) { [Console]::WriteLine('fixture guard reaped residual owned process') };
1318
1368
  exit 0;
1319
1369
  `;
1370
+ const GUARD_TIMEOUT_MS = 5000;
1320
1371
  function cleanNativeProcesses(root: string) {
1321
1372
  const ps = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe");
1322
- const result = spawnSync(ps, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", guardCommand], { env: nativeEnv(root), timeout: 5000, killSignal: "SIGKILL", encoding: "utf8", maxBuffer: 65536 });
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);
1323
1381
  assert.equal(result.error, undefined, "fixture guard must be bounded and available");
1324
1382
  assert.equal(result.status, 0, `fixture guard must only clean verified owned processes: ${(result.stderr ?? "").slice(0, 4000)}`);
1325
1383
  assert.ok(["", "fixture guard reaped residual owned process"].includes(result.stdout.trim()), "unexpected fixture guard output");
@@ -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
+ });