gentle-pi 3.5.1 → 3.7.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 -1
- package/assets/orchestrator-delegation.md +2 -0
- package/bin/gentle-shell.mjs +900 -23
- package/docs/gentle-shell.md +5 -4
- package/docs/readme-reference.md +53 -39
- package/extensions/gentle-agents.ts +89 -22
- package/extensions/gentle-ai.ts +27 -48
- package/lib/agents-runner.ts +9 -0
- package/lib/foreign-target-grants.ts +32 -0
- package/lib/gentle-shell-launcher.ts +314 -2
- package/lib/inprocess-reviewer.ts +54 -6
- package/lib/native-review-cli.ts +16 -0
- package/lib/session-change-capture.ts +10 -1
- package/package.json +1 -1
- package/runtime/gentle-shell-launcher.mjs +313 -1
- package/runtime/native-review-cli.mjs +16 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-tui-mode-setting.mjs +21 -2
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-runner.test.ts +22 -0
- package/tests/foreign-target-grants.test.ts +58 -0
- package/tests/gentle-agents.test.ts +362 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +54 -49
- package/tests/gentle-ai.test.ts +147 -2
- package/tests/gentle-shell-bin.test.ts +1739 -4
- package/tests/gentle-shell-launcher.test.ts +394 -0
- package/tests/inprocess-reviewer.test.ts +179 -0
- package/tests/install-tui-mode-setting.test.ts +22 -4
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/odd-runtime-delegation-gate.test.ts +18 -197
- package/tests/package-manifest.test.ts +6 -6
- package/tests/runtime-harness.mjs +1 -2
- package/tests/session-change-capture.test.ts +12 -1
- package/lib/odd-runtime-delegation-gate.ts +0 -88
|
@@ -10,6 +10,7 @@ import test from "node:test";
|
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import {
|
|
12
12
|
GENTLE_AI_PENDING_DIGEST,
|
|
13
|
+
INSTALLER_VERSION,
|
|
13
14
|
GENTLE_AI_RELEASE_ASSETS,
|
|
14
15
|
GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|
|
15
16
|
downloadGentleAiAsset,
|
|
@@ -20,16 +21,20 @@ import {
|
|
|
20
21
|
trustedSystemExtractor,
|
|
21
22
|
} from "../scripts/gentle-ai-installer.mjs";
|
|
22
23
|
|
|
23
|
-
// v3.
|
|
24
|
-
//
|
|
25
|
-
// digests computed from the extracted executables.
|
|
24
|
+
// v3.7.0 archive digests independently match the minisign-signed release
|
|
25
|
+
// checksums; binary digests were computed from the extracted executables.
|
|
26
26
|
const EXPECTED_ASSETS = {
|
|
27
|
-
"darwin/amd64": { name: "gentle-ai_3.
|
|
28
|
-
"darwin/arm64": { name: "gentle-ai_3.
|
|
29
|
-
"linux/amd64": { name: "gentle-ai_3.
|
|
30
|
-
"linux/arm64": { name: "gentle-ai_3.
|
|
27
|
+
"darwin/amd64": { name: "gentle-ai_3.7.0_darwin_amd64.tar.gz", sha256: "e55ff3ee06258a90e9195c0e3a593b85f6d79cc439e9e6c04b2596725120a610", binarySha256: "aa30a940e3b75ac218249b3f92d17afd2b972f833258e8212175a73d8e76d5bf" },
|
|
28
|
+
"darwin/arm64": { name: "gentle-ai_3.7.0_darwin_arm64.tar.gz", sha256: "66eb5740c4506cb2cfd1cc5207439c61cfe07678854f4ac7c83027a95a0e666a", binarySha256: "ca726cf3f9a523dc0112aa113beb634cda389e8b47edc5851254c32979bee89e" },
|
|
29
|
+
"linux/amd64": { name: "gentle-ai_3.7.0_linux_amd64.tar.gz", sha256: "a730a61a43758f04cc9a4ac644945cc0e8652a1e33d6997a0a3d3f0044d2fff5", binarySha256: "002d09fd2b9628a29986a660c1f51f8a5042ff7fd54c8ab15b7b27de96c6cccc" },
|
|
30
|
+
"linux/arm64": { name: "gentle-ai_3.7.0_linux_arm64.tar.gz", sha256: "a3a3d3a974f3d9b67d935fe9e306ae83c305da4ec1baed4a5319c10b044cd0eb", binarySha256: "e29546c51d8d65528bf565239ed3fc174da73c5fa3e861e64f20f84b16f28f26" },
|
|
31
31
|
} as const;
|
|
32
32
|
|
|
33
|
+
test("published Gentle AI v3.7.0 is the installer pin", () => {
|
|
34
|
+
assert.equal(INSTALLER_VERSION, "3.7.0");
|
|
35
|
+
assert.equal(GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM, "h1:MQbzHlLdPklUQn0rVE9Mz94UygHsN2OPe7xMfPn9aGw=");
|
|
36
|
+
});
|
|
37
|
+
|
|
33
38
|
test("default installer package root is the package containing scripts, not its parent", () => {
|
|
34
39
|
const installerPath = fileURLToPath(new URL("../scripts/gentle-ai-installer.mjs", import.meta.url));
|
|
35
40
|
const expectedPackageRoot = dirname(dirname(installerPath));
|
|
@@ -38,15 +43,15 @@ test("default installer package root is the package containing scripts, not its
|
|
|
38
43
|
assert.notEqual(resolveGentleAiInstallerPackageRoot(), dirname(expectedPackageRoot));
|
|
39
44
|
});
|
|
40
45
|
|
|
41
|
-
test("release mapping selects only the supported official v3.
|
|
46
|
+
test("release mapping selects only the supported official v3.7.0 assets and pinned digests", () => {
|
|
42
47
|
assert.deepEqual(
|
|
43
48
|
Object.fromEntries(Object.entries(GENTLE_AI_RELEASE_ASSETS).map(([key, asset]) => [key, { name: asset.name, sha256: asset.sha256, binarySha256: asset.binarySha256 }])),
|
|
44
49
|
EXPECTED_ASSETS,
|
|
45
50
|
);
|
|
46
|
-
assert.equal(resolveGentleAiReleaseAsset("linux", "x64").name, "gentle-ai_3.
|
|
47
|
-
assert.equal(resolveGentleAiReleaseAsset("darwin", "arm64").name, "gentle-ai_3.
|
|
51
|
+
assert.equal(resolveGentleAiReleaseAsset("linux", "x64").name, "gentle-ai_3.7.0_linux_amd64.tar.gz");
|
|
52
|
+
assert.equal(resolveGentleAiReleaseAsset("darwin", "arm64").name, "gentle-ai_3.7.0_darwin_arm64.tar.gz");
|
|
48
53
|
for (const asset of Object.values(GENTLE_AI_RELEASE_ASSETS)) {
|
|
49
|
-
assert.match(asset.url, /^https:\/\/github\.com\/Gentleman-Programming\/gentle-ai\/releases\/download\/v3\.
|
|
54
|
+
assert.match(asset.url, /^https:\/\/github\.com\/Gentleman-Programming\/gentle-ai\/releases\/download\/v3\.7\.0\//);
|
|
50
55
|
}
|
|
51
56
|
});
|
|
52
57
|
|
|
@@ -56,7 +61,7 @@ test("raw release assets are admitted only under a prerelease pin", () => {
|
|
|
56
61
|
assert.equal(gentleAiAssetForm("gentle-ai_2.5.0-rc.3_windows_amd64.exe", "2.5.0-rc.3"), "raw-binary");
|
|
57
62
|
// A raw binary under a stable pin means the pin itself is wrong: stable
|
|
58
63
|
// releases publish signed archives only, so this fails closed pre-download.
|
|
59
|
-
assert.throws(() => gentleAiAssetForm("gentle-ai_3.
|
|
64
|
+
assert.throws(() => gentleAiAssetForm("gentle-ai_3.7.0_linux_amd64", "3.7.0"), /only admitted for a prerelease pin/);
|
|
60
65
|
assert.throws(() => gentleAiAssetForm("gentle-ai.dmg", "2.5.0-rc.3"), /unsupported Gentle AI release asset form/);
|
|
61
66
|
// The current stable pin admits every pinned asset row through the same
|
|
62
67
|
// gate the installer uses at download time (default installerVersion
|
|
@@ -83,7 +88,7 @@ test("release digests are all-or-none and install fails closed while any digest
|
|
|
83
88
|
}),
|
|
84
89
|
/checksum mismatch/,
|
|
85
90
|
);
|
|
86
|
-
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.
|
|
91
|
+
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
|
|
87
92
|
}
|
|
88
93
|
});
|
|
89
94
|
|
|
@@ -112,7 +117,7 @@ function windowsGoFixture(fixtureOptions: WindowsGoFixtureOptions = {}) {
|
|
|
112
117
|
const metadata = [
|
|
113
118
|
"gentle-ai.exe: go1.25.10",
|
|
114
119
|
"\tpath\tgithub.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
|
|
115
|
-
`\tmod\tgithub.com/gentleman-programming/gentle-ai/v3\tv3.
|
|
120
|
+
`\tmod\tgithub.com/gentleman-programming/gentle-ai/v3\tv3.7.0\t${GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM}`,
|
|
116
121
|
"\tbuild\t-buildmode=exe", "\tbuild\t-compiler=gc", "\tbuild\tCGO_ENABLED=0", `\tbuild\tGOARCH=${fixtureOptions.goArchitecture ?? "amd64"}`, "\tbuild\tGOOS=windows",
|
|
117
122
|
].join("\n");
|
|
118
123
|
const run = async (file: string, arguments_: string[], options: WindowsGoCall["options"]) => {
|
|
@@ -130,7 +135,7 @@ function windowsGoFixture(fixtureOptions: WindowsGoFixtureOptions = {}) {
|
|
|
130
135
|
return { stdout: "", stderr: "" };
|
|
131
136
|
}
|
|
132
137
|
if (file === goExecutable && arguments_[0] === "version" && arguments_[1] === "-m") return { stdout: metadata, stderr: "" };
|
|
133
|
-
if (arguments_.length === 1 && arguments_[0] === "version") return { stdout: fixtureOptions.reportedVersion ?? "gentle-ai 3.
|
|
138
|
+
if (arguments_.length === 1 && arguments_[0] === "version") return { stdout: fixtureOptions.reportedVersion ?? "gentle-ai 3.7.0\n", stderr: "" };
|
|
134
139
|
throw new Error(`unexpected command: ${file} ${arguments_.join(" ")}`);
|
|
135
140
|
};
|
|
136
141
|
return { calls, run, setGoExecutable: (path: string) => { goExecutable = path; } };
|
|
@@ -146,7 +151,7 @@ test("win32 x64 and arm64 install the exact Go SumDB source tag without archive
|
|
|
146
151
|
const result = await installGentleAi({ packageRoot, platform: "win32", arch, execFile: fixture.run, resolveGoExecutable: async () => goPath });
|
|
147
152
|
assert.equal(result.installed, true);
|
|
148
153
|
assert.deepEqual(fixture.calls.filter((call) => call.file === goPath).map((call) => call.arguments_.slice(0, 2)), [
|
|
149
|
-
["version"], ["install", "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai@v3.
|
|
154
|
+
["version"], ["install", "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai@v3.7.0"], ["version", "-m"],
|
|
150
155
|
]);
|
|
151
156
|
}
|
|
152
157
|
});
|
|
@@ -165,7 +170,7 @@ test("Windows source install reports missing or too-old Go without publishing a
|
|
|
165
170
|
() => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: async () => goPath }),
|
|
166
171
|
(error: unknown) => error instanceof Error && "code" in error && error.code === fixtureOptions.expectedCode,
|
|
167
172
|
);
|
|
168
|
-
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.
|
|
173
|
+
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai.exe")), false);
|
|
169
174
|
assert.deepEqual((await readdir(packageRoot)).filter((entry) => entry.includes("install-")), []);
|
|
170
175
|
}
|
|
171
176
|
});
|
|
@@ -184,7 +189,7 @@ test("Windows source install cleans staging after Go failure or wrong built vers
|
|
|
184
189
|
() => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: async () => goPath }),
|
|
185
190
|
(error: unknown) => error instanceof Error && "code" in error && error.code === fixtureOptions.expectedCode,
|
|
186
191
|
);
|
|
187
|
-
const runtimeDirectory = join(packageRoot, ".gentle-ai", "v3.
|
|
192
|
+
const runtimeDirectory = join(packageRoot, ".gentle-ai", "v3.7.0");
|
|
188
193
|
assert.ok(fixture.calls.some((call) => call.arguments_[0] === "install"));
|
|
189
194
|
assert.equal(existsSync(join(runtimeDirectory, "gentle-ai.exe")), false);
|
|
190
195
|
assert.equal(existsSync(runtimeDirectory) && (await readdir(runtimeDirectory)).some((entry) => entry.startsWith(".go-install-") || entry.endsWith(".tmp")), false);
|
|
@@ -205,7 +210,7 @@ test("Windows source installs reuse only a fully verified package-local binary",
|
|
|
205
210
|
assert.equal(reused.installed, false);
|
|
206
211
|
assert.ok(reuse.calls.every((call) => call.file !== "gentle-ai"), "the installer must never fall back to ambient gentle-ai on PATH");
|
|
207
212
|
|
|
208
|
-
await writeFile(join(packageRoot, ".gentle-ai", "v3.
|
|
213
|
+
await writeFile(join(packageRoot, ".gentle-ai", "v3.7.0", "integrity.json"), "{}\n");
|
|
209
214
|
const repaired = windowsGoFixture();
|
|
210
215
|
repaired.setGoExecutable(goPath);
|
|
211
216
|
assert.equal((await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: repaired.run, resolveGoExecutable: async () => goPath })).installed, true);
|
|
@@ -247,7 +252,7 @@ async function hardenedWindowsGoFixture(packageRoot: string, fixtureOptions: Har
|
|
|
247
252
|
const metadata = fixtureOptions.metadataOverride ?? [
|
|
248
253
|
"gentle-ai.exe: go1.25.10",
|
|
249
254
|
"\tpath\tgithub.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
|
|
250
|
-
`\tmod\tgithub.com/gentleman-programming/gentle-ai/v3\tv3.
|
|
255
|
+
`\tmod\tgithub.com/gentleman-programming/gentle-ai/v3\tv3.7.0\t${GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM}`,
|
|
251
256
|
"\tbuild\t-buildmode=exe",
|
|
252
257
|
"\tbuild\t-compiler=gc",
|
|
253
258
|
"\tbuild\tCGO_ENABLED=0",
|
|
@@ -267,7 +272,7 @@ async function hardenedWindowsGoFixture(packageRoot: string, fixtureOptions: Har
|
|
|
267
272
|
return { stdout: "", stderr: "" };
|
|
268
273
|
}
|
|
269
274
|
if (file === goPath && arguments_[0] === "version" && arguments_[1] === "-m") return { stdout: metadata, stderr: "" };
|
|
270
|
-
if (arguments_.length === 1 && arguments_[0] === "version") return { stdout: "gentle-ai 3.
|
|
275
|
+
if (arguments_.length === 1 && arguments_[0] === "version") return { stdout: "gentle-ai 3.7.0\n", stderr: "" };
|
|
271
276
|
throw new Error(`unexpected command: ${file} ${arguments_.join(" ")}`);
|
|
272
277
|
};
|
|
273
278
|
return {
|
|
@@ -309,7 +314,7 @@ test("Windows source installation resolves one validated Go executable and seals
|
|
|
309
314
|
const goCalls = fixture.calls.filter((call) => call.file === fixture.goPath);
|
|
310
315
|
assert.deepEqual(goCalls.map((call) => call.arguments_.slice(0, 2)), [
|
|
311
316
|
["version"],
|
|
312
|
-
["install", "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai@v3.
|
|
317
|
+
["install", "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai@v3.7.0"],
|
|
313
318
|
["version", "-m"],
|
|
314
319
|
]);
|
|
315
320
|
for (const call of goCalls) {
|
|
@@ -335,14 +340,14 @@ test("Windows source manifest binds verified Go metadata and architecture", asyn
|
|
|
335
340
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-provenance-"));
|
|
336
341
|
const fixture = await hardenedWindowsGoFixture(packageRoot, { architecture: "arm64" });
|
|
337
342
|
const result = await installGentleAi({ packageRoot, platform: "win32", arch: "arm64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable });
|
|
338
|
-
const manifest = JSON.parse(await readFile(join(packageRoot, ".gentle-ai", "v3.
|
|
343
|
+
const manifest = JSON.parse(await readFile(join(packageRoot, ".gentle-ai", "v3.7.0", "integrity.json"), "utf8")) as Record<string, string>;
|
|
339
344
|
assert.equal(result.installed, true);
|
|
340
345
|
assert.deepEqual(manifest, {
|
|
341
|
-
version: "3.
|
|
346
|
+
version: "3.7.0",
|
|
342
347
|
method: "go-sumdb-source-build",
|
|
343
348
|
package: "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
|
|
344
349
|
module: "github.com/gentleman-programming/gentle-ai/v3",
|
|
345
|
-
tag: "v3.
|
|
350
|
+
tag: "v3.7.0",
|
|
346
351
|
architecture: "arm64",
|
|
347
352
|
binarySha256: createHash("sha256").update("trusted Windows source build").digest("hex"),
|
|
348
353
|
moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|
|
@@ -362,12 +367,12 @@ test("Windows source installation rejects Go metadata for a different architectu
|
|
|
362
367
|
() => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable }),
|
|
363
368
|
(error: unknown) => error instanceof Error && "code" in error && error.code === "GENTLE_AI_GO_INSTALL_FAILED",
|
|
364
369
|
);
|
|
365
|
-
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.
|
|
370
|
+
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai.exe")), false);
|
|
366
371
|
});
|
|
367
372
|
|
|
368
373
|
test("Windows source installation treats a fresh ownerless lock as active", async () => {
|
|
369
374
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-ownerless-lock-"));
|
|
370
|
-
const lockPath = join(packageRoot, ".gentle-ai", ".v3.
|
|
375
|
+
const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
|
|
371
376
|
await mkdir(lockPath, { recursive: true });
|
|
372
377
|
const fixture = await hardenedWindowsGoFixture(packageRoot);
|
|
373
378
|
await assertManualLockRecoveryRequired(packageRoot, fixture);
|
|
@@ -375,7 +380,7 @@ test("Windows source installation treats a fresh ownerless lock as active", asyn
|
|
|
375
380
|
|
|
376
381
|
test("Windows source installation preserves a lock whose owner nonce changed before release", async () => {
|
|
377
382
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-owner-lock-"));
|
|
378
|
-
const lockOwnerPath = join(packageRoot, ".gentle-ai", ".v3.
|
|
383
|
+
const lockOwnerPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock", "owner.json");
|
|
379
384
|
const fixture = await hardenedWindowsGoFixture(packageRoot);
|
|
380
385
|
let replacedOwner = false;
|
|
381
386
|
const run = async (...arguments_: Parameters<typeof fixture.run>) => {
|
|
@@ -392,7 +397,7 @@ test("Windows source installation preserves a lock whose owner nonce changed bef
|
|
|
392
397
|
});
|
|
393
398
|
|
|
394
399
|
async function assertManualLockRecoveryRequired(packageRoot: string, fixture: Awaited<ReturnType<typeof hardenedWindowsGoFixture>>, options: Record<string, unknown> = {}) {
|
|
395
|
-
const lockPath = join(packageRoot, ".gentle-ai", ".v3.
|
|
400
|
+
const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
|
|
396
401
|
await assert.rejects(
|
|
397
402
|
() => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable, ...options }),
|
|
398
403
|
(error: unknown) => error instanceof Error && error.message.includes(lockPath) && /confirm no installer is active.*remove.*manually/i.test(error.message),
|
|
@@ -402,18 +407,18 @@ async function assertManualLockRecoveryRequired(packageRoot: string, fixture: Aw
|
|
|
402
407
|
}
|
|
403
408
|
|
|
404
409
|
function tombstonePath(packageRoot: string, nonce: string): string {
|
|
405
|
-
return join(packageRoot, ".gentle-ai", `.v3.
|
|
410
|
+
return join(packageRoot, ".gentle-ai", `.v3.7.0.install.tombstone-${nonce}`);
|
|
406
411
|
}
|
|
407
412
|
|
|
408
413
|
async function tombstones(packageRoot: string): Promise<string[]> {
|
|
409
414
|
const runtimeRoot = join(packageRoot, ".gentle-ai");
|
|
410
415
|
return existsSync(runtimeRoot)
|
|
411
|
-
? (await readdir(runtimeRoot)).filter((entry) => entry.startsWith(".v3.
|
|
416
|
+
? (await readdir(runtimeRoot)).filter((entry) => entry.startsWith(".v3.7.0.install.tombstone-"))
|
|
412
417
|
: [];
|
|
413
418
|
}
|
|
414
419
|
|
|
415
420
|
function backupBundlePath(packageRoot: string, nonce: string): string {
|
|
416
|
-
return join(packageRoot, ".gentle-ai", `.v3.
|
|
421
|
+
return join(packageRoot, ".gentle-ai", `.v3.7.0.backup-${nonce}`);
|
|
417
422
|
}
|
|
418
423
|
|
|
419
424
|
async function copyWindowsBundle(source: string, destination: string): Promise<void> {
|
|
@@ -424,7 +429,7 @@ async function copyWindowsBundle(source: string, destination: string): Promise<v
|
|
|
424
429
|
|
|
425
430
|
test("Windows source installation fails closed for an ownerless lock even after the stale threshold", async () => {
|
|
426
431
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-ownerless-stale-lock-"));
|
|
427
|
-
const lockPath = join(packageRoot, ".gentle-ai", ".v3.
|
|
432
|
+
const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
|
|
428
433
|
await mkdir(lockPath, { recursive: true });
|
|
429
434
|
const fixture = await hardenedWindowsGoFixture(packageRoot);
|
|
430
435
|
await assertManualLockRecoveryRequired(packageRoot, fixture, { now: () => Date.now() + 10 * 60_000 });
|
|
@@ -432,7 +437,7 @@ test("Windows source installation fails closed for an ownerless lock even after
|
|
|
432
437
|
|
|
433
438
|
test("Windows source installation fails closed for a stale owner lock", async () => {
|
|
434
439
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-stale-lock-"));
|
|
435
|
-
const lockPath = join(packageRoot, ".gentle-ai", ".v3.
|
|
440
|
+
const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
|
|
436
441
|
await mkdir(lockPath, { recursive: true });
|
|
437
442
|
await writeFile(join(lockPath, "owner.json"), `${JSON.stringify({ createdAt: 0, nonce: "0".repeat(64) })}\n`);
|
|
438
443
|
const fixture = await hardenedWindowsGoFixture(packageRoot);
|
|
@@ -484,7 +489,7 @@ test("Windows source acquisition fails closed when a tombstone appears after its
|
|
|
484
489
|
(error: unknown) => error instanceof Error && error.message.includes(foreignTombstone),
|
|
485
490
|
);
|
|
486
491
|
assert.equal(existsSync(foreignTombstone), true);
|
|
487
|
-
assert.equal(existsSync(join(packageRoot, ".gentle-ai", ".v3.
|
|
492
|
+
assert.equal(existsSync(join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock")), false);
|
|
488
493
|
assert.equal(fixture.calls.some((call) => call.arguments_[0] === "install"), false);
|
|
489
494
|
});
|
|
490
495
|
|
|
@@ -492,7 +497,7 @@ test("Windows source release deletes its matching tombstone and allows reuse", a
|
|
|
492
497
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-owned-lock-release-"));
|
|
493
498
|
const fixture = await hardenedWindowsGoFixture(packageRoot);
|
|
494
499
|
await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable });
|
|
495
|
-
assert.equal(existsSync(join(packageRoot, ".gentle-ai", ".v3.
|
|
500
|
+
assert.equal(existsSync(join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock")), false);
|
|
496
501
|
assert.deepEqual(await tombstones(packageRoot), []);
|
|
497
502
|
const reuse = await hardenedWindowsGoFixture(packageRoot);
|
|
498
503
|
assert.equal((await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: reuse.run, resolveGoExecutable: reuse.resolveGoExecutable })).installed, false);
|
|
@@ -502,7 +507,7 @@ test("Windows source recovers a valid backup after a crash between publication r
|
|
|
502
507
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-backup-crash-"));
|
|
503
508
|
const initial = await hardenedWindowsGoFixture(packageRoot);
|
|
504
509
|
await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: initial.run, resolveGoExecutable: initial.resolveGoExecutable });
|
|
505
|
-
const live = join(packageRoot, ".gentle-ai", "v3.
|
|
510
|
+
const live = join(packageRoot, ".gentle-ai", "v3.7.0");
|
|
506
511
|
const backup = backupBundlePath(packageRoot, "crash");
|
|
507
512
|
await rename(live, backup);
|
|
508
513
|
const recovery = await hardenedWindowsGoFixture(packageRoot);
|
|
@@ -538,7 +543,7 @@ test("Windows source cleans one validated backup only when the live bundle is va
|
|
|
538
543
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-backup-valid-live-"));
|
|
539
544
|
const initial = await hardenedWindowsGoFixture(packageRoot);
|
|
540
545
|
await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: initial.run, resolveGoExecutable: initial.resolveGoExecutable });
|
|
541
|
-
const live = join(packageRoot, ".gentle-ai", "v3.
|
|
546
|
+
const live = join(packageRoot, ".gentle-ai", "v3.7.0");
|
|
542
547
|
const backup = backupBundlePath(packageRoot, "valid");
|
|
543
548
|
await copyWindowsBundle(live, backup);
|
|
544
549
|
const reuse = await hardenedWindowsGoFixture(packageRoot);
|
|
@@ -551,7 +556,7 @@ test("Windows source preserves a valid backup when the live bundle is invalid",
|
|
|
551
556
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-backup-invalid-live-"));
|
|
552
557
|
const initial = await hardenedWindowsGoFixture(packageRoot);
|
|
553
558
|
await installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: initial.run, resolveGoExecutable: initial.resolveGoExecutable });
|
|
554
|
-
const live = join(packageRoot, ".gentle-ai", "v3.
|
|
559
|
+
const live = join(packageRoot, ".gentle-ai", "v3.7.0");
|
|
555
560
|
const backup = backupBundlePath(packageRoot, "valid");
|
|
556
561
|
await copyWindowsBundle(live, backup);
|
|
557
562
|
await writeFile(join(live, "integrity.json"), "{}\n");
|
|
@@ -569,7 +574,7 @@ test("Windows concurrent installs fail closed until normal release, then reuse t
|
|
|
569
574
|
const fixture = await hardenedWindowsGoFixture(packageRoot, { blockInstall: true });
|
|
570
575
|
const first = installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable });
|
|
571
576
|
await fixture.waitForInstall();
|
|
572
|
-
const lockPath = join(packageRoot, ".gentle-ai", ".v3.
|
|
577
|
+
const lockPath = join(packageRoot, ".gentle-ai", ".v3.7.0.install.lock");
|
|
573
578
|
await assert.rejects(
|
|
574
579
|
() => installGentleAi({ packageRoot, platform: "win32", arch: "x64", execFile: fixture.run, resolveGoExecutable: fixture.resolveGoExecutable }),
|
|
575
580
|
(error: unknown) => error instanceof Error && error.message.includes(lockPath),
|
|
@@ -583,7 +588,7 @@ test("Windows concurrent installs fail closed until normal release, then reuse t
|
|
|
583
588
|
|
|
584
589
|
test("Windows source publication rolls back a prior bundle when final directory swap fails", async () => {
|
|
585
590
|
const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-rollback-"));
|
|
586
|
-
const versionDirectory = join(packageRoot, ".gentle-ai", "v3.
|
|
591
|
+
const versionDirectory = join(packageRoot, ".gentle-ai", "v3.7.0");
|
|
587
592
|
await mkdir(versionDirectory, { recursive: true });
|
|
588
593
|
await writeFile(join(versionDirectory, "old.txt"), "previous bundle");
|
|
589
594
|
const fixture = await hardenedWindowsGoFixture(packageRoot);
|
|
@@ -620,7 +625,7 @@ test("Darwin/Linux signed bundles retain their four-field manifest and reusable
|
|
|
620
625
|
},
|
|
621
626
|
};
|
|
622
627
|
await installGentleAi(options);
|
|
623
|
-
const manifestPath = join(packageRoot, ".gentle-ai", "v3.
|
|
628
|
+
const manifestPath = join(packageRoot, ".gentle-ai", "v3.7.0", "integrity.json");
|
|
624
629
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record<string, string>;
|
|
625
630
|
assert.deepEqual(Object.keys(manifest), ["version", "asset", "assetSha256", "binarySha256"]);
|
|
626
631
|
assert.equal((await installGentleAi({ ...options, download: async () => { throw new Error("signed bundle must be reused"); } })).installed, false);
|
|
@@ -633,7 +638,7 @@ test("a pinned asset falls back to its gentle-pi mirror only on download failure
|
|
|
633
638
|
// under the current stable pin, whose form gate refuses raw binaries.
|
|
634
639
|
const payload = Buffer.from("signed archive fixture");
|
|
635
640
|
const binary = "mirrored binary";
|
|
636
|
-
const baseAsset = { name: "gentle-ai_3.
|
|
641
|
+
const baseAsset = { name: "gentle-ai_3.7.0_linux_amd64.tar.gz", sha256: createHash("sha256").update(payload).digest("hex"), binarySha256: createHash("sha256").update(binary).digest("hex"), url: "https://example.invalid/upstream", mirrorUrl: "https://example.invalid/mirror", executable: "gentle-ai" };
|
|
637
642
|
const extractArchive = async (_archive: string, destination: string) => {
|
|
638
643
|
await mkdir(destination, { recursive: true });
|
|
639
644
|
await writeFile(join(destination, "gentle-ai"), binary);
|
|
@@ -751,7 +756,7 @@ test("checksum mismatch cleans temporary state without promoting a binary", asyn
|
|
|
751
756
|
}),
|
|
752
757
|
/checksum mismatch/,
|
|
753
758
|
);
|
|
754
|
-
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.
|
|
759
|
+
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
|
|
755
760
|
assert.deepEqual((await readdir(packageRoot)).filter((entry) => entry.startsWith(".gentle-ai-install-")), []);
|
|
756
761
|
});
|
|
757
762
|
|
|
@@ -773,7 +778,7 @@ test("installer promotes only the expected regular executable with executable PO
|
|
|
773
778
|
await chmod(extracted, 0o700);
|
|
774
779
|
},
|
|
775
780
|
});
|
|
776
|
-
const binary = join(packageRoot, ".gentle-ai", "v3.
|
|
781
|
+
const binary = join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai");
|
|
777
782
|
assert.equal(existsSync(binary), true);
|
|
778
783
|
assert.equal(await readFile(binary, "utf8"), "native executable");
|
|
779
784
|
assert.ok(((await stat(binary)).mode & 0o111) !== 0);
|
|
@@ -804,7 +809,7 @@ test("installer rejects an extracted binary that differs from its pinned digest"
|
|
|
804
809
|
}),
|
|
805
810
|
/binary checksum mismatch/,
|
|
806
811
|
);
|
|
807
|
-
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.
|
|
812
|
+
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
|
|
808
813
|
});
|
|
809
814
|
|
|
810
815
|
test("installer repairs a valid non-executable POSIX binary instead of reusing it", async (t) => {
|
|
@@ -827,7 +832,7 @@ test("installer repairs a valid non-executable POSIX binary instead of reusing i
|
|
|
827
832
|
},
|
|
828
833
|
};
|
|
829
834
|
await installGentleAi(options);
|
|
830
|
-
const binary = join(packageRoot, ".gentle-ai", "v3.
|
|
835
|
+
const binary = join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai");
|
|
831
836
|
await chmod(binary, 0o600);
|
|
832
837
|
const repaired = await installGentleAi(options);
|
|
833
838
|
assert.equal(repaired.installed, true);
|
|
@@ -864,7 +869,7 @@ test("installer rejects archives with multiple expected executable entries", asy
|
|
|
864
869
|
}),
|
|
865
870
|
/exactly one regular gentle-ai/,
|
|
866
871
|
);
|
|
867
|
-
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.
|
|
872
|
+
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
|
|
868
873
|
});
|
|
869
874
|
|
|
870
875
|
test("installer rejects an archive without the expected regular executable", async () => {
|
|
@@ -882,7 +887,7 @@ test("installer rejects an archive without the expected regular executable", asy
|
|
|
882
887
|
}),
|
|
883
888
|
/non-regular gentle-ai/,
|
|
884
889
|
);
|
|
885
|
-
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.
|
|
890
|
+
assert.equal(existsSync(join(packageRoot, ".gentle-ai", "v3.7.0", "gentle-ai")), false);
|
|
886
891
|
});
|
|
887
892
|
|
|
888
893
|
// #400: a clean Windows source build measured 232 s on a cold module cache,
|
package/tests/gentle-ai.test.ts
CHANGED
|
@@ -15,13 +15,15 @@ import type {
|
|
|
15
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
17
17
|
import { PROFILES_KIND, PROFILES_VERSION } from "../lib/agent-profiles.ts";
|
|
18
|
+
import type { AgentRoutingEntry } from "../lib/model-routing-authority.ts";
|
|
19
|
+
type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel">;
|
|
18
20
|
import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
|
|
19
21
|
import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
20
22
|
import { CandidateViewError, type CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
21
23
|
import { installPackageAssets } from "../lib/sdd-preflight.ts";
|
|
22
24
|
import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
23
25
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
24
|
-
import { cardBody,
|
|
26
|
+
import { cardBody, cardTitle, cardTone } from "./gentle-card-text.ts";
|
|
25
27
|
|
|
26
28
|
initTheme("dark");
|
|
27
29
|
|
|
@@ -274,7 +276,7 @@ test("registered Gentle Review tools preserve result envelopes and redact collap
|
|
|
274
276
|
const scope = tools.get("gentle_review_scope");
|
|
275
277
|
const manifest = { version: 1, scopeByMode: { "100644": ["src/file.ts"] }, gitlinks: {} };
|
|
276
278
|
const bytes = Buffer.from(JSON.stringify(manifest), "utf8");
|
|
277
|
-
const encoded = gzipSync(bytes
|
|
279
|
+
const encoded = gzipSync(bytes).toString("base64url");
|
|
278
280
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
279
281
|
|
|
280
282
|
const result = await scope.execute(
|
|
@@ -2627,3 +2629,146 @@ test("a rename follows the clone pin and leaves the committed declaration naming
|
|
|
2627
2629
|
rmSync(base, { recursive: true, force: true });
|
|
2628
2630
|
}
|
|
2629
2631
|
});
|
|
2632
|
+
|
|
2633
|
+
// getPiModelOptions guard branches (gentle-pi regression coverage)
|
|
2634
|
+
|
|
2635
|
+
function makeContext(registry?: ExtensionContext["modelRegistry"]): ExtensionContext {
|
|
2636
|
+
return {
|
|
2637
|
+
cwd: process.cwd(),
|
|
2638
|
+
hasUI: true,
|
|
2639
|
+
modelRegistry: registry,
|
|
2640
|
+
ui: { notify() {} },
|
|
2641
|
+
} as unknown as ExtensionContext;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
test("getPiModelOptions returns MODEL_CONTROL_OPTIONS when modelRegistry is absent", async () => {
|
|
2645
|
+
const options = await __testing.getPiModelOptions(makeContext());
|
|
2646
|
+
assert.deepEqual(options, ["Keep current", "Inherit active/default model", "Custom model id"]);
|
|
2647
|
+
});
|
|
2648
|
+
|
|
2649
|
+
test("getPiModelOptions returns MODEL_CONTROL_OPTIONS when getAvailable throws", async () => {
|
|
2650
|
+
const registry = {
|
|
2651
|
+
getAvailable: async () => { throw new Error("registry unavailable"); },
|
|
2652
|
+
} as unknown as ExtensionContext["modelRegistry"];
|
|
2653
|
+
const options = await __testing.getPiModelOptions(makeContext(registry));
|
|
2654
|
+
assert.deepEqual(options, ["Keep current", "Inherit active/default model", "Custom model id"]);
|
|
2655
|
+
});
|
|
2656
|
+
|
|
2657
|
+
test("getPiModelOptions returns MODEL_CONTROL_OPTIONS when getAvailable returns non-array", async () => {
|
|
2658
|
+
const registry = {
|
|
2659
|
+
getAvailable: async () => ({ provider: "openai", id: "gpt-5" }),
|
|
2660
|
+
} as unknown as ExtensionContext["modelRegistry"];
|
|
2661
|
+
const options = await __testing.getPiModelOptions(makeContext(registry));
|
|
2662
|
+
assert.deepEqual(options, ["Keep current", "Inherit active/default model", "Custom model id"]);
|
|
2663
|
+
});
|
|
2664
|
+
|
|
2665
|
+
test("getPiModelOptions merges MODEL_CONTROL_OPTIONS with normalized sorted model list", async () => {
|
|
2666
|
+
const registry = {
|
|
2667
|
+
getAvailable: async () => [
|
|
2668
|
+
{ provider: "openai", id: "gpt-5.5" },
|
|
2669
|
+
{ provider: "anthropic", id: "opus-4" },
|
|
2670
|
+
{ provider: "openai", id: "gpt-5" },
|
|
2671
|
+
],
|
|
2672
|
+
} as unknown as ExtensionContext["modelRegistry"];
|
|
2673
|
+
const options = await __testing.getPiModelOptions(makeContext(registry));
|
|
2674
|
+
|
|
2675
|
+
assert.equal(options[0], "Keep current");
|
|
2676
|
+
assert.equal(options[1], "Inherit active/default model");
|
|
2677
|
+
assert.equal(options[2], "Custom model id");
|
|
2678
|
+
|
|
2679
|
+
const modelPart = options.slice(3);
|
|
2680
|
+
assert.deepEqual(
|
|
2681
|
+
modelPart,
|
|
2682
|
+
[
|
|
2683
|
+
"anthropic/opus-4",
|
|
2684
|
+
"openai/gpt-5",
|
|
2685
|
+
"openai/gpt-5.5",
|
|
2686
|
+
],
|
|
2687
|
+
);
|
|
2688
|
+
});
|
|
2689
|
+
|
|
2690
|
+
test("getPiModelOptions drops models that normalize to undefined", async () => {
|
|
2691
|
+
const registry = {
|
|
2692
|
+
getAvailable: async () => [
|
|
2693
|
+
{ provider: "openai", id: "gpt-5" },
|
|
2694
|
+
{ provider: "anthropic", id: "claude 4" }, // space fails SAFE_MODEL_ID_PATTERN
|
|
2695
|
+
{ provider: "o|penai", id: "gpt-5" }, // pipe fails SAFE_MODEL_ID_PATTERN
|
|
2696
|
+
],
|
|
2697
|
+
} as unknown as ExtensionContext["modelRegistry"];
|
|
2698
|
+
const options = await __testing.getPiModelOptions(makeContext(registry));
|
|
2699
|
+
|
|
2700
|
+
const modelPart = options.slice(3);
|
|
2701
|
+
assert.deepEqual(
|
|
2702
|
+
modelPart,
|
|
2703
|
+
[
|
|
2704
|
+
"openai/gpt-5",
|
|
2705
|
+
],
|
|
2706
|
+
);
|
|
2707
|
+
});
|
|
2708
|
+
|
|
2709
|
+
// switchLiveOrchestrator regression coverage (gentle-pi)
|
|
2710
|
+
|
|
2711
|
+
test("switchLiveOrchestrator returns fallback note when modelRegistry is absent", async () => {
|
|
2712
|
+
const live = {
|
|
2713
|
+
setModel: async () => true,
|
|
2714
|
+
setThinkingLevel: () => {},
|
|
2715
|
+
} as unknown as LiveSession;
|
|
2716
|
+
const ctx = {
|
|
2717
|
+
cwd: process.cwd(),
|
|
2718
|
+
hasUI: true,
|
|
2719
|
+
ui: { notify() {} },
|
|
2720
|
+
} as unknown as ExtensionContext;
|
|
2721
|
+
const entry: AgentRoutingEntry = {
|
|
2722
|
+
model: "openai/gpt-5",
|
|
2723
|
+
};
|
|
2724
|
+
const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
|
|
2725
|
+
assert.equal(
|
|
2726
|
+
result,
|
|
2727
|
+
"\nModel registry unavailable; this session keeps its current model.",
|
|
2728
|
+
);
|
|
2729
|
+
});
|
|
2730
|
+
|
|
2731
|
+
test("switchLiveOrchestrator returns fallback note when model not found in catalog", async () => {
|
|
2732
|
+
const registry = {
|
|
2733
|
+
find: () => undefined,
|
|
2734
|
+
} as unknown as ExtensionContext["modelRegistry"];
|
|
2735
|
+
const live = {
|
|
2736
|
+
setModel: async () => true,
|
|
2737
|
+
setThinkingLevel: () => {},
|
|
2738
|
+
} as unknown as LiveSession;
|
|
2739
|
+
const ctx = {
|
|
2740
|
+
cwd: process.cwd(),
|
|
2741
|
+
hasUI: true,
|
|
2742
|
+
modelRegistry: registry,
|
|
2743
|
+
ui: { notify() {} },
|
|
2744
|
+
} as unknown as ExtensionContext;
|
|
2745
|
+
const entry: AgentRoutingEntry = {
|
|
2746
|
+
model: "openai/gpt-99",
|
|
2747
|
+
};
|
|
2748
|
+
const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
|
|
2749
|
+
assert.equal(
|
|
2750
|
+
result,
|
|
2751
|
+
"\nopenai/gpt-99 is not in the model catalog; this session keeps its current model.",
|
|
2752
|
+
);
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
test("switchLiveOrchestrator returns note when setModel fails", async () => {
|
|
2756
|
+
const registry = {
|
|
2757
|
+
find: () => ({ provider: "openai", id: "gpt-5" }),
|
|
2758
|
+
} as unknown as ExtensionContext["modelRegistry"];
|
|
2759
|
+
const live = {
|
|
2760
|
+
setModel: async () => false,
|
|
2761
|
+
setThinkingLevel: () => {},
|
|
2762
|
+
} as unknown as LiveSession;
|
|
2763
|
+
const ctx = {
|
|
2764
|
+
cwd: process.cwd(),
|
|
2765
|
+
hasUI: true,
|
|
2766
|
+
modelRegistry: registry,
|
|
2767
|
+
ui: { notify() {} },
|
|
2768
|
+
} as unknown as ExtensionContext;
|
|
2769
|
+
const entry: AgentRoutingEntry = {
|
|
2770
|
+
model: "openai/gpt-5",
|
|
2771
|
+
};
|
|
2772
|
+
const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
|
|
2773
|
+
assert.equal(result, "\nno authentication is configured for openai; this session keeps its current model.");
|
|
2774
|
+
});
|