Skip to content
Open
4 changes: 2 additions & 2 deletions docs/readme-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -816,9 +816,9 @@ Contract `/v2` replaces the Base64 `candidate_diff` reviewer transport of `/v1`

Target status owns `current_target`, `unrelated`, `ambiguous`, and `corrupted` applicability and returns one native action. Pi does not reconstruct ordinary authority from provider-private files or choose a lineage from repository-wide history. Restart recovery rebuilds only the derived candidate view from the native Git/content projection, including intended-untracked paths, symlinks, and immutable gitlink identities. Native failure envelopes retain their exact mutation outcome, replayability, required inputs, request digest, and next action. After an unknown or lost mutating result, Pi calls target status before any replay decision and returns only the provider-declared action.

Candidate views materialize tracked Git symlinks from their frozen blobs even when `core.symlinks=false`, including unchanged links outside the changed scope. Unsafe targets fail before any link is created; a host without native symlink capability fails closed with `symlink-materialization-failed`. Pi does not alter the contributor's Git configuration or install symlink privileges.
Candidate views materialize tracked Git symlinks from their frozen blobs even when `core.symlinks=false`, including unchanged links outside the changed scope. Unsafe targets fail before any link is created; a host without native symlink capability fails closed with `symlink-materialization-failed`. Pi does not alter the contributor's Git configuration or install symlink privileges. Before `inspect` advertises a ready `review.start`, it runs the same non-persistent capability check over the frozen candidate tree (the probe creates and removes a temporary directory under the candidate-views parent, or its nearest existing ancestor), so a host that cannot create the tracked symlinks sees the refusal at inspect time instead of a START that cannot begin (gentle-ai#2196).

On POSIX, if START rejects a group- or world-accessible `.git/gentle-ai/candidate-views` parent, Pi reports `candidate-owner-parent-privacy` before native START. When a sanitized probe shows the filesystem cannot represent private POSIX modes (for example WSL DrvFS mounts without `metadata`), Pi instead reports `candidate-owner-parent-chmod-ineffective` with guidance to move the Git common directory to a POSIX-metadata filesystem or enable metadata support. Inspect that parent's ownership and permissions and correct them out of band before retrying; Pi does not change them automatically. Other owner-preparation failures retain a generic diagnostic rather than exposing filesystem errors.
On POSIX, if START rejects a group- or world-accessible `.git/gentle-ai/candidate-views` parent, Pi reports `candidate-owner-parent-privacy` before native START. When a sanitized probe shows the filesystem cannot represent private POSIX modes (for example WSL DrvFS mounts without `metadata`), Pi instead reports `candidate-owner-parent-chmod-ineffective` with guidance to move the Git common directory to a POSIX-metadata filesystem or enable metadata support. Inspect that parent's ownership and permissions and correct them out of band before retrying; Pi does not change them automatically. Other owner-preparation failures retain a generic diagnostic rather than exposing filesystem errors. `inspect` runs the same read-only privacy assert before advertising a ready `review.start` and withholds the advertisement with the identical typed diagnostic under `candidate-view-preflight-blocked`, so STATUS and START stop disagreeing about a candidate whose view cannot materialize (gentle-ai#2196).

Once the source checkout's pinned gentle-ai runtime (currently v4.2.0) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path.

