Repository navigation
feat(runtime): explicit dev-binary override with forward decoder support #325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import { createHash } from "node:crypto"; | ||
| import { existsSync, lstatSync, readFileSync } from "node:fs"; | ||
| import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { dirname, isAbsolute, join, relative, resolve } from "node:path"; | ||
| import { | ||
| GENTLE_AI_INSTALL_METHOD, | ||
|
|
@@ -46,6 +47,165 @@ function sha256(value: Buffer): string { | |
| return createHash("sha256").update(value).digest("hex"); | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Dev-binary override — the maintainer field-test lane. | ||
| // | ||
| // Two explicit activation paths, in precedence order: | ||
| // 1. GENTLE_PI_GENTLE_AI_DEV_BINARY (session override, absolute path), then | ||
| // 2. the persistent registration file at | ||
| // <GENTLE_PI_CONFIG_HOME|~/.pi/gentle-ai>/dev-binary.json with the strict | ||
| // shape {"schema":"gentle-pi.dev-binary/v1","path":"<absolute path>"}. | ||
| // | ||
| // The registration deliberately pins no digest: it is the unpinned field-test | ||
| // mode, and the binary at that path changes on every rebuild. Every resolution | ||
| // re-validates the file and recomputes the sha256, so a rebuilt binary is | ||
| // followed automatically with a fresh digest and no re-registration. | ||
| // | ||
| // Guardrails per resolution: absolute path, regular non-symlink file, POSIX | ||
| // executable. Any failure — including a malformed registration document or a | ||
| // registered-but-missing binary — is a typed error naming its origin, never a | ||
| // silent fallback to the pinned binary: silently running the pin while the | ||
| // maintainer believes he is field-testing main is the worst possible outcome. | ||
| // With neither activation path present, the pinned supply-chain resolution | ||
| // below stays byte-identical. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export const GENTLE_AI_DEV_BINARY_ENV = "GENTLE_PI_GENTLE_AI_DEV_BINARY"; | ||
| export const GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA = "gentle-pi.dev-binary/v1"; | ||
| export const GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE = "dev-binary-override-invalid"; | ||
|
|
||
| export interface GentleAiDevBinaryEnvironment { | ||
| env: Record<string, string | undefined>; | ||
| home: string; | ||
| } | ||
|
|
||
| export interface GentleAiDevBinaryOverride { | ||
| source: "env" | "registration"; | ||
| /** The env var name or registration file path that selected this binary. */ | ||
| origin: string; | ||
| path: string; | ||
| sha256: string; | ||
| } | ||
|
|
||
| export class GentleAiDevBinaryOverrideError extends Error { | ||
| readonly code = GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE; | ||
| readonly source: "env" | "registration"; | ||
| readonly origin: string; | ||
| constructor(source: "env" | "registration", origin: string, reason: string) { | ||
| super(`${GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE}: ${origin} ${reason}. Fix or remove the override; the pinned binary is never used silently while an override is declared.`); | ||
| this.name = "GentleAiDevBinaryOverrideError"; | ||
| this.source = source; | ||
| this.origin = origin; | ||
| } | ||
| } | ||
|
|
||
| let devBinaryEnvironmentTestingOverlay: GentleAiDevBinaryEnvironment | undefined; | ||
|
|
||
| /** Testing-only environment overlay; production code never calls this. */ | ||
| export function setGentleAiDevBinaryEnvironmentForTesting(environment: GentleAiDevBinaryEnvironment | undefined): void { | ||
| devBinaryEnvironmentTestingOverlay = environment; | ||
| } | ||
|
|
||
| function ambientDevBinaryEnvironment(): GentleAiDevBinaryEnvironment { | ||
| return devBinaryEnvironmentTestingOverlay ?? { env: process.env, home: homedir() }; | ||
| } | ||
|
|
||
| export function gentleAiDevBinaryRegistrationPath(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): string { | ||
| const configHome = environment.env.GENTLE_PI_CONFIG_HOME ?? join(environment.home, ".pi", "gentle-ai"); | ||
| return join(configHome, "dev-binary.json"); | ||
|
Comment on lines
+113
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Reject empty and relative configuration homes.
Add regression tests for empty and relative 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| function validateDevBinary(source: "env" | "registration", origin: string, path: string, platform: string): GentleAiDevBinaryOverride { | ||
| if (typeof path !== "string" || path.length === 0) throw new GentleAiDevBinaryOverrideError(source, origin, "declares an empty dev binary path"); | ||
| if (!isAbsolute(path)) throw new GentleAiDevBinaryOverrideError(source, origin, `must name an absolute path, received "${path}"`); | ||
| let details: ReturnType<typeof lstatSync>; | ||
| try { | ||
| details = lstatSync(path); | ||
| } catch { | ||
| throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which does not exist`); | ||
| } | ||
| if (!details.isFile() || details.isSymbolicLink()) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a regular non-symlink file`); | ||
| if (platform !== "win32" && (details.mode & 0o111) === 0) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a POSIX executable`); | ||
| let digest: string; | ||
| try { | ||
| digest = sha256(readFileSync(path)); | ||
| } catch { | ||
| throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which could not be read`); | ||
| } | ||
| return { source, origin, path, sha256: digest }; | ||
| } | ||
|
|
||
| function readDevBinaryRegistration(registrationPath: string): string { | ||
| let contents: string; | ||
| try { | ||
| contents = readFileSync(registrationPath, "utf8"); | ||
| } catch { | ||
| throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "could not be read"); | ||
| } | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(contents); | ||
| } catch { | ||
| throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "is not valid JSON"); | ||
| } | ||
| if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must be a JSON object"); | ||
| const record = parsed as Record<string, unknown>; | ||
| const keys = Object.keys(record).sort(); | ||
| if (keys.length !== 2 || keys[0] !== "path" || keys[1] !== "schema") throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must carry exactly the keys "schema" and "path"`); | ||
| if (record.schema !== GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must declare schema ${GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA}`); | ||
| if (typeof record.path !== "string" || record.path.length === 0) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must declare a non-empty string path"); | ||
| return record.path; | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the active dev-binary override, if any. Returns undefined only when | ||
| * neither activation path is present; a present-but-invalid override always | ||
| * throws a typed GentleAiDevBinaryOverrideError naming its origin. | ||
| */ | ||
| export function resolveGentleAiDevBinaryOverride( | ||
| environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(), | ||
| platform = process.platform, | ||
| ): GentleAiDevBinaryOverride | undefined { | ||
| const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV]; | ||
| if (envValue !== undefined && envValue.length > 0) return validateDevBinary("env", GENTLE_AI_DEV_BINARY_ENV, envValue, platform); | ||
| const registrationPath = gentleAiDevBinaryRegistrationPath(environment); | ||
| if (!existsSync(registrationPath)) return undefined; | ||
| return validateDevBinary("registration", registrationPath, readDevBinaryRegistration(registrationPath), platform); | ||
| } | ||
|
|
||
| /** | ||
| * Cheap presence probe: is a dev-binary override declared at all? Used by the | ||
| * native CLI to select the unpinned version gate without hashing the binary. | ||
| * Declared-but-invalid still counts as configured — the resolution path will | ||
| * fail loudly with the typed error instead of quietly using the pin. | ||
| */ | ||
| export function gentleAiDevBinaryOverrideConfigured(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): boolean { | ||
| const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV]; | ||
| if (envValue !== undefined && envValue.length > 0) return true; | ||
| return existsSync(gentleAiDevBinaryRegistrationPath(environment)); | ||
| } | ||
|
|
||
| /** Validates and persistently registers a dev binary; returns the fresh override. */ | ||
| export function registerGentleAiDevBinary( | ||
| path: string, | ||
| environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(), | ||
| platform = process.platform, | ||
| ): { registrationPath: string; override: GentleAiDevBinaryOverride } { | ||
| const registrationPath = gentleAiDevBinaryRegistrationPath(environment); | ||
| const validated = validateDevBinary("registration", registrationPath, path, platform); | ||
| mkdirSync(dirname(registrationPath), { recursive: true }); | ||
| writeFileSync(registrationPath, `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path })}\n`); | ||
| return { registrationPath, override: validated }; | ||
| } | ||
|
|
||
| /** Deletes the persistent registration; returns whether one existed. */ | ||
| export function unregisterGentleAiDevBinary(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): boolean { | ||
| const registrationPath = gentleAiDevBinaryRegistrationPath(environment); | ||
| if (!existsSync(registrationPath)) return false; | ||
| rmSync(registrationPath); | ||
| return true; | ||
| } | ||
|
|
||
| function isConfined(path: string, directory: string): boolean { | ||
| const relativePath = relative(directory, path); | ||
| return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); | ||
|
|
@@ -110,7 +270,14 @@ export function resolveGentleAiBinary( | |
| packageRoot = dirname(dirname(fileURLToPath(import.meta.url))), | ||
| platform = process.platform, | ||
| readBinary: (path: string) => Buffer = readFileSync, | ||
| environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(), | ||
| ): string { | ||
| // The explicit dev-binary override wins over the pinned supply-chain path. | ||
| // Its typed errors propagate: a declared override never falls back to the | ||
| // pin. Without a declared override this call returns undefined and the | ||
| // pinned resolution below is byte-identical to the pre-override behavior. | ||
| const override = resolveGentleAiDevBinaryOverride(environment, platform); | ||
| if (override !== undefined) return override.path; | ||
| const binaryPath = gentleAiBinaryPath(packageRoot, platform); | ||
| const versionDirectory = dirname(binaryPath); | ||
| const manifestPath = join(versionDirectory, "integrity.json"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Do not report the pinned binary after removing only the registration.
If
GENTLE_PI_GENTLE_AI_DEV_BINARYis set, this command removes the registration file but the environment override remains selected. The current message says that the pinned binary is active while later resolution still executes the environment binary.Resolve and report the override state after removal. Tell the user to unset
GENTLE_PI_GENTLE_AI_DEV_BINARYwhen it remains active.Proposed fix
if (argument === "off") { const removed = unregisterGentleAiDevBinary(); - ctx.ui.notify(removed ? "Gentle AI dev binary registration removed; the pinned binary is active again." : "No dev binary registration to remove.", "info"); + const described = await describeDevBinaryOverride(); + if (described.state === "active") { + ctx.ui.notify(`Gentle AI dev binary registration removed. ${described.line} Unset GENTLE_PI_GENTLE_AI_DEV_BINARY to return to the pinned binary.`, "warning"); + } else { + ctx.ui.notify(removed ? "Gentle AI dev binary registration removed; the pinned binary is active again." : "No dev binary registration to remove.", "info"); + } return; }🧰 Tools
🪛 ast-grep (0.45.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile, execFileSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents