gentle-pi 2.0.0 → 2.1.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.
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { createWriteStream, existsSync } from "node:fs";
3
3
  import {
4
4
  chmod,
@@ -13,7 +13,7 @@ import {
13
13
  writeFile,
14
14
  } from "node:fs/promises";
15
15
  import https from "node:https";
16
- import { dirname, isAbsolute, join, relative, win32 } from "node:path";
16
+ import { dirname, isAbsolute, join, relative } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { execFile } from "node:child_process";
19
19
  import { promisify } from "node:util";
@@ -23,7 +23,34 @@ const RELEASE_BASE_URL = "https://github.com/Gentleman-Programming/gentle-ai/rel
23
23
  const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
24
24
  const MAX_REDIRECTS = 3;
25
25
  const DOWNLOAD_TIMEOUTS = { headers: 10_000, body: 30_000, attempts: 2, retryDelay: 100 };
26
+ const GO_COMMAND_TIMEOUT_MS = 120_000;
27
+ const GO_COMMAND_MAX_BUFFER = 1024 * 1024;
28
+ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
26
29
  export const INSTALLER_VERSION = "2.2.2";
30
+ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
31
+ SIGNED_RELEASE_ASSET: "signed-release-asset",
32
+ GO_SUMDB_SOURCE_BUILD: "go-sumdb-source-build",
33
+ });
34
+ export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v2/cmd/gentle-ai";
35
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v2";
36
+ export const GENTLE_AI_WINDOWS_SOURCE_TAG = "v2.2.2";
37
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.2.2`
38
+ // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum.
39
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:YZcI5dRvoHm82I2CULvgBkB2M3UQQGarYO/u/Nt5LSc=";
40
+ export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
41
+ export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
42
+ export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
43
+ export const GENTLE_AI_GO_TOOLCHAIN_TOO_OLD_CODE = "GENTLE_AI_GO_TOOLCHAIN_TOO_OLD";
44
+ export const GENTLE_AI_GO_INSTALL_FAILED_CODE = "GENTLE_AI_GO_INSTALL_FAILED";
45
+ export const GENTLE_AI_VERSION_MISMATCH_CODE = "GENTLE_AI_VERSION_MISMATCH";
46
+
47
+ export class GentleAiInstallerError extends Error {
48
+ constructor(code, message, cause) {
49
+ super(message, cause === undefined ? undefined : { cause });
50
+ this.code = code;
51
+ this.name = "GentleAiInstallerError";
52
+ }
53
+ }
27
54
 
28
55
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
29
56
  // sentinel digest can never match a real SHA-256, so installation fails closed,
@@ -38,12 +65,11 @@ function asset(name, sha256, binarySha256, executable) {
38
65
  return Object.freeze({ name, sha256, binarySha256, executable, url: `${RELEASE_BASE_URL}${name}` });
39
66
  }
40
67
 
41
- // Windows is absent on purpose. gentle-ai stopped distributing Windows builds
42
- // in c4b764d0 ("omit unsigned Windows distribution"), so v2.2.2 publishes only
43
- // darwin and linux archives and there is nothing to pin. A Windows caller now
44
- // gets resolveGentleAiReleaseAsset's unsupported-platform error, which is the
45
- // truth: Pi cannot install a binary that upstream does not publish. Restore
46
- // both rows the moment gentle-ai ships signed Windows assets again.
68
+ // Windows is absent from signed release archives on purpose. gentle-ai stopped
69
+ // distributing unsigned Windows builds in c4b764d0, so v2.2.2 publishes signed
70
+ // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
71
+ // exact-tag Go SumDB source-build path below; restore archive rows only when
72
+ // upstream ships signed Windows assets.
47
73
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
48
74
  "darwin/amd64": asset("gentle-ai_2.2.2_darwin_amd64.tar.gz", "5ca67829903bf4c6b14665664f80f9d8216c84b10c8e50870d297f452cefb9dc", "9b239423450562d026384f482bbd2f1e3f2820431a84f0921743ac3df9d632de", "gentle-ai"),
49
75
  "darwin/arm64": asset("gentle-ai_2.2.2_darwin_arm64.tar.gz", "0193e1a284444dccee2863d31b8dbb76a982e8f9111955908d6a9131c1a5490e", "149b97248552c5e03ebc4d991f86b1360fb847a40fc315555a8aa256f95baca0", "gentle-ai"),
@@ -59,10 +85,19 @@ function upstreamPlatform(platform) {
59
85
  return platform === "win32" ? "windows" : platform;
60
86
  }
61
87
 
88
+ export function isWindowsGoSumdbSourceTarget(platform = process.platform, architecture = process.arch) {
89
+ return platform === "win32" && ["x64", "arm64"].includes(architecture);
90
+ }
91
+
62
92
  export function resolveGentleAiReleaseAsset(platform = process.platform, architecture = process.arch, releaseAssets = GENTLE_AI_RELEASE_ASSETS) {
63
93
  const key = `${upstreamPlatform(platform)}/${upstreamArchitecture(architecture)}`;
64
94
  const resolved = releaseAssets[key];
65
- if (!resolved) throw new Error(`unsupported Gentle AI platform/architecture: ${platform}/${architecture}; supported pairs are darwin, linux, or windows with x64 or arm64`);
95
+ if (!resolved) {
96
+ const windowsSource = isWindowsGoSumdbSourceTarget(platform, architecture)
97
+ ? "; Windows x64/arm64 use Go SumDB source installation because no signed Windows archive is published"
98
+ : "";
99
+ throw new Error(`unsupported Gentle AI platform/architecture: ${platform}/${architecture}; signed archive pairs are darwin/x64, darwin/arm64, linux/x64, and linux/arm64${windowsSource}`);
100
+ }
66
101
  return resolved;
67
102
  }
68
103
 
@@ -172,73 +207,421 @@ function isConfined(path, directory) {
172
207
  return value !== "" && !value.startsWith("..") && !isAbsolute(value);
173
208
  }
174
209
 
175
- async function existingBinaryMatches(binaryPath, manifestPath, asset, platform) {
210
+ function signedReleaseManifest(asset, binarySha256) {
211
+ return { version: INSTALLER_VERSION, asset: asset.name, assetSha256: asset.sha256, binarySha256 };
212
+ }
213
+
214
+ function windowsSourceManifest(metadata, binarySha256, architecture) {
215
+ return {
216
+ version: INSTALLER_VERSION,
217
+ method: GENTLE_AI_INSTALL_METHOD.GO_SUMDB_SOURCE_BUILD,
218
+ package: GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
219
+ module: GENTLE_AI_WINDOWS_SOURCE_MODULE,
220
+ tag: GENTLE_AI_WINDOWS_SOURCE_TAG,
221
+ architecture,
222
+ binarySha256,
223
+ moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
224
+ goVersion: metadata.goVersion,
225
+ goos: metadata.goos,
226
+ goarch: metadata.goarch,
227
+ buildMode: metadata.buildMode,
228
+ compiler: metadata.compiler,
229
+ cgoEnabled: metadata.cgoEnabled,
230
+ };
231
+ }
232
+
233
+ function canonicalManifest(manifest) { return `${JSON.stringify(manifest)}\n`; }
234
+
235
+ function isCanonicalManifest(contents, parsed, expected) {
236
+ return contents === canonicalManifest(expected)
237
+ && typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
238
+ && Object.keys(parsed).length === Object.keys(expected).length
239
+ && Object.entries(expected).every(([key, value]) => parsed[key] === value);
240
+ }
241
+
242
+ function commandOutput(result) { return typeof result?.stdout === "string" ? result.stdout : ""; }
243
+
244
+ function commandOptions(env, cwd) {
245
+ return { cwd, env, shell: false, windowsHide: true, timeout: GO_COMMAND_TIMEOUT_MS, maxBuffer: GO_COMMAND_MAX_BUFFER };
246
+ }
247
+
248
+ function outputVersion(output) {
249
+ const match = /^go version go(\d+)\.(\d+)\.(\d+)(?:\s|$)/.exec(output);
250
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
251
+ }
252
+
253
+ function isVersionAtLeast(actual, minimum) {
254
+ return actual[0] > minimum[0] || (actual[0] === minimum[0] && (actual[1] > minimum[1] || (actual[1] === minimum[1] && actual[2] >= minimum[2])));
255
+ }
256
+
257
+ export function isGentleAiWindowsGoVersionSupported(goVersion) {
258
+ const match = /^go(\d+)\.(\d+)\.(\d+)$/.exec(goVersion);
259
+ return match !== null && isVersionAtLeast(match.slice(1).map(Number), GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION.split(".").map(Number));
260
+ }
261
+
262
+ function expectedGoArchitecture(architecture) { return architecture === "x64" ? "amd64" : "arm64"; }
263
+
264
+ function sealedGoEnvironment(goPath, buildDirectory, architecture) {
265
+ const goDirectory = dirname(goPath);
266
+ const tempDirectory = join(buildDirectory, "tmp");
267
+ return {
268
+ GOENV: "off", GOFLAGS: "", GOWORK: "off", GOTOOLCHAIN: "local", GOSUMDB: "sum.golang.org",
269
+ GONOSUMDB: "", GOPRIVATE: "", GONOPROXY: "", GOINSECURE: "", GOPROXY: "https://proxy.golang.org",
270
+ GOOS: "windows", GOARCH: expectedGoArchitecture(architecture), CGO_ENABLED: "0",
271
+ GOBIN: join(buildDirectory, "gobin"), GOPATH: join(buildDirectory, "gopath"), GOMODCACHE: join(buildDirectory, "gomodcache"), GOCACHE: join(buildDirectory, "gocache"),
272
+ SystemRoot: WINDOWS_SYSTEM_ROOT, WINDIR: WINDOWS_SYSTEM_ROOT, ComSpec: join(WINDOWS_SYSTEM_ROOT, "System32", "cmd.exe"),
273
+ TEMP: tempDirectory, TMP: tempDirectory, PATHEXT: ".COM;.EXE;.BAT;.CMD",
274
+ PATH: [goDirectory, join(WINDOWS_SYSTEM_ROOT, "System32"), WINDOWS_SYSTEM_ROOT].join(";"),
275
+ };
276
+ }
277
+
278
+ async function runCommand(execute, file, arguments_, options) { return execute(file, arguments_, options); }
279
+
280
+ async function resolveWindowsGoExecutable(options) {
281
+ let resolved;
282
+ if (options.resolveGoExecutable) resolved = await options.resolveGoExecutable();
283
+ else {
284
+ try {
285
+ const result = await execFileAsync(join(WINDOWS_SYSTEM_ROOT, "System32", "where.exe"), ["go.exe"], { shell: false, windowsHide: true, timeout: GO_COMMAND_TIMEOUT_MS, maxBuffer: GO_COMMAND_MAX_BUFFER });
286
+ resolved = commandOutput(result).split(/\r?\n/).find((value) => isAbsolute(value));
287
+ } catch (error) {
288
+ throw new GentleAiInstallerError(GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE, `Windows source installation requires local Go ${GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION} or newer; install Go and retry (automatic Go toolchain download is disabled).`, error);
289
+ }
290
+ }
291
+ if (typeof resolved !== "string" || !isAbsolute(resolved)) throw new GentleAiInstallerError(GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE, "Windows source installation could not resolve an absolute local Go executable.");
176
292
  try {
177
- const runtimeDirectory = dirname(binaryPath);
178
- const packageRuntimeDirectory = dirname(runtimeDirectory);
179
- if (!isConfined(binaryPath, runtimeDirectory) || !isConfined(manifestPath, runtimeDirectory)) return false;
180
- const [parent, runtime, binary, manifestFile, manifest] = await Promise.all([
181
- lstat(packageRuntimeDirectory), lstat(runtimeDirectory), lstat(binaryPath), lstat(manifestPath), readFile(manifestPath, "utf8"),
182
- ]);
183
- const parsed = JSON.parse(manifest);
184
- return parent.isDirectory() && !parent.isSymbolicLink()
185
- && runtime.isDirectory() && !runtime.isSymbolicLink()
186
- && binary.isFile() && !binary.isSymbolicLink()
187
- && (platform === "win32" || (binary.mode & 0o111) !== 0)
188
- && manifestFile.isFile() && !manifestFile.isSymbolicLink()
189
- && typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
190
- && Object.keys(parsed).length === 4
191
- && ["version", "asset", "assetSha256", "binarySha256"].every((key) => key in parsed)
192
- && parsed.version === INSTALLER_VERSION
193
- && parsed.asset === asset.name
194
- && parsed.assetSha256 === asset.sha256
195
- && typeof parsed.binarySha256 === "string"
196
- && /^[0-9a-f]{64}$/.test(parsed.binarySha256)
197
- && (!asset.binarySha256 || parsed.binarySha256 === asset.binarySha256)
198
- && parsed.binarySha256 === await sha256File(binaryPath);
199
- } catch {
200
- return false;
293
+ const details = await lstat(resolved);
294
+ if (!details.isFile() || details.isSymbolicLink()) throw new Error("not a regular non-symlink file");
295
+ } catch (error) {
296
+ throw new GentleAiInstallerError(GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE, "Windows source installation requires a regular non-symlink local Go executable.", error);
201
297
  }
298
+ return resolved;
202
299
  }
203
300
 
204
- export async function installGentleAi(options = {}) {
205
- const packageRoot = options.packageRoot ?? resolveGentleAiInstallerPackageRoot();
206
- const platform = options.platform ?? process.platform;
207
- const arch = options.arch ?? process.arch;
208
- const releaseAssets = options.releaseAssets ?? GENTLE_AI_RELEASE_ASSETS;
209
- const asset = resolveGentleAiReleaseAsset(platform, arch, releaseAssets);
210
- const installDirectory = join(packageRoot, ".gentle-ai", `v${INSTALLER_VERSION}`);
211
- const binaryPath = join(installDirectory, asset.executable);
212
- const manifestPath = join(installDirectory, "integrity.json");
213
- await assertRuntimeDirectory(join(packageRoot, ".gentle-ai"));
214
- await assertRuntimeDirectory(installDirectory);
215
- if (await existingBinaryMatches(binaryPath, manifestPath, asset, platform)) return { installed: false, binaryPath, asset };
301
+ async function assertGoToolchain(execute, goPath, environment, cwd) {
302
+ let result;
303
+ try { result = await runCommand(execute, goPath, ["version"], commandOptions(environment, cwd)); }
304
+ catch (error) { throw new GentleAiInstallerError(GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE, `Windows source installation requires local Go ${GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION} or newer; install Go and retry (automatic Go toolchain download is disabled).`, error); }
305
+ const version = outputVersion(commandOutput(result));
306
+ const minimum = GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION.split(".").map(Number);
307
+ if (!version || !isVersionAtLeast(version, minimum)) throw new GentleAiInstallerError(GENTLE_AI_GO_TOOLCHAIN_TOO_OLD_CODE, `Windows source installation requires local Go ${GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION} or newer; found ${commandOutput(result).trim() || "an unrecognized Go version"}.`);
308
+ }
216
309
 
217
- await mkdir(packageRoot, { recursive: true });
218
- const temporaryDirectory = await mkdtemp(join(packageRoot, ".gentle-ai-install-"));
310
+ function parseGoBuildMetadata(output, architecture) {
311
+ const fields = new Map();
312
+ const lines = output.split(/\r?\n/).filter(Boolean);
313
+ const goVersion = /:\s+(go\d+\.\d+\.\d+)$/.exec(lines[0] ?? "")?.[1];
314
+ for (const line of lines.slice(1)) {
315
+ const [kind, ...values] = line.trimStart().split(/\s+/);
316
+ if (kind === "path") fields.set("path", values[0]);
317
+ else if (kind === "mod") { fields.set("module", values[0]); fields.set("tag", values[1]); fields.set("moduleChecksum", values[2]); }
318
+ else if (kind === "build") { const [key, value] = (values[0] ?? "").split("="); fields.set(key, value); }
319
+ }
320
+ const metadata = {
321
+ goVersion, path: fields.get("path"), module: fields.get("module"), tag: fields.get("tag"), moduleChecksum: fields.get("moduleChecksum"),
322
+ goos: fields.get("GOOS"), goarch: fields.get("GOARCH"), buildMode: fields.get("-buildmode"), compiler: fields.get("-compiler"), cgoEnabled: fields.get("CGO_ENABLED"),
323
+ };
324
+ if (metadata.path !== GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH || metadata.module !== GENTLE_AI_WINDOWS_SOURCE_MODULE || metadata.tag !== GENTLE_AI_WINDOWS_SOURCE_TAG || metadata.moduleChecksum !== GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM || !isGentleAiWindowsGoVersionSupported(metadata.goVersion ?? "") || metadata.goos !== "windows" || metadata.goarch !== expectedGoArchitecture(architecture) || metadata.buildMode !== "exe" || metadata.compiler !== "gc" || metadata.cgoEnabled !== "0") throw new GentleAiInstallerError(GENTLE_AI_GO_INSTALL_FAILED_CODE, "Gentle AI source build metadata does not match the pinned Windows provenance.");
325
+ return metadata;
326
+ }
327
+
328
+ async function verifyGoBuildMetadata(execute, goPath, binaryPath, environment, cwd, architecture) {
329
+ try { return parseGoBuildMetadata(commandOutput(await runCommand(execute, goPath, ["version", "-m", binaryPath], commandOptions(environment, cwd))), architecture); }
330
+ catch (error) { if (error instanceof GentleAiInstallerError) throw error; throw new GentleAiInstallerError(GENTLE_AI_GO_INSTALL_FAILED_CODE, "Gentle AI source build metadata could not be verified.", error); }
331
+ }
332
+
333
+ async function assertExactGentleAiVersion(execute, binaryPath, environment, cwd) {
334
+ let result;
335
+ try { result = await runCommand(execute, binaryPath, ["version"], commandOptions(environment, cwd)); }
336
+ catch (error) { throw new GentleAiInstallerError(GENTLE_AI_VERSION_MISMATCH_CODE, `Gentle AI source build at ${binaryPath} could not report its version.`, error); }
337
+ if (!/^gentle-ai 2\.2\.2\r?\n?$/.test(commandOutput(result))) throw new GentleAiInstallerError(GENTLE_AI_VERSION_MISMATCH_CODE, `Gentle AI source build reported ${JSON.stringify(commandOutput(result).trim())}; expected gentle-ai ${INSTALLER_VERSION}.`);
338
+ }
339
+
340
+ function sameFile(before, after) { return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs; }
341
+
342
+ async function safeRemoveDirectory(path) {
343
+ try {
344
+ const details = await lstat(path);
345
+ if (!details.isDirectory() || details.isSymbolicLink()) throw new Error("not a real directory");
346
+ await rm(path, { recursive: true, force: true });
347
+ } catch (error) { if (!(error && typeof error === "object" && error.code === "ENOENT")) throw error; }
348
+ }
349
+
350
+ async function existingSignedBundleMatches(directory, asset, platform) {
351
+ try {
352
+ const binaryPath = join(directory, asset.executable), manifestPath = join(directory, "integrity.json");
353
+ const [bundle, binary, manifestFile, contents] = await Promise.all([lstat(directory), lstat(binaryPath), lstat(manifestPath), readFile(manifestPath, "utf8")]);
354
+ const binarySha256 = await sha256File(binaryPath);
355
+ return bundle.isDirectory() && !bundle.isSymbolicLink() && binary.isFile() && !binary.isSymbolicLink() && (platform === "win32" || (binary.mode & 0o111) !== 0) && manifestFile.isFile() && !manifestFile.isSymbolicLink() && (!asset.binarySha256 || asset.binarySha256 === binarySha256) && isCanonicalManifest(contents, JSON.parse(contents), signedReleaseManifest(asset, binarySha256));
356
+ } catch { return false; }
357
+ }
358
+
359
+ async function existingWindowsSourceBundleMatches(directory, execute, goPath, environment, architecture) {
360
+ try {
361
+ const binaryPath = join(directory, "gentle-ai.exe"), manifestPath = join(directory, "integrity.json");
362
+ const [bundle, binary, manifestFile, contents] = await Promise.all([lstat(directory), lstat(binaryPath), lstat(manifestPath), readFile(manifestPath, "utf8")]);
363
+ if (!bundle.isDirectory() || bundle.isSymbolicLink() || !binary.isFile() || binary.isSymbolicLink() || !manifestFile.isFile() || manifestFile.isSymbolicLink()) return false;
364
+ const parsed = JSON.parse(contents);
365
+ if (typeof parsed?.binarySha256 !== "string" || !/^[0-9a-f]{64}$/.test(parsed.binarySha256)) return false;
366
+ const beforeBinary = await lstat(binaryPath), beforeManifest = await lstat(manifestPath);
367
+ const metadata = await verifyGoBuildMetadata(execute, goPath, binaryPath, environment, directory, architecture);
368
+ if ((await sha256File(binaryPath)) !== parsed.binarySha256 || !isCanonicalManifest(contents, parsed, windowsSourceManifest(metadata, parsed.binarySha256, architecture))) return false;
369
+ await assertExactGentleAiVersion(execute, binaryPath, environment, directory);
370
+ return sameFile(beforeBinary, await lstat(binaryPath)) && sameFile(beforeManifest, await lstat(manifestPath));
371
+ } catch { return false; }
372
+ }
373
+
374
+ function lockIdentity(details) { return `${details.dev}:${details.ino}`; }
375
+
376
+ function validInstallLockOwner(owner) {
377
+ return typeof owner === "object" && owner !== null
378
+ && Number.isSafeInteger(owner.createdAt)
379
+ && typeof owner.nonce === "string"
380
+ && /^[0-9a-f]{64}$/.test(owner.nonce);
381
+ }
382
+
383
+ async function inspectInstallLock(lockPath) {
384
+ const details = await lstat(lockPath);
385
+ if (!details.isDirectory() || details.isSymbolicLink()) throw new Error("Gentle AI install lock is not a real directory");
386
+ let owner;
387
+ try {
388
+ const ownerPath = join(lockPath, "owner.json");
389
+ const ownerDetails = await lstat(ownerPath);
390
+ if (ownerDetails.isFile() && !ownerDetails.isSymbolicLink()) {
391
+ const parsed = JSON.parse(await readFile(ownerPath, "utf8"));
392
+ if (validInstallLockOwner(parsed)) owner = parsed;
393
+ }
394
+ } catch (error) {
395
+ if (!(error && typeof error === "object" && error.code === "ENOENT") && !(error instanceof SyntaxError)) throw error;
396
+ }
397
+ return { details, identity: lockIdentity(details), owner };
398
+ }
399
+
400
+ function installLockBlockedError(path) {
401
+ return new Error(`Gentle AI package-private installation lock or tombstone exists at ${path}. Confirm no installer is active, then remove this package-private path manually before retrying.`);
402
+ }
403
+
404
+ function installTombstonePrefix() { return `.v${INSTALLER_VERSION}.install.tombstone-`; }
405
+
406
+ function installTombstonePath(runtimeRoot, nonce) {
407
+ const path = join(runtimeRoot, `${installTombstonePrefix()}${nonce}`);
408
+ if (!isConfined(path, runtimeRoot)) throw new Error("Gentle AI install tombstone escaped the package-private runtime directory");
409
+ return path;
410
+ }
411
+
412
+ async function existingInstallTombstone(runtimeRoot) {
413
+ for (const entry of await readdir(runtimeRoot, { withFileTypes: true })) {
414
+ if (!entry.name.startsWith(installTombstonePrefix())) continue;
415
+ const path = join(runtimeRoot, entry.name);
416
+ const details = await lstat(path);
417
+ if (!details.isDirectory() || details.isSymbolicLink()) throw installLockBlockedError(path);
418
+ return path;
419
+ }
420
+ return undefined;
421
+ }
422
+
423
+ async function assertNoInstallTombstones(runtimeRoot) {
424
+ const tombstone = await existingInstallTombstone(runtimeRoot);
425
+ if (tombstone) throw installLockBlockedError(tombstone);
426
+ }
427
+
428
+ async function releaseInstallLock(runtimeRoot, lockPath, owner, options) {
429
+ try {
430
+ const observed = await inspectInstallLock(lockPath);
431
+ if (observed.identity !== owner.identity || observed.owner?.nonce !== owner.owner?.nonce) return;
432
+ await options.beforeInstallLockReleaseRename?.(lockPath);
433
+ const tombstonePath = installTombstonePath(runtimeRoot, randomBytes(32).toString("hex"));
434
+ await (options.rename ?? rename)(lockPath, tombstonePath);
435
+ const tombstone = await inspectInstallLock(tombstonePath);
436
+ if (tombstone.identity !== owner.identity || tombstone.owner?.nonce !== owner.owner?.nonce) throw installLockBlockedError(tombstonePath);
437
+ await safeRemoveDirectory(tombstonePath);
438
+ } catch (error) {
439
+ if (!(error && typeof error === "object" && error.code === "ENOENT")) throw error;
440
+ }
441
+ }
442
+
443
+ // This coordinates cooperative installers only. A malicious same-user package
444
+ // writer can already replace package code, binaries, or manifests; Node has no
445
+ // pathname-delete CAS. Tombstones make rename auditable: only a matching owned
446
+ // tombstone is deleted automatically. Any other tombstone requires a maintainer
447
+ // to confirm no installer is active before manual package-private cleanup.
448
+ async function acquireInstallLock(runtimeRoot, options) {
449
+ const lockPath = join(runtimeRoot, `.v${INSTALLER_VERSION}.install.lock`);
450
+ await assertNoInstallTombstones(runtimeRoot);
451
+ await options.afterInstallLockFirstTombstoneScan?.(runtimeRoot);
452
+ const nonce = randomBytes(32).toString("hex");
453
+ try {
454
+ await mkdir(lockPath, { mode: 0o700 });
455
+ await writeFile(join(lockPath, "owner.json"), canonicalManifest({ createdAt: (options.now ?? Date.now)(), nonce }), { mode: 0o600, flag: "wx" });
456
+ } catch (error) {
457
+ if (error && typeof error === "object" && error.code === "EEXIST") throw installLockBlockedError(lockPath);
458
+ throw error;
459
+ }
460
+ const owner = await inspectInstallLock(lockPath);
461
+ if (owner.owner?.nonce !== nonce) throw installLockBlockedError(lockPath);
462
+ try {
463
+ await assertNoInstallTombstones(runtimeRoot);
464
+ } catch (error) {
465
+ await releaseInstallLock(runtimeRoot, lockPath, owner, options);
466
+ throw error;
467
+ }
468
+ return async () => releaseInstallLock(runtimeRoot, lockPath, owner, options);
469
+ }
470
+
471
+ function bundleRecoveryError(runtimeRoot, reason) {
472
+ return new Error(`Gentle AI package-local bundle recovery requires manual intervention in ${runtimeRoot}: ${reason}. Confirm no installer is active before changing package-private bundle paths.`);
473
+ }
474
+
475
+ function versionBundlePath(runtimeRoot) { return join(runtimeRoot, `v${INSTALLER_VERSION}`); }
476
+ function bundleBackupPrefix() { return `.v${INSTALLER_VERSION}.backup-`; }
477
+ function bundleStagingPrefix() { return `.v${INSTALLER_VERSION}.staging-`; }
478
+
479
+ async function realBundleDirectory(path, runtimeRoot, label) {
480
+ if (!isConfined(path, runtimeRoot)) throw bundleRecoveryError(runtimeRoot, `${label} escaped the version parent`);
219
481
  try {
220
- await chmod(temporaryDirectory, 0o700);
221
- const archive = join(temporaryDirectory, asset.name);
222
- await (options.download ?? downloadGentleAiAsset)(asset.url, archive);
223
- const digest = await sha256File(archive);
224
- if (digest !== asset.sha256) throw new Error(`Gentle AI archive checksum mismatch for ${asset.name}`);
225
- const extracted = join(temporaryDirectory, "extracted");
226
- await (options.extractArchive ?? extractGentleAiArchive)(archive, extracted);
227
- const source = await expectedRegularFile(extracted, asset.executable);
228
- if (asset.binarySha256 && (await sha256File(source)) !== asset.binarySha256) throw new Error(`Gentle AI binary checksum mismatch for ${asset.name}`);
229
- await mkdir(installDirectory, { recursive: true, mode: 0o700 });
230
- await assertRuntimeDirectory(join(packageRoot, ".gentle-ai"));
231
- await assertRuntimeDirectory(installDirectory);
232
- const temporaryBinary = join(installDirectory, `.${asset.executable}.${process.pid}.${Date.now()}.tmp`);
233
- const temporaryManifest = join(installDirectory, `.integrity.${process.pid}.${Date.now()}.tmp`);
234
- await copyFile(source, temporaryBinary);
235
- if (platform !== "win32") await chmod(temporaryBinary, 0o700);
236
- const binarySha256 = await sha256File(temporaryBinary);
237
- await writeFile(temporaryManifest, `${JSON.stringify({ version: INSTALLER_VERSION, asset: asset.name, assetSha256: asset.sha256, binarySha256 })}\n`, { mode: 0o600 });
238
- await rename(temporaryBinary, binaryPath);
239
- await rename(temporaryManifest, manifestPath);
240
- return { installed: true, binaryPath, asset };
241
- } finally {
242
- await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined);
482
+ const details = await lstat(path);
483
+ if (!details.isDirectory() || details.isSymbolicLink()) throw bundleRecoveryError(runtimeRoot, `${label} is not a real directory at ${path}`);
484
+ return true;
485
+ } catch (error) {
486
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
487
+ throw error;
488
+ }
489
+ }
490
+
491
+ async function backupBundlePaths(runtimeRoot) {
492
+ const backups = [];
493
+ for (const entry of await readdir(runtimeRoot, { withFileTypes: true })) {
494
+ if (!entry.name.startsWith(bundleBackupPrefix())) continue;
495
+ const path = join(runtimeRoot, entry.name);
496
+ if (!await realBundleDirectory(path, runtimeRoot, "backup bundle")) throw bundleRecoveryError(runtimeRoot, `backup bundle disappeared at ${path}`);
497
+ backups.push(path);
498
+ }
499
+ return backups;
500
+ }
501
+
502
+ async function recoverInterruptedPublication(runtimeRoot, bundleIsValid, options) {
503
+ const backups = await backupBundlePaths(runtimeRoot);
504
+ if (backups.length === 0) return;
505
+ if (backups.length !== 1) throw bundleRecoveryError(runtimeRoot, `found ${backups.length} backup bundle candidates`);
506
+ const [backup] = backups;
507
+ if (!await bundleIsValid(backup)) throw bundleRecoveryError(runtimeRoot, `backup bundle is invalid at ${backup}`);
508
+ const live = versionBundlePath(runtimeRoot);
509
+ const liveExists = await realBundleDirectory(live, runtimeRoot, "live bundle");
510
+ if (!liveExists) {
511
+ try { await (options.rename ?? rename)(backup, live); }
512
+ catch (error) { throw bundleRecoveryError(runtimeRoot, `could not restore valid backup ${backup}: ${error instanceof Error ? error.message : String(error)}`); }
513
+ return;
514
+ }
515
+ if (!await bundleIsValid(live)) throw bundleRecoveryError(runtimeRoot, `live bundle is invalid while valid backup remains at ${backup}`);
516
+ await safeRemoveDirectory(backup);
517
+ }
518
+
519
+ async function cleanupStaleStagingBundles(runtimeRoot) {
520
+ for (const entry of await readdir(runtimeRoot, { withFileTypes: true })) {
521
+ if (!entry.name.startsWith(bundleStagingPrefix())) continue;
522
+ const path = join(runtimeRoot, entry.name);
523
+ if (!await realBundleDirectory(path, runtimeRoot, "staging bundle")) throw bundleRecoveryError(runtimeRoot, `staging bundle disappeared at ${path}`);
524
+ await safeRemoveDirectory(path);
243
525
  }
244
526
  }
527
+
528
+ async function publishBundle(runtimeRoot, stagingDirectory, options) {
529
+ const versionDirectory = join(runtimeRoot, `v${INSTALLER_VERSION}`), renameFile = options.rename ?? rename;
530
+ const backupDirectory = join(runtimeRoot, `.v${INSTALLER_VERSION}.backup-${process.pid}-${Date.now()}`);
531
+ let movedPrior = false;
532
+ try {
533
+ try {
534
+ const current = await lstat(versionDirectory);
535
+ if (!current.isDirectory() || current.isSymbolicLink()) throw new Error("Gentle AI package-local version directory must be a real directory");
536
+ await renameFile(versionDirectory, backupDirectory);
537
+ movedPrior = true;
538
+ } catch (error) { if (!(error && typeof error === "object" && error.code === "ENOENT")) throw error; }
539
+ await renameFile(stagingDirectory, versionDirectory);
540
+ } catch (error) {
541
+ if (movedPrior) {
542
+ try { await renameFile(backupDirectory, versionDirectory); }
543
+ catch (rollbackError) { throw new Error("Gentle AI bundle publication failed and rollback could not restore the prior bundle", { cause: rollbackError }); }
544
+ }
545
+ throw error;
546
+ }
547
+ if (movedPrior) await safeRemoveDirectory(backupDirectory);
548
+ return versionDirectory;
549
+ }
550
+
551
+ async function withInstallLock(packageRoot, options, install) {
552
+ const runtimeRoot = join(packageRoot, ".gentle-ai");
553
+ await mkdir(packageRoot, { recursive: true });
554
+ await mkdir(runtimeRoot, { recursive: true, mode: 0o700 });
555
+ await assertRuntimeDirectory(runtimeRoot);
556
+ const release = await acquireInstallLock(runtimeRoot, options);
557
+ try { return await install(runtimeRoot); }
558
+ finally { await release(); }
559
+ }
560
+
561
+ async function installWindowsGentleAiFromGoSumdb(options, packageRoot, architecture) {
562
+ const execute = options.execFile ?? execFileAsync;
563
+ return withInstallLock(packageRoot, options, async (runtimeRoot) => {
564
+ await cleanupStaleStagingBundles(runtimeRoot);
565
+ const stagingDirectory = await mkdtemp(join(runtimeRoot, `.v${INSTALLER_VERSION}.staging-`));
566
+ try {
567
+ await chmod(stagingDirectory, 0o700);
568
+ const buildDirectory = join(stagingDirectory, ".build");
569
+ await mkdir(buildDirectory, { recursive: true, mode: 0o700 });
570
+ const goPath = await resolveWindowsGoExecutable(options);
571
+ const environment = sealedGoEnvironment(goPath, buildDirectory, architecture);
572
+ for (const directory of [environment.GOBIN, environment.GOPATH, environment.GOMODCACHE, environment.GOCACHE, environment.TEMP]) await mkdir(directory, { recursive: true, mode: 0o700 });
573
+ await recoverInterruptedPublication(runtimeRoot, (directory) => existingWindowsSourceBundleMatches(directory, execute, goPath, environment, architecture), options);
574
+ const existing = versionBundlePath(runtimeRoot);
575
+ if (await existingWindowsSourceBundleMatches(existing, execute, goPath, environment, architecture)) return { installed: false, binaryPath: join(existing, "gentle-ai.exe"), method: GENTLE_AI_INSTALL_METHOD.GO_SUMDB_SOURCE_BUILD };
576
+ await assertGoToolchain(execute, goPath, environment, stagingDirectory);
577
+ try { await runCommand(execute, goPath, ["install", GENTLE_AI_WINDOWS_SOURCE_PACKAGE], commandOptions(environment, stagingDirectory)); }
578
+ catch (error) { throw new GentleAiInstallerError(GENTLE_AI_GO_INSTALL_FAILED_CODE, `Gentle AI Go SumDB source installation failed for ${GENTLE_AI_WINDOWS_SOURCE_PACKAGE}.`, error); }
579
+ const builtBinary = join(environment.GOBIN, "gentle-ai.exe"), binaryPath = join(stagingDirectory, "gentle-ai.exe");
580
+ const details = await lstat(builtBinary);
581
+ if (!details.isFile() || details.isSymbolicLink()) throw new GentleAiInstallerError(GENTLE_AI_GO_INSTALL_FAILED_CODE, "Gentle AI Go installation produced a non-regular gentle-ai.exe.");
582
+ await copyFile(builtBinary, binaryPath);
583
+ const metadata = await verifyGoBuildMetadata(execute, goPath, binaryPath, environment, stagingDirectory, architecture);
584
+ await assertExactGentleAiVersion(execute, binaryPath, environment, stagingDirectory);
585
+ const binarySha256 = await sha256File(binaryPath);
586
+ await writeFile(join(stagingDirectory, "integrity.json"), canonicalManifest(windowsSourceManifest(metadata, binarySha256, architecture)), { mode: 0o600 });
587
+ await safeRemoveDirectory(buildDirectory);
588
+ const published = await publishBundle(runtimeRoot, stagingDirectory, options);
589
+ return { installed: true, binaryPath: join(published, "gentle-ai.exe"), method: GENTLE_AI_INSTALL_METHOD.GO_SUMDB_SOURCE_BUILD };
590
+ } finally { await safeRemoveDirectory(stagingDirectory); }
591
+ });
592
+ }
593
+
594
+ async function installSignedRelease(options, packageRoot, platform, arch, asset) {
595
+ return withInstallLock(packageRoot, options, async (runtimeRoot) => {
596
+ await recoverInterruptedPublication(runtimeRoot, (directory) => existingSignedBundleMatches(directory, asset, platform), options);
597
+ await cleanupStaleStagingBundles(runtimeRoot);
598
+ const existing = versionBundlePath(runtimeRoot);
599
+ if (await existingSignedBundleMatches(existing, asset, platform)) return { installed: false, binaryPath: join(existing, asset.executable), asset };
600
+ const stagingDirectory = await mkdtemp(join(runtimeRoot, `.v${INSTALLER_VERSION}.staging-`));
601
+ try {
602
+ const archive = join(stagingDirectory, asset.name);
603
+ await (options.download ?? downloadGentleAiAsset)(asset.url, archive);
604
+ if ((await sha256File(archive)) !== asset.sha256) throw new Error(`Gentle AI archive checksum mismatch for ${asset.name}`);
605
+ const extracted = join(stagingDirectory, "extracted");
606
+ await (options.extractArchive ?? extractGentleAiArchive)(archive, extracted);
607
+ const source = await expectedRegularFile(extracted, asset.executable);
608
+ if (asset.binarySha256 && (await sha256File(source)) !== asset.binarySha256) throw new Error(`Gentle AI binary checksum mismatch for ${asset.name}`);
609
+ const binaryPath = join(stagingDirectory, asset.executable);
610
+ await copyFile(source, binaryPath);
611
+ if (platform !== "win32") await chmod(binaryPath, 0o700);
612
+ await writeFile(join(stagingDirectory, "integrity.json"), canonicalManifest(signedReleaseManifest(asset, await sha256File(binaryPath))), { mode: 0o600 });
613
+ await safeRemoveDirectory(extracted);
614
+ await rm(archive, { force: true });
615
+ const published = await publishBundle(runtimeRoot, stagingDirectory, options);
616
+ return { installed: true, binaryPath: join(published, asset.executable), asset };
617
+ } finally { await safeRemoveDirectory(stagingDirectory); }
618
+ });
619
+ }
620
+
621
+ export async function installGentleAi(options = {}) {
622
+ const packageRoot = options.packageRoot ?? resolveGentleAiInstallerPackageRoot();
623
+ const platform = options.platform ?? process.platform, arch = options.arch ?? process.arch;
624
+ if (isWindowsGoSumdbSourceTarget(platform, arch)) return installWindowsGentleAiFromGoSumdb(options, packageRoot, arch);
625
+ const asset = resolveGentleAiReleaseAsset(platform, arch, options.releaseAssets ?? GENTLE_AI_RELEASE_ASSETS);
626
+ return installSignedRelease(options, packageRoot, platform, arch, asset);
627
+ }
@@ -44,7 +44,7 @@ try {
44
44
  if (packed.length !== 1 || typeof packed[0]?.filename !== "string") throw new Error("npm pack did not return one tarball");
45
45
  const tarball = join(packDirectory, packed[0].filename);
46
46
  writeFileSync(join(installDirectory, "package.json"), JSON.stringify({ name: "gentle-pi-packed-runner-test", private: true }), "utf8");
47
- runNpm(["install", "--no-audit", "--no-fund", "--package-lock=false", "--omit=dev", "--legacy-peer-deps", tarball], {
47
+ runNpm(["install", "--ignore-scripts=false", "--no-audit", "--no-fund", "--package-lock=false", "--omit=dev", "--legacy-peer-deps", tarball], {
48
48
  cwd: installDirectory,
49
49
  stdio: "inherit",
50
50
  });
@@ -20,6 +20,8 @@ Risk routing is deterministic:
20
20
 
21
21
  Generated files matching `testdata/golden/**` remain in snapshot identity but do not count as authored risk lines. Ordinary tests, fixtures, and snapshots are never broadly excluded. The correction budget is frozen as `min(200, ceil(original_changed_lines / 2))`.
22
22
 
23
+ Before status/START, consult effective review mode. `off` creates no authority or authorization and yields organic `disabled/unmanaged`, never approval. Ordinary START declares `--consent relay`; low risk stays silent. A medium/high `consent/v2` result always returns the complete raw provider envelope plus an opaque in-memory candidate binding to the parent, then stops without UI or provider follow-up. The parent localizes and presents it losslessly while preserving tokens, commands, target IDs, and invocations. One explicit `answer-consent` call accepts only that binding and `granted|declined`, consumes it before provider mutation, and rechecks repository/target/projection/lineage/answer binding. Ambiguity reconciles through STATUS, never replay. Grant is exact-candidate-only. Decline creates no lineage, authority, actor/candidate binding, latch, or pending authorization; the next candidate asks again. Old Pi clone latch files are inert.
24
+
23
25
  `finalize` canonicalizes selected-lens results, assigns missing lens/finding IDs, and performs only the legal transition from the current compact state. The five states are `reviewing`, `correction_required`, `validating`, `approved`, and `escalated`.
24
26
 
25
27
  `validate` loads the terminal receipt and authority, derives the named live Git gate, and runs with zero actors. It never mutates compact authority.
@@ -69,7 +71,7 @@ Pre-commit, pre-push, pre-PR, and release validate an approved receipt against o
69
71
 
70
72
  PR #1216 introduced the v2.1.1 `<remote>/<branch>` selector contract that v2.1.2 inherits unchanged.
71
73
 
72
- Pi additionally registers one one-shot authorization for the exact subsequent command. Full target/publication derivation runs after controller-time native allow, before bash-time native validation, and again after that validation before command allow. `gh pr create` binds repository precedence (`--repo`, `GH_REPO`, local inference), the effective source/value, and the exact advertised remote head commit equal to reviewed local `HEAD`; pre-PR keeps fetch-side repository/base/head semantics. Existing native push destinations bind the command remote, destination ref, old/new objects, exact destination selector, and advertised old commit in one rederived fingerprint only when effective push and fetch URL/identity match. Split fetch/push pre-push is an upstream v2.1.1 contract limitation: `<remote>/<branch>` resolves through fetch-side remote-tracking state even when Pi probes `pushurl`, so Pi fails closed before native validation with `native-split-fetch-push-unsupported-until-upstream-supports-explicit-push-base`. Native first-push authorization remains unsupported until a separate follow-up adds a persisted explicit advertised-base source, so a missing destination fails closed instead of inferring an upstream, default branch, or nearest ancestor. Publication probes are shell-free, bounded, and cancellation-aware, and the complete bash-time publication/native revalidation has one aggregate bounded deadline combined with Pi's cancellation signal when available. Repository identity, first-push destination, push destination, exact PR base/head, release evidence, protected-main release fast path, and fail-closed dangerous-command interception remain mandatory. Base advancement is unsupported without a receipt-bound signed CI trust root and therefore fails closed.
74
+ Pi additionally registers one one-shot authorization for the exact subsequent command. Successful `/gentle:review-mode disable` clears pending lifecycle authorization; each lifecycle command rechecks mode, so out-of-band disable discards stale authorization and proceeds organically, never as approval. Full target/publication derivation runs after controller-time native allow, before bash-time native validation, and again after that validation before command allow. `gh pr create` binds repository precedence (`--repo`, `GH_REPO`, local inference), the effective source/value, and the exact advertised remote head commit equal to reviewed local `HEAD`; pre-PR keeps fetch-side repository/base/head semantics. Existing native push destinations bind the command remote, destination ref, old/new objects, exact destination selector, and advertised old commit in one rederived fingerprint only when effective push and fetch URL/identity match. Split fetch/push pre-push is an upstream v2.1.1 contract limitation: `<remote>/<branch>` resolves through fetch-side remote-tracking state even when Pi probes `pushurl`, so Pi fails closed before native validation with `native-split-fetch-push-unsupported-until-upstream-supports-explicit-push-base`. Native first-push authorization remains unsupported until a separate follow-up adds a persisted explicit advertised-base source, so a missing destination fails closed instead of inferring an upstream, default branch, or nearest ancestor. Publication probes are shell-free, bounded, and cancellation-aware, and the complete bash-time publication/native revalidation has one aggregate bounded deadline combined with Pi's cancellation signal when available. Repository identity, first-push destination, push destination, exact PR base/head, release evidence, protected-main release fast path, and fail-closed dangerous-command interception remain mandatory. Base advancement is unsupported without a receipt-bound signed CI trust root and therefore fails closed.
73
75
 
74
76
  Release from protected `main` may bypass receipt validation only when the tag targets the current immutable `origin/main` SHA, required CI for that exact SHA is independently proven successful, the remote head is rechecked before tag push, and no fresh risk evidence exists. Major and post-incident releases require explicit extraordinary review.
75
77