Expand Down
192 changes: 190 additions & 2 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ import { execFileSync } from "node:child_process";
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
import { isProxy } from "node:util/types";
import {
accessSync,
constants as fsConstants,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import {
Expand Down Expand Up @@ -169,7 +174,8 @@ import {
import { GentleAiElapsedTimingLedger } from "../lib/gentle-ai-elapsed-store.ts";
import { renderGentleAiLifecycleCall, renderGentleAiResult, type GentleAiRenderContext } from "../lib/gentle-ai-renderer.ts";
import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
import { BASE_REF_ACCEPTED_FORMS, CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, isProviderCandidateBaseTree, type CandidateView } from "../lib/review-candidate-view.ts";
import { BASE_REF_ACCEPTED_FORMS, CANDIDATE_GIT_MAX_BUFFER_BYTES, candidateOwnerPreparationError, CandidateViewError, CandidateViewRegistry, CANDIDATE_SYMLINK_CAPABILITY_UNCONFIRMED_MESSAGE, CANDIDATE_SYMLINK_MATERIALIZATION_MESSAGE, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, resolveCandidateGitTimeoutMs, isProviderCandidateBaseTree, type CandidateView } from "../lib/review-candidate-view.ts";
import { assertCandidateOwnerParent } from "../lib/review-candidate-view-owner.ts";
import {
GentleAiDevBinaryOverrideError,
GENTLE_AI_INSTALL_RECOVERY_COMMAND,
Expand Down Expand Up @@ -7421,6 +7427,167 @@ function nativePreLineageCandidateIdentity(
: undefined;
}

// gentle-ai#2196: inspect must not advertise a START whose candidate-view
// materialization is already known to fail. START materializes the frozen
// view and then verifies cheap non-mutating invariants; when one is
// deterministically false (the POSIX candidate owner parent is not private,
// or the frozen tree tracks symlinks this process cannot create natively),
// inspect runs the same non-persistent checks first and withholds the ready
// advertisement with the exact typed diagnostics the START refusal would
// carry, so STATUS and START stop disagreeing about operations that cannot
// begin. The Windows owner boundary keeps its START-time DACL validation for
// now; this preflight only answers questions a non-persistent check settles
// (the capability probe creates and removes a temporary directory). The
// symlink scan never claims a confirmed symlink-free tree from an incomplete
// listing (timeout, output limit, unreadable tree), and the capability probe
// samples the filesystem that will hold the candidate view — when that
// filesystem cannot be probed, the capability stays unconfirmed and the
// advertisement is withheld instead of guessing from another filesystem.
let candidateViewSymlinkProbe: ((probeRoot: string) => void) | undefined;

function setCandidateViewSymlinkProbeForTesting(probe: ((probeRoot: string) => void) | undefined): void {
candidateViewSymlinkProbe = probe;
}

// Thrown when no existing directory of the candidate-view filesystem can host
// the probe: the capability stays unconfirmed instead of being reported as
// incapable, so an unverifiable filesystem never masquerades as a pass.
class CandidateViewSymlinkProbeUnavailableError extends Error {}

function defaultCandidateViewSymlinkProbe(probeRoot: string): void {
let directory: string;
try {
directory = mkdtempSync(join(probeRoot, "gentle-symlink-probe-"));
} catch (error) {
throw new CandidateViewSymlinkProbeUnavailableError("cannot create a probe directory on the candidate-view filesystem", { cause: error });
}
try {
symlinkSync("probe-target", join(directory, "probe"), process.platform === "win32" ? "file" : undefined);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}

// The deepest existing ancestor of the candidate-view parent: probing there
// samples the filesystem that will actually hold the candidate view. Before
// the first START only the Git common directory exists; after one, the probe
// lands inside the real candidate-views parent.
function nearestExistingAncestorDirectory(path: string): string | undefined {
let current = path;
for (;;) {
try {
if (statSync(current).isDirectory()) return current;
} catch {
// Missing or unreadable: keep walking up.
}
const parent = dirname(current);
if (parent === current) return undefined;
current = parent;
}
}

type CandidateTreeSymlinkScan = "tracks-symlinks" | "no-symlinks" | "unconfirmed";

function scanCandidateTreeForSymlinks(cwd: string, candidateTree: string): CandidateTreeSymlinkScan {
// Discovery must describe the selected directory, never an ambient Git
// override pointing at a different repository or worktree.
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
try {
const listing = execFileSync("git", ["ls-tree", "-r", "--full-tree", candidateTree], {
cwd,
env,
encoding: "utf8",
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
// Same bounds the candidate-view materialization uses: a bounded
// child that cannot hang the event loop, and a listing past the
// output limit is an incomplete scan, never a symlink-free verdict.
timeout: resolveCandidateGitTimeoutMs(),
maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES,
});
return listing.split("\n").some((line) => line.startsWith("120000 ")) ? "tracks-symlinks" : "no-symlinks";
} catch {
// A timed-out, over-limit, or otherwise unreadable tree is an
// incomplete scan, not a deterministic materialization failure;
// START owns reporting unreadable trees.
return "unconfirmed";
}
}

type CandidateViewSymlinkCapability = "capable" | "incapable" | "unconfirmed";

function candidateViewSymlinkCapability(commonDir: string | undefined): CandidateViewSymlinkCapability {
if (commonDir === undefined) return "unconfirmed";
const probeRoot = nearestExistingAncestorDirectory(join(commonDir, "gentle-ai", "candidate-views"));
if (probeRoot === undefined) return "unconfirmed";
try {
(candidateViewSymlinkProbe ?? defaultCandidateViewSymlinkProbe)(probeRoot);
} catch (error) {
return error instanceof CandidateViewSymlinkProbeUnavailableError ? "unconfirmed" : "incapable";
}
return "capable";
}

function candidateViewPreflightRejection(status: ReviewStatusV3, diagnostics: Record<string, string>): Record<string, unknown> {
// The native body carries the executable START transition; consumers read the
// nested result.next_transition, so a blocked inspect must not forward it.
const { next_transition: _withheldTransition, ...blockedResult } = status.raw;
return {
operation: REVIEW_CONTROLLER_OPERATION.INSPECT,
status: "blocked",
outcome: "candidate-view-preflight-blocked",
result: blockedResult,
diagnostics,
mutation_performed: false,
mutation_outcome: "none",
lineage_created: false,
next_action: "resolve-native-operation-failure",
};
}

function inspectCandidateViewPreflightBlocked(status: ReviewStatusV3, cwd: string, commonDir: string | undefined, platform: NodeJS.Platform = process.platform): Record<string, unknown> | undefined {
if (status.action !== "start") return undefined;
if (platform !== "win32" && commonDir !== undefined) {
try {
assertCandidateOwnerParent(commonDir, platform);
} catch (error) {
// A missing parent is not a deterministic failure while START's prepare
// can still create it private under the nearest existing ancestor. Every
// other owner failure makes START refuse too, so withhold the
// advertisement with the owner-preparation classification.
let failureCause: unknown = error;
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
failureCause = undefined;
const ancestor = nearestExistingAncestorDirectory(join(commonDir, "gentle-ai", "candidate-views"));
try {
if (ancestor !== undefined) accessSync(ancestor, fsConstants.W_OK | fsConstants.X_OK);
} catch (accessError) {
failureCause = accessError;
}
}
if (failureCause !== undefined) {
const failure = candidateOwnerPreparationError(failureCause);
const typed = failure.diagnostics !== undefined && "code" in failure.diagnostics ? failure.diagnostics : undefined;
return candidateViewPreflightRejection(status, typed ?? { code: failure.reason, message: failure.message });
}
}
}
const candidateTree = status.projection.currentCandidateTree;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (isCanonicalProcessString(candidateTree) && scanCandidateTreeForSymlinks(cwd, candidateTree) === "tracks-symlinks") {
const capability = candidateViewSymlinkCapability(commonDir);
if (capability === "unconfirmed") {
// The tree needs native symlinks and the target filesystem could not
// be probed: advertising START would only move the same uncertainty
// into a materialization refusal (gentle-ai#2196).
return candidateViewPreflightRejection(status, { code: "symlink-capability-unconfirmed" as const, message: CANDIDATE_SYMLINK_CAPABILITY_UNCONFIRMED_MESSAGE });
}
if (capability === "incapable") {
return candidateViewPreflightRejection(status, { code: "symlink-materialization-failed" as const, message: CANDIDATE_SYMLINK_MATERIALIZATION_MESSAGE });
}
}
return undefined;
}

function readRetainedPreLineageNativeUntrackedSelection(
selections: Map<string, RetainedNativeStatusSelection>,
workspaceRoot: string,
Expand Down Expand Up @@ -8811,6 +8978,12 @@ async function executeReviewControllerOperation(
retainNativeStatusSelection(retainedUntrackedSelections, reviewLifecycleStorageKey(defaultCwd, ""), Object.freeze({ ...readyCandidateIdentity, baseRef: canonicalBaseRef, committedOnly: true as const }));
}
}
// gentle-ai#2196: a ready advertisement must survive the same cheap
// candidate-view invariants START verifies, or be withheld.
if (plainMapped.status === "ready") {
const preflightBlocked = inspectCandidateViewPreflightBlocked(status, defaultCwd, workspaceResolution.identity?.commonDir);
if (preflightBlocked !== undefined) return { ...preflightBlocked, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
}
// gentle-pi#706: the stop alone never tells the caller what to do next.
return {
...plainMapped,
Expand All @@ -8821,12 +8994,18 @@ async function executeReviewControllerOperation(
};
}
const input = reviewIntendedUntrackedInput(status);
if (input === undefined)
if (input === undefined) {
// gentle-ai#2196: same ready-advertisement preflight as the plain path.
if (plainMapped.status === "ready") {
const preflightBlocked = inspectCandidateViewPreflightBlocked(status, defaultCwd, workspaceResolution.identity?.commonDir);
if (preflightBlocked !== undefined) return { ...preflightBlocked, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
}
return {
...plainMapped,
untracked_selection: "not-required",
...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}),
};
}
const eligibleJson = exactCollectArgument(input, "eligible_paths_json"),
inventory = exactCollectArgument(input, "expected_untracked_inventory");
let eligible: unknown;
Expand Down Expand Up @@ -8910,6 +9089,12 @@ async function executeReviewControllerOperation(
undefined,
includeWorkspaceRoot ? defaultCwd : undefined,
);
// gentle-ai#2196: the resolved selection path advertises START under the
// same cheap candidate-view invariants as the plain path.
if (resolvedMapped.status === "ready") {
const preflightBlocked = inspectCandidateViewPreflightBlocked(resolvedStatus, defaultCwd, workspaceResolution.identity?.commonDir);
if (preflightBlocked !== undefined) return { ...preflightBlocked, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) };
}
return {
...resolvedMapped,
...("selectionBinding" in resolvedMapped
Expand Down Expand Up @@ -9718,6 +9903,9 @@ async function executeReviewControllerOperation(

/** @internal */
export const __testing = {
setCandidateViewSymlinkProbeForTesting,
CandidateViewSymlinkProbeUnavailableError,
scanCandidateTreeForSymlinks,
parseReviewControllerParameters,
parseReviewCaptureParameters,
parseReviewCaptureGroupParameters,
Expand Down
Loading
Loading