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.
- package/README.md +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -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 +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- 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/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -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/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- 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 +11 -4
- 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-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- 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/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- 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-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- 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 +226 -5
- 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 +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- 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/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- 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-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- 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 +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- 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 +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- 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 +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -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-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- 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/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- 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 +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- 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
|
|
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, {
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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", "
|
|
938
|
-
"darwin-x64": ["darwin_amd64.tar.gz", "
|
|
939
|
-
"linux-x64": ["linux_amd64.tar.gz", "
|
|
940
|
-
"linux-arm64": ["linux_arm64.tar.gz", "
|
|
941
|
-
"win32-x64": ["windows_amd64.zip", "
|
|
942
|
-
"win32-arm64": ["windows_arm64.zip", "
|
|
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.
|
|
949
|
-
assert.equal(artifact.url, `https://github.com/Gentleman-Programming/engram/releases/download/v3.
|
|
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.
|
|
310
|
-
"Update it with `pnpm add -g gentle-pi@4.
|
|
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
|
-
|
|
1201
|
-
|
|
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
|
|
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
|
+
});
|