From c2f5ad412440753499befedb40e6cbcbcd1cde13 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Sun, 16 Aug 2026 17:13:31 -0300 Subject: [PATCH 1/4] test(review): add organic provider role vectors --- scripts/maintainer/provider-relay-matrix.mjs | 196 ++++++++++++++++++- tests/maintainer/provider-relay.maintest.ts | 191 +++++++++++++++++- 2 files changed, 382 insertions(+), 5 deletions(-) diff --git a/scripts/maintainer/provider-relay-matrix.mjs b/scripts/maintainer/provider-relay-matrix.mjs index f30fa7ac9..c6d67d166 100644 --- a/scripts/maintainer/provider-relay-matrix.mjs +++ b/scripts/maintainer/provider-relay-matrix.mjs @@ -9,12 +9,60 @@ // `pnpm test` never imports it; `pnpm run test:maintainer` runs the tests; // the CLI is the entry point the separate verifier uses for the real organic // positive journey after a user-visible forecast. +import { spawn } from "node:child_process"; import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, isAbsolute, join, resolve } from "node:path"; -import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, resolveReviewHostRelaySubmission, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts"; +import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, classifyReviewHostRelayRefusal, resolveReviewHostRelaySubmission, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts"; +import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts"; export const DESCRIPTOR_SCHEMA = "gentle-pi.maintainer.provider-relay-descriptor/v1"; -export const CASE_KINDS = Object.freeze(["relay-unavailable", "positive-lens"]); +export const CASE_KINDS = Object.freeze(["relay-unavailable", "positive-lens", "provider-role-refuter", "provider-role-validator"]); +// gentle-pi#311 P7 — the two organic provider-role vectors that remain. Unlike +// the lens capture-result slots, these are SELF-CONTAINED: the provider renders +// binding tokens plus `--agent=pi --execute=true` (no submission descriptor), +// and executing the exact rendered invocation makes Go materialize the role +// prompt, spawn its own locked-down pi subprocess, and admit the raw verdict. +// The host runs one CLI invocation verbatim; it never materializes, launches +// pi, or submits anything for these slots. +export const PROVIDER_ROLE_VECTOR_KINDS = Object.freeze(["provider-role-refuter", "provider-role-validator"]); +export const PROVIDER_ROLE_VECTOR_VERB = Object.freeze({ "provider-role-refuter": "capture-refuter", "provider-role-validator": "capture-validation" }); +export const PROVIDER_ROLE_VECTOR_ROLE = Object.freeze({ "provider-role-refuter": "refuter", "provider-role-validator": "targeted-validator" }); +export const PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA = "gentle-ai.review-provider-role-capture/v1"; +export const ROLE_VECTOR_FAILURE = Object.freeze({ + ROLE_SURFACE_UNAVAILABLE: "role-surface-unavailable", + ROLE_LAUNCH_FAILED: "role-launch-failed", + ROLE_FAILED: "role-failed", + EMPTY_ARTIFACT: "empty-artifact", + HANDSHAKE_REFUSED: "handshake-refused", +}); +const REQUEST_HASH_RE = /^sha256:[0-9a-f]{64}$/; +const RCTX_RE = /^rctx1_[0-9a-f]{64}$/; +// Stale-target fail-closed: the provider-returned binding tokens are the +// sole authority for lineage and target identity. Each must appear exactly +// once; the matrix later compares the returned artifact against these values. +const ROLE_BINDING_RE = Object.freeze({ "--lineage=": /^.+$/, "--expected-revision=": REQUEST_HASH_RE, "--target=": REQUEST_HASH_RE, "--repository-context=": RCTX_RE }); +const DEFAULT_ROLE_VECTOR_TIMEOUT_MS = 600_000; +export class ProviderRoleVectorError extends Error { + kind; + stage; + exitCode; + stderr; + timedOut; + mutationOutcome; + // "none" until the role invocation launches Go's role work; a launched + // role whose outcome could not be read is "unknown" and the caller + // re-queries negotiated STATUS, never through a blind retry. + constructor(kind, stage, message, details) { + super(message); + this.name = "ProviderRoleVectorError"; + this.kind = kind; + this.stage = stage; + this.exitCode = details?.exitCode ?? null; + this.stderr = details?.stderr ?? ""; + this.timedOut = details?.timedOut ?? false; + this.mutationOutcome = kind === ROLE_VECTOR_FAILURE.ROLE_FAILED ? "unknown" : "none"; + } +} // The positive lens runs only when explicitly armed: the organic journey // needs a real capable binary, a real pi, and a real review session. Without // the arm the runner blocks the positive leg before materialize. @@ -53,11 +101,14 @@ export function validateDescriptor(value) { const seen = new Set(); return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable, piExecutable: value.piExecutable, cases: value.cases.map((entry, index) => { if (!isObj(entry)) fail(`descriptor.cases[${index}] must be an object`); - exactKeys(entry, new Set(["name", "kind", "captureArgumentTokens", "submission"]), `descriptor.cases[${index}]`); if (!isStr(entry.name)) fail(`descriptor.cases[${index}].name must be a non-empty string`); if (seen.has(entry.name)) fail(`descriptor.cases[${index}].name "${entry.name}" is duplicated`); seen.add(entry.name); if (!CASE_KINDS.includes(entry.kind)) fail(`descriptor.cases[${index}].kind must be one of ${JSON.stringify([...CASE_KINDS])}`); + if (entry.kind === "provider-role-refuter" || entry.kind === "provider-role-validator") { + return validateRoleCaseEntry(entry, index); + } + exactKeys(entry, new Set(["name", "kind", "captureArgumentTokens", "submission"]), `descriptor.cases[${index}]`); if (!isStrArr(entry.captureArgumentTokens)) fail(`descriptor.cases[${index}].captureArgumentTokens must be a non-empty array of non-empty strings`); // A real host-relay materialize slot is the provider-issued // --agent=pi --materialize=true pair; the descriptor must declare @@ -93,6 +144,50 @@ export function validateDescriptor(value) { }), }; } +// Validates one provider-role vector case. The descriptor carries the +// provider-returned argument tokens verbatim plus, for the targeted validator, +// a MINIMAL binding (schema + requestHash) extracted from the embedded +// validation_request — never the full provider descriptor. The matrix asserts +// the `--request-hash=` token in the argument vector matches that +// requestHash, preserving the provider request hash / validation request +// binding without reconstructing the role payload. +function validateRoleCaseEntry(entry, index) { + const label = `descriptor.cases[${index}]`; + const allowedKeys = new Set(["name", "kind", "argumentTokens", ...(entry.kind === "provider-role-validator" ? ["validationRequest"] : [])]); + exactKeys(entry, allowedKeys, label); + if (!isStrArr(entry.argumentTokens)) fail(`${label}.argumentTokens must be a non-empty array of non-empty strings`); + // The provider-issued execute token pair is the self-contained vector + // signature; --materialize=true would make this a lens slot, a contract + // mismatch on a role vector. + for (const required of ["--agent=pi", "--execute=true"]) { + if (!entry.argumentTokens.includes(required)) fail(`${label}.argumentTokens must include the provider-issued "${required}" token`); + } + if (entry.argumentTokens.includes("--materialize=true")) { + fail(`${label}.argumentTokens must not include --materialize=true; a role vector is self-contained, never a lens materialize slot`); + } + const bindings = {}; + for (const [prefix, pattern] of Object.entries(ROLE_BINDING_RE)) { + const matches = entry.argumentTokens.filter((t) => t.startsWith(prefix)); + if (matches.length !== 1) fail(`${label}.argumentTokens must include exactly one "${prefix}" token, found ${matches.length}`); + const value = matches[0].slice(prefix.length); + if (!pattern.test(value)) fail(`${label}.argumentTokens "${prefix}" value is malformed`); + bindings[prefix] = value; + } + const result = { name: entry.name, kind: entry.kind, argumentTokens: [...entry.argumentTokens], lineage: bindings["--lineage="], targetIdentity: bindings["--target="] }; + if (entry.kind === "provider-role-validator") { + const vr = entry.validationRequest; + if (!isObj(vr)) fail(`${label}.validationRequest must be an object; the provider-embedded validation_request binding is required on the targeted-validator vector`); + exactKeys(vr, new Set(["schema", "requestHash"]), `${label}.validationRequest`); + if (vr.schema !== "gentle-ai.review-targeted-validation-request/v1") fail(`${label}.validationRequest.schema must be exactly "gentle-ai.review-targeted-validation-request/v1"`); + if (!isStr(vr.requestHash) || !REQUEST_HASH_RE.test(vr.requestHash)) fail(`${label}.validationRequest.requestHash must be a sha256 digest`); + const expectedToken = `--request-hash=${vr.requestHash}`; + if (!entry.argumentTokens.includes(expectedToken)) { + fail(`${label}.argumentTokens must include the provider-issued "${expectedToken}" token that binds the embedded validation_request`); + } + result.validationRequest = { schema: vr.schema, requestHash: vr.requestHash }; + } + return Object.freeze(result); +} export function loadDescriptor(path) { if (!isStr(path)) fail("a descriptor path is required"); let raw, parsed; @@ -130,6 +225,68 @@ function unlaunchablePi() { chmodSync(directory, 0o700); return { directory, executable: join(directory, "pi-must-never-launch") }; } +// Default role vector runner: spawns `gentle-ai review ` once, +// in the foreground, with the relay handshake environment. Go materializes the +// role prompt, spawns its own locked-down pi subprocess, and admits the raw +// verdict; the host runs one CLI invocation verbatim and decodes the typed +// artifact. Model/provider/profile stay user-owned: no --model/--provider is +// added, and the pi subprocess environment is exactly the user's own. +export async function runProviderRoleVector(request) { + const verb = PROVIDER_ROLE_VECTOR_VERB[request.kind]; + if (verb === undefined) throw new TypeError(`runProviderRoleVector received an unknown role vector kind: ${request.kind}`); + const argv = ["review", verb, ...request.argumentTokens]; + const env = { ...process.env, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT }; + let capture; + try { + capture = await new Promise((resolve, reject) => { + const child = spawn(request.gentleAiExecutable, argv, { + cwd: process.cwd(), + env, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + windowsHide: true, + ...(request.signal === undefined ? {} : { signal: request.signal }), + }); + const stdout = []; + const stderr = []; + let timedOut = false; + let settled = false; + const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, request.timeoutMs ?? DEFAULT_ROLE_VECTOR_TIMEOUT_MS); + timer.unref(); + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + child.on("error", (error) => { if (settled) return; settled = true; clearTimeout(timer); reject(error); }); + child.on("close", (code) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut }); }); + }); + } catch (error) { + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_LAUNCH_FAILED, "launch", `gentle-ai role vector could not start: ${error instanceof Error ? error.message : String(error)}`); + } + const stderrText = capture.stderr.toString("utf8"); + if (capture.exitCode !== 0 || capture.timedOut) { + const refusal = classifyReviewHostRelayRefusal(stderrText); + if (refusal === "handshake") { + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED, "execute", stderrText, { exitCode: capture.exitCode, stderr: stderrText, timedOut: capture.timedOut }); + } + if (refusal === "unknown-flag") { + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "execute", "the declared gentle-ai binary lacks the provider role capture surface", { exitCode: capture.exitCode, stderr: stderrText, timedOut: capture.timedOut }); + } + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector failed", { exitCode: capture.exitCode, stderr: stderrText, timedOut: capture.timedOut }); + } + if (capture.stdout.length === 0) { + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT, "execute", "gentle-ai role vector produced no artifact bytes", { exitCode: 0, stderr: stderrText }); + } + let artifact; + try { + artifact = JSON.parse(capture.stdout.toString("utf8")); + } catch (error) { + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", `gentle-ai role vector returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { exitCode: 0, stderr: stderrText }); + } + const expectedRole = PROVIDER_ROLE_VECTOR_ROLE[request.kind]; + if (!isObj(artifact) || artifact.schema !== PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA || artifact.role !== expectedRole || artifact.captured !== true) { + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector returned an artifact that does not match the expected typed shape", { exitCode: 0, stderr: stderrText }); + } + return { schema: artifact.schema, lineageId: artifact.lineage_id, targetIdentity: artifact.target_identity, role: artifact.role, captured: true }; +} // Runs the matrix against the REAL relay. A case that cannot run honestly is // `blocked` (never `pass`); `fail` is an armed case whose outcome mismatched. export async function runMatrix(descriptor, options = {}) { @@ -139,6 +296,10 @@ export async function runMatrix(descriptor, options = {}) { // inject a fake to assert the exact resolved path reaches the boundary // without executing a real subprocess (platform-neutral #324 evidence). const relay = options.relay ?? runReviewHostRelaySlot; + // Same seam for the role vectors: defaults to the real spawn so production + // CLI behavior is identical when unset; tests inject a fake to assert the + // exact kind/verb/tokens reach the boundary without launching a model. + const roleRunner = options.roleRunner ?? runProviderRoleVector; const verdicts = []; for (const caseEntry of descriptor.cases) { const gentleAiPath = resolveDeclaredExecutable(descriptor.gentleAiExecutable); @@ -146,6 +307,35 @@ export async function runMatrix(descriptor, options = {}) { verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.gentleAiExecutable, "gentleAiExecutable") }); continue; } + // Provider-role vectors are self-contained --execute invocations: Go + // materializes the role prompt, spawns its own locked-down pi, and + // admits the raw verdict. Like the positive lens, they need a real + // capable binary and an explicit arm; the runner never synthesizes a + // provider result. + if (caseEntry.kind === "provider-role-refuter" || caseEntry.kind === "provider-role-validator") { + if (!positiveArmed) { + verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `${caseEntry.kind} case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary, a real pi, and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic role vector. The runner never synthesizes a provider verdict.`, command: POSITIVE_JOURNEY_COMMAND }); + continue; + } + try { + const artifact = await roleRunner({ kind: caseEntry.kind, argumentTokens: caseEntry.argumentTokens, gentleAiExecutable: gentleAiPath, ...(caseEntry.validationRequest === undefined ? {} : { validationRequest: caseEntry.validationRequest }), ...(options.signal === undefined ? {} : { signal: options.signal }) }); + if (artifact.lineageId !== caseEntry.lineage || artifact.targetIdentity !== caseEntry.targetIdentity) { + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", `role vector returned a stale artifact: expected lineage=${caseEntry.lineage} target=${caseEntry.targetIdentity}, got lineage=${artifact.lineageId} target=${artifact.targetIdentity}`); + } + verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", role: artifact.role, lineageId: artifact.lineageId, targetIdentity: artifact.targetIdentity, captured: artifact.captured }); + } catch (error) { + if (error instanceof ProviderRoleVectorError) { + if (error.kind === ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE || error.kind === ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED) { + verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: `the declared gentle-ai binary lacks the provider role capture surface: ${error.message}`, command: POSITIVE_JOURNEY_COMMAND }); + } else { + verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `role vector error kind=${error.kind} stage=${error.stage} mutationOutcome=${error.mutationOutcome}: ${error.message}`, stderr: error.stderr }); + } + } else { + verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `unexpected non-role error: ${error instanceof Error ? error.message : String(error)}` }); + } + } + continue; + } // The positive lens needs a real pi AND an explicit arm; the negative // control never launches pi (fails closed at materialize). The precheck // resolves the declared Pi executable ONCE and reuses that exact diff --git a/tests/maintainer/provider-relay.maintest.ts b/tests/maintainer/provider-relay.maintest.ts index e1245026c..7ae084b9f 100644 --- a/tests/maintainer/provider-relay.maintest.ts +++ b/tests/maintainer/provider-relay.maintest.ts @@ -14,7 +14,7 @@ import { tmpdir } from "node:os"; import { delimiter, dirname, isAbsolute, join, relative } from "node:path"; import test from "node:test"; import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts"; -import { ARM_POSITIVE_ENV, CASE_KINDS, DESCRIPTOR_SCHEMA, DescriptorValidationError, POSITIVE_JOURNEY_COMMAND, loadDescriptor, resolveDeclaredExecutable, runMatrix, validateDescriptor } from "../../scripts/maintainer/provider-relay-matrix.mjs"; +import { ARM_POSITIVE_ENV, CASE_KINDS, DESCRIPTOR_SCHEMA, DescriptorValidationError, POSITIVE_JOURNEY_COMMAND, PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, PROVIDER_ROLE_VECTOR_KINDS, ProviderRoleVectorError, ROLE_VECTOR_FAILURE, loadDescriptor, resolveDeclaredExecutable, runMatrix, validateDescriptor } from "../../scripts/maintainer/provider-relay-matrix.mjs"; const BASELINE_ENV = "GENTLE_PI_MAINTAINER_BASELINE_BINARY"; const CAPABLE_ENV = "GENTLE_PI_MAINTAINER_CAPABLE_BINARY"; const REQUIRE_ENV = "GENTLE_PI_REQUIRE_MAINTAINER"; @@ -47,6 +47,43 @@ const SUBMISSION = Object.freeze({ values: Object.freeze([{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]), }); +// Provider-role vector fixtures (gentle-pi#311 P7). Synthetic stable values — +// never copied from a live probe. The refuter vector is binding tokens plus +// --agent=pi --execute=true (no submission, no validation_request). The +// validator vector adds --request-hash= and carries the minimal +// validation_request binding (schema + requestHash) extracted from the +// provider-embedded descriptor, so the matrix can assert the token matches. +const ROLE_LINEAGE = "review-fixture-role-vector"; +const ROLE_REVISION = `sha256:${"a".repeat(64)}`; +const ROLE_TARGET = `sha256:${"b".repeat(64)}`; +const ROLE_CONTEXT = `rctx1_${"c".repeat(64)}`; +const ROLE_REQUEST_HASH = `sha256:${"9".repeat(64)}`; +const REFUTER_TOKENS = Object.freeze([ + `--lineage=${ROLE_LINEAGE}`, + `--expected-revision=${ROLE_REVISION}`, + `--target=${ROLE_TARGET}`, + `--repository-context=${ROLE_CONTEXT}`, + "--agent=pi", + "--execute=true", +]); +const VALIDATOR_TOKENS = Object.freeze([ + `--lineage=${ROLE_LINEAGE}`, + `--expected-revision=${ROLE_REVISION}`, + `--target=${ROLE_TARGET}`, + `--repository-context=${ROLE_CONTEXT}`, + `--request-hash=${ROLE_REQUEST_HASH}`, + "--agent=pi", + "--execute=true", +]); +const VALIDATION_REQUEST = Object.freeze({ schema: "gentle-ai.review-targeted-validation-request/v1", requestHash: ROLE_REQUEST_HASH }); +const ROLE_ARTIFACT = Object.freeze({ + schema: "gentle-ai.review-provider-role-capture/v1", + lineage_id: ROLE_LINEAGE, + target_identity: ROLE_TARGET, + role: "refuter", + captured: true, +}); + function descriptor(overrides = {}) { return { schema: DESCRIPTOR_SCHEMA, @@ -83,7 +120,7 @@ test("rejects malformed descriptor fields with exact-shape errors (no defaults, rejects({ ...descriptor(), gentleAiExecutable: undefined }, "absolute path"); rejects({ ...descriptor(), extra: 1 }, "descriptor.extra"); rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, extra: 1 }] }, "extra"); - assert.deepEqual([...CASE_KINDS], ["relay-unavailable", "positive-lens"]); + assert.deepEqual([...CASE_KINDS], ["relay-unavailable", "positive-lens", ...PROVIDER_ROLE_VECTOR_KINDS]); rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, kind: "bogus" }] }, "kind must be one of"); const dup = { ...descriptor().cases[0]!, name: "dup" }; rejects({ ...descriptor(), cases: [structuredClone(dup), structuredClone(dup)] }, "duplicated"); @@ -108,6 +145,156 @@ test("loadDescriptor reads and validates a descriptor file", (t) => { assert.throws(() => loadDescriptor(join(dirname(path), "bad.json")), /not valid JSON/); }); +// --------------------------------------------------------------------------- +// Provider-role vector descriptor validation (gentle-pi#311 P7) — the two +// organic self-contained vectors. The descriptor carries provider-returned +// argument tokens verbatim; the matrix never reconstructs the role payload. +// Pure/deterministic; always runs. +// --------------------------------------------------------------------------- +function roleDescriptor(kind: "provider-role-refuter" | "provider-role-validator", overrides: Record = {}) { + const tokens = kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS; + const entry: Record = { name: `${kind}-case`, kind, argumentTokens: [...tokens] }; + if (kind === "provider-role-validator") entry.validationRequest = structuredClone(VALIDATION_REQUEST); + return { schema: DESCRIPTOR_SCHEMA, gentleAiExecutable: PLACEHOLDER_BINARY, piExecutable: "pi", cases: [entry], ...overrides }; +} +// A declared gentle-ai executable that EXISTS so resolveDeclaredExecutable +// succeeds and the role branch is reached. The stub roleRunner replaces the +// real spawn, so this file is never executed and no model launches. +const ROLE_STUB_BINARY = sandboxPath("gentle-ai-role-stub"); +writeFileSync(ROLE_STUB_BINARY, "stub"); +test("role vector descriptors validate and return a normalized copy with exact provider tokens", () => { + const refuter = validateDescriptor(roleDescriptor("provider-role-refuter")).cases[0]!; + assert.equal(refuter.kind, "provider-role-refuter"); + assert.deepEqual(refuter.argumentTokens, [...REFUTER_TOKENS]); + assert.equal("validationRequest" in refuter, false); + assert.equal("captureArgumentTokens" in refuter, false); + assert.equal("submission" in refuter, false); + const validator = validateDescriptor(roleDescriptor("provider-role-validator")).cases[0]!; + assert.deepEqual(validator.argumentTokens, [...VALIDATOR_TOKENS]); + assert.deepEqual(validator.validationRequest, VALIDATION_REQUEST); +}); +test("role vector descriptors reject missing --agent=pi / --execute=true and reject --materialize=true (self-contained, never a lens slot)", () => { + rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS].filter((t) => t !== "--agent=pi") }] }), "--agent=pi"); + rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS].filter((t) => t !== "--execute=true") }] }), "--execute=true"); + rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS, "--materialize=true"] }] }), "--materialize=true"); +}); +test("targeted-validator descriptor preserves the provider request hash / validation request binding", () => { + // The --request-hash token must match validationRequest.requestHash exactly. + const mismatched = { ...structuredClone(VALIDATION_REQUEST), requestHash: `sha256:${"0".repeat(64)}` }; + rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS], validationRequest: mismatched }] }), "must include the provider-issued"); + // A validator without validationRequest is a contract violation. + rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS] }] }), "validationRequest"); + // A refuter must not carry validationRequest (only the validator does). + rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS], validationRequest: structuredClone(VALIDATION_REQUEST) }] }), "validationRequest"); + // A malformed requestHash is rejected. + const badHash = { ...structuredClone(VALIDATION_REQUEST), requestHash: "not-a-sha256" }; + rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS], validationRequest: badHash }] }), "sha256"); +}); + +// --------------------------------------------------------------------------- +// Provider-role vector execution (stub-injected roleRunner) — proves the +// matrix reaches the boundary with the exact kind, verb, argument tokens, and +// preserved validation_request binding, exactly once, without launching a +// model. Deterministic; always runs. +// --------------------------------------------------------------------------- +test("refuter vector: runMatrix executes exactly once through review.capture-refuter with exact provider tokens and returns the typed artifact", async () => { + const calls: Array<{ kind: string; argumentTokens: readonly string[]; gentleAiExecutable: string; validationRequest?: unknown }> = []; + const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { + armPositive: true, + roleRunner: async (request) => { + calls.push({ kind: request.kind, argumentTokens: request.argumentTokens, gentleAiExecutable: request.gentleAiExecutable, ...(request.validationRequest === undefined ? {} : { validationRequest: request.validationRequest }) }); + return { schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId: ROLE_LINEAGE, targetIdentity: ROLE_TARGET, role: "refuter", captured: true }; + }, + }); + assert.equal(calls.length, 1, "the refuter vector must execute exactly once"); + assert.equal(calls[0]!.kind, "provider-role-refuter"); + assert.equal("validationRequest" in calls[0]!, false, "the refuter vector must not carry a validation_request"); + assert.deepEqual(calls[0]!.argumentTokens, [...REFUTER_TOKENS]); + assert.ok(calls[0]!.argumentTokens.includes("--execute=true")); + assert.ok(!calls[0]!.argumentTokens.includes("--materialize=true")); + assert.equal(calls[0]!.gentleAiExecutable, ROLE_STUB_BINARY); + assert.equal(verdict!.verdict, "pass"); + assert.equal(verdict!.role, "refuter"); + assert.equal(verdict!.captured, true); + assert.equal(verdict!.lineageId, ROLE_LINEAGE); + assert.equal(verdict!.targetIdentity, ROLE_TARGET); +}); +test("validator vector: runMatrix executes exactly once through review.capture-validation and preserves the request-hash binding", async () => { + const calls: Array<{ kind: string; argumentTokens: readonly string[]; validationRequest?: unknown }> = []; + const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-validator"), gentleAiExecutable: ROLE_STUB_BINARY }), { + armPositive: true, + roleRunner: async (request) => { + calls.push({ kind: request.kind, argumentTokens: request.argumentTokens, ...(request.validationRequest === undefined ? {} : { validationRequest: request.validationRequest }) }); + return { schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId: ROLE_LINEAGE, targetIdentity: ROLE_TARGET, role: "targeted-validator", captured: true }; + }, + }); + assert.equal(calls.length, 1, "the validator vector must execute exactly once"); + assert.equal(calls[0]!.kind, "provider-role-validator"); + assert.deepEqual(calls[0]!.argumentTokens, [...VALIDATOR_TOKENS]); + assert.ok(calls[0]!.argumentTokens.includes(`--request-hash=${ROLE_REQUEST_HASH}`)); + assert.deepEqual(calls[0]!.validationRequest, VALIDATION_REQUEST); + assert.equal(verdict!.verdict, "pass"); + assert.equal(verdict!.role, "targeted-validator"); + assert.equal(verdict!.captured, true); +}); +test("an unarmed role vector blocks loudly and never fakes green (no model launch)", async () => { + const calls: unknown[] = []; + const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { + roleRunner: async () => { calls.push("ran"); return ROLE_ARTIFACT; }, + }); + assert.equal(calls.length, 0, "an unarmed role vector must never reach the runner"); + assert.equal(verdict!.verdict, "blocked"); + assert.match(verdict!.reason, new RegExp(ARM_POSITIVE_ENV)); + assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND); +}); +test("negative control: an unsupported role surface fails closed with zero role invocation and no mutation", async () => { + const calls: unknown[] = []; + const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { + armPositive: true, + roleRunner: async (request) => { + calls.push(request); + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "execute", "the declared gentle-ai binary lacks the provider role capture surface"); + }, + }); + assert.equal(calls.length, 1, "the runner was probed exactly once to detect the missing surface"); + assert.equal(verdict!.verdict, "blocked"); + assert.equal(verdict!.mutationOutcome, "none"); + assert.match(verdict!.reason, /provider role capture surface/); + assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND); + assert.notEqual(verdict!.verdict, "pass"); + assert.notEqual(verdict!.verdict, "fail"); +}); +test("negative control: a role vector failure (not surface-unavailable) is reported as fail, never pass", async () => { + const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { + armPositive: true, + roleRunner: async () => { + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector failed"); + }, + }); + assert.equal(verdict!.verdict, "fail"); + assert.match(verdict!.reason, /role-failed/); +}); +test("stale-target fail-closed: missing or duplicate required binding tokens are rejected before runner invocation", () => { + const drop = (tokens: readonly string[], prefix: string) => tokens.filter((t) => !t.startsWith(prefix)); + for (const prefix of ["--lineage=", "--target=", "--expected-revision=", "--repository-context="]) { + rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: drop(REFUTER_TOKENS, prefix) }] }), `exactly one "${prefix}`); + } + rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS, `--lineage=${ROLE_LINEAGE}`] }] }), "exactly one \"--lineage=\""); + const badTarget = REFUTER_TOKENS.map((t) => t.startsWith("--target=") ? "--target=not-a-sha256" : t); + rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: badTarget }] }), "malformed"); +}); +test("stale-target fail-closed: a returned artifact with mismatched lineage or target fails, never passes", async () => { + const stale = `sha256:${"f".repeat(64)}`; + for (const [lineageId, targetIdentity] of [[stale, ROLE_TARGET], [ROLE_LINEAGE, stale]] as const) { + const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { + armPositive: true, + roleRunner: async () => ({ schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId, targetIdentity, role: "refuter", captured: true }), + }); + assert.equal(verdict!.verdict, "fail"); + assert.match(verdict!.reason, /stale artifact/); + } +}); + // --------------------------------------------------------------------------- // No-production-resolution — the runner uses only declared executables and // never falls through to the package-local production binary. From 4ddc3028ff78c339e127a66c5b28073c901e7490 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Tue, 18 Aug 2026 13:53:13 -0300 Subject: [PATCH 2/4] fix(review): bound provider role vector timeout --- scripts/maintainer/provider-relay-matrix.mjs | 33 ++++++++++---------- tests/maintainer/provider-relay.maintest.ts | 24 +++++++++++--- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/scripts/maintainer/provider-relay-matrix.mjs b/scripts/maintainer/provider-relay-matrix.mjs index c6d67d166..3768a8a5b 100644 --- a/scripts/maintainer/provider-relay-matrix.mjs +++ b/scripts/maintainer/provider-relay-matrix.mjs @@ -17,13 +17,9 @@ import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, classifyReviewHostRela import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts"; export const DESCRIPTOR_SCHEMA = "gentle-pi.maintainer.provider-relay-descriptor/v1"; export const CASE_KINDS = Object.freeze(["relay-unavailable", "positive-lens", "provider-role-refuter", "provider-role-validator"]); -// gentle-pi#311 P7 — the two organic provider-role vectors that remain. Unlike -// the lens capture-result slots, these are SELF-CONTAINED: the provider renders -// binding tokens plus `--agent=pi --execute=true` (no submission descriptor), -// and executing the exact rendered invocation makes Go materialize the role -// prompt, spawn its own locked-down pi subprocess, and admit the raw verdict. -// The host runs one CLI invocation verbatim; it never materializes, launches -// pi, or submits anything for these slots. +// P7 role vectors are self-contained provider --execute invocations: Go +// materializes, runs locked-down pi, and admits the verdict. The host executes +// the exact rendered command and never handles role prompt or result bytes. export const PROVIDER_ROLE_VECTOR_KINDS = Object.freeze(["provider-role-refuter", "provider-role-validator"]); export const PROVIDER_ROLE_VECTOR_VERB = Object.freeze({ "provider-role-refuter": "capture-refuter", "provider-role-validator": "capture-validation" }); export const PROVIDER_ROLE_VECTOR_ROLE = Object.freeze({ "provider-role-refuter": "refuter", "provider-role-validator": "targeted-validator" }); @@ -32,6 +28,7 @@ export const ROLE_VECTOR_FAILURE = Object.freeze({ ROLE_SURFACE_UNAVAILABLE: "role-surface-unavailable", ROLE_LAUNCH_FAILED: "role-launch-failed", ROLE_FAILED: "role-failed", + ROLE_TIMED_OUT: "role-timed-out", EMPTY_ARTIFACT: "empty-artifact", HANDSHAKE_REFUSED: "handshake-refused", }); @@ -41,7 +38,9 @@ const RCTX_RE = /^rctx1_[0-9a-f]{64}$/; // sole authority for lineage and target identity. Each must appear exactly // once; the matrix later compares the returned artifact against these values. const ROLE_BINDING_RE = Object.freeze({ "--lineage=": /^.+$/, "--expected-revision=": REQUEST_HASH_RE, "--target=": REQUEST_HASH_RE, "--repository-context=": RCTX_RE }); -const DEFAULT_ROLE_VECTOR_TIMEOUT_MS = 600_000; +// The provider owns a 600s role deadline; this outer watchdog starts earlier and +// must leave setup/cancellation margin without pretending to know prompt bytes. +export const DEFAULT_ROLE_VECTOR_TIMEOUT_MS = 900_000; export class ProviderRoleVectorError extends Error { kind; stage; @@ -60,7 +59,7 @@ export class ProviderRoleVectorError extends Error { this.exitCode = details?.exitCode ?? null; this.stderr = details?.stderr ?? ""; this.timedOut = details?.timedOut ?? false; - this.mutationOutcome = kind === ROLE_VECTOR_FAILURE.ROLE_FAILED ? "unknown" : "none"; + this.mutationOutcome = kind === ROLE_VECTOR_FAILURE.ROLE_FAILED || kind === ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT ? "unknown" : "none"; } } // The positive lens runs only when explicitly armed: the organic journey @@ -225,12 +224,9 @@ function unlaunchablePi() { chmodSync(directory, 0o700); return { directory, executable: join(directory, "pi-must-never-launch") }; } -// Default role vector runner: spawns `gentle-ai review ` once, -// in the foreground, with the relay handshake environment. Go materializes the -// role prompt, spawns its own locked-down pi subprocess, and admits the raw -// verdict; the host runs one CLI invocation verbatim and decodes the typed -// artifact. Model/provider/profile stay user-owned: no --model/--provider is -// added, and the pi subprocess environment is exactly the user's own. +// Runs the exact provider role command once with the relay handshake. Go owns +// prompt materialization, locked-down pi execution, and admission; the host only +// decodes the typed artifact and never overrides model/provider/profile. export async function runProviderRoleVector(request) { const verb = PROVIDER_ROLE_VECTOR_VERB[request.kind]; if (verb === undefined) throw new TypeError(`runProviderRoleVector received an unknown role vector kind: ${request.kind}`); @@ -262,7 +258,10 @@ export async function runProviderRoleVector(request) { throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_LAUNCH_FAILED, "launch", `gentle-ai role vector could not start: ${error instanceof Error ? error.message : String(error)}`); } const stderrText = capture.stderr.toString("utf8"); - if (capture.exitCode !== 0 || capture.timedOut) { + if (capture.timedOut) { + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT, "execute", "gentle-ai role vector exceeded its outer watchdog; re-query negotiated STATUS before any retry, and the same transcript must not relaunch blindly", { exitCode: capture.exitCode, stderr: stderrText, timedOut: true }); + } + if (capture.exitCode !== 0) { const refusal = classifyReviewHostRelayRefusal(stderrText); if (refusal === "handshake") { throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED, "execute", stderrText, { exitCode: capture.exitCode, stderr: stderrText, timedOut: capture.timedOut }); @@ -328,7 +327,7 @@ export async function runMatrix(descriptor, options = {}) { if (error.kind === ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE || error.kind === ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED) { verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: `the declared gentle-ai binary lacks the provider role capture surface: ${error.message}`, command: POSITIVE_JOURNEY_COMMAND }); } else { - verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `role vector error kind=${error.kind} stage=${error.stage} mutationOutcome=${error.mutationOutcome}: ${error.message}`, stderr: error.stderr }); + verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `role vector error kind=${error.kind} stage=${error.stage} mutationOutcome=${error.mutationOutcome}: ${error.message}`, stderr: error.stderr, timedOut: error.timedOut }); } } else { verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `unexpected non-role error: ${error instanceof Error ? error.message : String(error)}` }); diff --git a/tests/maintainer/provider-relay.maintest.ts b/tests/maintainer/provider-relay.maintest.ts index 7ae084b9f..de1f24487 100644 --- a/tests/maintainer/provider-relay.maintest.ts +++ b/tests/maintainer/provider-relay.maintest.ts @@ -14,7 +14,7 @@ import { tmpdir } from "node:os"; import { delimiter, dirname, isAbsolute, join, relative } from "node:path"; import test from "node:test"; import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts"; -import { ARM_POSITIVE_ENV, CASE_KINDS, DESCRIPTOR_SCHEMA, DescriptorValidationError, POSITIVE_JOURNEY_COMMAND, PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, PROVIDER_ROLE_VECTOR_KINDS, ProviderRoleVectorError, ROLE_VECTOR_FAILURE, loadDescriptor, resolveDeclaredExecutable, runMatrix, validateDescriptor } from "../../scripts/maintainer/provider-relay-matrix.mjs"; +import { ARM_POSITIVE_ENV, CASE_KINDS, DEFAULT_ROLE_VECTOR_TIMEOUT_MS, DESCRIPTOR_SCHEMA, DescriptorValidationError, POSITIVE_JOURNEY_COMMAND, PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, PROVIDER_ROLE_VECTOR_KINDS, ProviderRoleVectorError, ROLE_VECTOR_FAILURE, loadDescriptor, resolveDeclaredExecutable, runMatrix, runProviderRoleVector, validateDescriptor } from "../../scripts/maintainer/provider-relay-matrix.mjs"; const BASELINE_ENV = "GENTLE_PI_MAINTAINER_BASELINE_BINARY"; const CAPABLE_ENV = "GENTLE_PI_MAINTAINER_CAPABLE_BINARY"; const REQUIRE_ENV = "GENTLE_PI_REQUIRE_MAINTAINER"; @@ -192,10 +192,9 @@ test("targeted-validator descriptor preserves the provider request hash / valida }); // --------------------------------------------------------------------------- -// Provider-role vector execution (stub-injected roleRunner) — proves the -// matrix reaches the boundary with the exact kind, verb, argument tokens, and -// preserved validation_request binding, exactly once, without launching a -// model. Deterministic; always runs. +// Provider-role execution proves the stub-injected boundary receives the exact +// kind, verb, tokens, and validation_request once, without launching a model. +// Deterministic; always runs. // --------------------------------------------------------------------------- test("refuter vector: runMatrix executes exactly once through review.capture-refuter with exact provider tokens and returns the typed artifact", async () => { const calls: Array<{ kind: string; argumentTokens: readonly string[]; gentleAiExecutable: string; validationRequest?: unknown }> = []; @@ -274,6 +273,21 @@ test("negative control: a role vector failure (not surface-unavailable) is repor assert.equal(verdict!.verdict, "fail"); assert.match(verdict!.reason, /role-failed/); }); +test("role vector outer timeout is longer than the provider deadline and fails explicitly without a blind retry", async (t) => { + const preload = sandboxPath("hang-role-child.cjs"); + writeFileSync(preload, "while (true) {}\n"); + const previousNodeOptions = process.env.NODE_OPTIONS; + process.env.NODE_OPTIONS = `--require=${JSON.stringify(preload)}`; + t.after(() => { if (previousNodeOptions === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = previousNodeOptions; }); + assert.ok(DEFAULT_ROLE_VECTOR_TIMEOUT_MS > 600_000, "the outer watchdog must not race the provider-owned 600s deadline"); + await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: process.execPath, timeoutMs: 50 }), (error) => { + assert.ok(error instanceof ProviderRoleVectorError); + assert.equal(error.kind, ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT); assert.equal(error.stage, "execute"); + assert.equal(error.timedOut, true); assert.equal(error.mutationOutcome, "unknown"); + assert.match(error.message, /re-query negotiated STATUS.*must not relaunch/i); + return true; + }); +}); test("stale-target fail-closed: missing or duplicate required binding tokens are rejected before runner invocation", () => { const drop = (tokens: readonly string[], prefix: string) => tokens.filter((t) => !t.startsWith(prefix)); for (const prefix of ["--lineage=", "--target=", "--expected-revision=", "--repository-context="]) { From f75e4a3b1f1a8525d9a1012762ccff78038b21c3 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Tue, 18 Aug 2026 19:14:24 -0300 Subject: [PATCH 3/4] fix(review): harden provider role vector execution --- scripts/maintainer/provider-relay-matrix.mjs | 106 +++++++--------- tests/maintainer/provider-relay.maintest.ts | 123 ++++++++++--------- 2 files changed, 112 insertions(+), 117 deletions(-) diff --git a/scripts/maintainer/provider-relay-matrix.mjs b/scripts/maintainer/provider-relay-matrix.mjs index 3768a8a5b..9e8cb7297 100644 --- a/scripts/maintainer/provider-relay-matrix.mjs +++ b/scripts/maintainer/provider-relay-matrix.mjs @@ -9,26 +9,27 @@ // `pnpm test` never imports it; `pnpm run test:maintainer` runs the tests; // the CLI is the entry point the separate verifier uses for the real organic // positive journey after a user-visible forecast. -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, isAbsolute, join, resolve } from "node:path"; import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, classifyReviewHostRelayRefusal, resolveReviewHostRelaySubmission, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts"; import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts"; export const DESCRIPTOR_SCHEMA = "gentle-pi.maintainer.provider-relay-descriptor/v1"; -export const CASE_KINDS = Object.freeze(["relay-unavailable", "positive-lens", "provider-role-refuter", "provider-role-validator"]); -// P7 role vectors are self-contained provider --execute invocations: Go -// materializes, runs locked-down pi, and admits the verdict. The host executes -// the exact rendered command and never handles role prompt or result bytes. export const PROVIDER_ROLE_VECTOR_KINDS = Object.freeze(["provider-role-refuter", "provider-role-validator"]); +export const CASE_KINDS = Object.freeze(["relay-unavailable", "positive-lens", ...PROVIDER_ROLE_VECTOR_KINDS]); export const PROVIDER_ROLE_VECTOR_VERB = Object.freeze({ "provider-role-refuter": "capture-refuter", "provider-role-validator": "capture-validation" }); export const PROVIDER_ROLE_VECTOR_ROLE = Object.freeze({ "provider-role-refuter": "refuter", "provider-role-validator": "targeted-validator" }); export const PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA = "gentle-ai.review-provider-role-capture/v1"; +export const ROLE_STREAM_MAX_BYTES = 1024 * 1024; export const ROLE_VECTOR_FAILURE = Object.freeze({ ROLE_SURFACE_UNAVAILABLE: "role-surface-unavailable", ROLE_LAUNCH_FAILED: "role-launch-failed", ROLE_FAILED: "role-failed", ROLE_TIMED_OUT: "role-timed-out", + ROLE_OUTPUT_OVERFLOW: "role-output-overflow", + ROLE_TERMINATION_FAILED: "role-termination-failed", + ROLE_ABORTED: "role-aborted", EMPTY_ARTIFACT: "empty-artifact", HANDSHAKE_REFUSED: "handshake-refused", }); @@ -38,8 +39,7 @@ const RCTX_RE = /^rctx1_[0-9a-f]{64}$/; // sole authority for lineage and target identity. Each must appear exactly // once; the matrix later compares the returned artifact against these values. const ROLE_BINDING_RE = Object.freeze({ "--lineage=": /^.+$/, "--expected-revision=": REQUEST_HASH_RE, "--target=": REQUEST_HASH_RE, "--repository-context=": RCTX_RE }); -// The provider owns a 600s role deadline; this outer watchdog starts earlier and -// must leave setup/cancellation margin without pretending to know prompt bytes. +// The 900s watchdog FIRES after the provider-owned 600s deadline and leaves setup/cancellation margin. export const DEFAULT_ROLE_VECTOR_TIMEOUT_MS = 900_000; export class ProviderRoleVectorError extends Error { kind; @@ -59,7 +59,7 @@ export class ProviderRoleVectorError extends Error { this.exitCode = details?.exitCode ?? null; this.stderr = details?.stderr ?? ""; this.timedOut = details?.timedOut ?? false; - this.mutationOutcome = kind === ROLE_VECTOR_FAILURE.ROLE_FAILED || kind === ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT ? "unknown" : "none"; + this.mutationOutcome = details?.mutationOutcome ?? (kind === ROLE_VECTOR_FAILURE.ROLE_FAILED || kind === ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT || kind === ROLE_VECTOR_FAILURE.ROLE_OUTPUT_OVERFLOW ? "unknown" : "none"); } } // The positive lens runs only when explicitly armed: the organic journey @@ -104,7 +104,7 @@ export function validateDescriptor(value) { if (seen.has(entry.name)) fail(`descriptor.cases[${index}].name "${entry.name}" is duplicated`); seen.add(entry.name); if (!CASE_KINDS.includes(entry.kind)) fail(`descriptor.cases[${index}].kind must be one of ${JSON.stringify([...CASE_KINDS])}`); - if (entry.kind === "provider-role-refuter" || entry.kind === "provider-role-validator") { + if (PROVIDER_ROLE_VECTOR_KINDS.includes(entry.kind)) { return validateRoleCaseEntry(entry, index); } exactKeys(entry, new Set(["name", "kind", "captureArgumentTokens", "submission"]), `descriptor.cases[${index}]`); @@ -180,9 +180,8 @@ function validateRoleCaseEntry(entry, index) { if (vr.schema !== "gentle-ai.review-targeted-validation-request/v1") fail(`${label}.validationRequest.schema must be exactly "gentle-ai.review-targeted-validation-request/v1"`); if (!isStr(vr.requestHash) || !REQUEST_HASH_RE.test(vr.requestHash)) fail(`${label}.validationRequest.requestHash must be a sha256 digest`); const expectedToken = `--request-hash=${vr.requestHash}`; - if (!entry.argumentTokens.includes(expectedToken)) { - fail(`${label}.argumentTokens must include the provider-issued "${expectedToken}" token that binds the embedded validation_request`); - } + const requestHashTokens = entry.argumentTokens.filter((token) => token.startsWith("--request-hash=")); + if (requestHashTokens.length !== 1 || requestHashTokens[0] !== expectedToken) fail(`${label}.argumentTokens must include exactly one provider-issued "${expectedToken}" token that binds the embedded validation_request`); result.validationRequest = { schema: vr.schema, requestHash: vr.requestHash }; } return Object.freeze(result); @@ -224,66 +223,53 @@ function unlaunchablePi() { chmodSync(directory, 0o700); return { directory, executable: join(directory, "pi-must-never-launch") }; } -// Runs the exact provider role command once with the relay handshake. Go owns -// prompt materialization, locked-down pi execution, and admission; the host only -// decodes the typed artifact and never overrides model/provider/profile. +function terminateRoleProcessTree(child) { + if (child.pid === undefined) return false; + if (process.platform === "win32") { + try { const result = spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", shell: false, windowsHide: true, timeout: 5_000 }); if (result.error === undefined && result.status === 0) return true; } catch {} + child.kill("SIGKILL"); return false; + } + try { process.kill(-child.pid, "SIGKILL"); return true; } catch (error) { if (error?.code === "ESRCH") return true; child.kill("SIGKILL"); return false; } +} export async function runProviderRoleVector(request) { const verb = PROVIDER_ROLE_VECTOR_VERB[request.kind]; if (verb === undefined) throw new TypeError(`runProviderRoleVector received an unknown role vector kind: ${request.kind}`); - const argv = ["review", verb, ...request.argumentTokens]; - const env = { ...process.env, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT }; - let capture; + if (request.signal?.aborted) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_ABORTED, "launch", "gentle-ai role vector was aborted before launch", { mutationOutcome: "none" }); + const argv = ["review", verb, ...request.argumentTokens], env = { ...process.env, ...request.env, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT }; + let capture, launched = false; try { capture = await new Promise((resolve, reject) => { - const child = spawn(request.gentleAiExecutable, argv, { - cwd: process.cwd(), - env, - stdio: ["ignore", "pipe", "pipe"], - shell: false, - windowsHide: true, - ...(request.signal === undefined ? {} : { signal: request.signal }), - }); - const stdout = []; - const stderr = []; - let timedOut = false; - let settled = false; - const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, request.timeoutMs ?? DEFAULT_ROLE_VECTOR_TIMEOUT_MS); - timer.unref(); - child.stdout.on("data", (chunk) => stdout.push(chunk)); - child.stderr.on("data", (chunk) => stderr.push(chunk)); - child.on("error", (error) => { if (settled) return; settled = true; clearTimeout(timer); reject(error); }); - child.on("close", (code) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut }); }); + const child = spawn(request.gentleAiExecutable, argv, { cwd: process.cwd(), env, stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true, ...(process.platform === "win32" ? {} : { detached: true }) }); + launched = child.pid !== undefined; + const stdout = [], stderr = []; let stdoutBytes = 0, stderrBytes = 0, terminationCause = null, treeTerminationFailed = false, settled = false; + const terminate = (cause) => { if (terminationCause === null) { terminationCause = cause; treeTerminationFailed = !terminateRoleProcessTree(child); } }, abort = () => terminate("abort"), cleanup = () => { clearTimeout(timer); request.signal?.removeEventListener("abort", abort); }; + const timer = setTimeout(() => terminate("timeout"), request.timeoutMs ?? DEFAULT_ROLE_VECTOR_TIMEOUT_MS); timer.unref(); + if (request.signal?.aborted) abort(); else request.signal?.addEventListener("abort", abort, { once: true }); + const collect = (stream, chunks) => (chunk) => { if (terminationCause !== null) return; const bytes = chunk.length; if ((stream === "stdout" ? stdoutBytes : stderrBytes) + bytes > ROLE_STREAM_MAX_BYTES) { terminate(stream); return; } if (stream === "stdout") stdoutBytes += bytes; else stderrBytes += bytes; chunks.push(chunk); }; + child.stdout.on("data", collect("stdout", stdout)); child.stderr.on("data", collect("stderr", stderr)); + child.on("error", (error) => { if (settled) return; settled = true; cleanup(); if (terminationCause === null) reject(error); else resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: null, terminationCause, treeTerminationFailed }); }); + child.on("close", (code) => { if (settled) return; settled = true; cleanup(); resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, terminationCause, treeTerminationFailed }); }); }); } catch (error) { + if (request.signal?.aborted && launched) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_ABORTED, "execute", "gentle-ai role vector was aborted after launch", { mutationOutcome: "unknown" }); throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_LAUNCH_FAILED, "launch", `gentle-ai role vector could not start: ${error instanceof Error ? error.message : String(error)}`); } const stderrText = capture.stderr.toString("utf8"); - if (capture.timedOut) { - throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT, "execute", "gentle-ai role vector exceeded its outer watchdog; re-query negotiated STATUS before any retry, and the same transcript must not relaunch blindly", { exitCode: capture.exitCode, stderr: stderrText, timedOut: true }); - } + if (capture.treeTerminationFailed) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_TERMINATION_FAILED, "execute", "gentle-ai role vector tree termination could not be confirmed; re-query negotiated STATUS before any retry", { exitCode: capture.exitCode, stderr: stderrText, mutationOutcome: "unknown" }); + if (capture.terminationCause === "abort") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_ABORTED, "execute", "gentle-ai role vector was aborted after launch", { exitCode: capture.exitCode, stderr: stderrText, mutationOutcome: "unknown" }); + if (capture.terminationCause === "stdout" || capture.terminationCause === "stderr") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_OUTPUT_OVERFLOW, "execute", `gentle-ai role vector ${capture.terminationCause} exceeded the ${ROLE_STREAM_MAX_BYTES}-byte limit; re-query negotiated STATUS before any retry`, { exitCode: capture.exitCode, stderr: stderrText }); + if (capture.terminationCause === "timeout") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT, "execute", "gentle-ai role vector exceeded its outer watchdog; re-query negotiated STATUS before any retry, and the same transcript must not relaunch blindly", { exitCode: capture.exitCode, stderr: stderrText, timedOut: true }); if (capture.exitCode !== 0) { const refusal = classifyReviewHostRelayRefusal(stderrText); - if (refusal === "handshake") { - throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED, "execute", stderrText, { exitCode: capture.exitCode, stderr: stderrText, timedOut: capture.timedOut }); - } - if (refusal === "unknown-flag") { - throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "execute", "the declared gentle-ai binary lacks the provider role capture surface", { exitCode: capture.exitCode, stderr: stderrText, timedOut: capture.timedOut }); - } - throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector failed", { exitCode: capture.exitCode, stderr: stderrText, timedOut: capture.timedOut }); - } - if (capture.stdout.length === 0) { - throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT, "execute", "gentle-ai role vector produced no artifact bytes", { exitCode: 0, stderr: stderrText }); + if (refusal === "handshake") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED, "execute", stderrText, { exitCode: capture.exitCode, stderr: stderrText }); + if (refusal === "unknown-flag") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "execute", "the declared gentle-ai binary lacks the provider role capture surface", { exitCode: capture.exitCode, stderr: stderrText }); + throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector failed", { exitCode: capture.exitCode, stderr: stderrText }); } + if (capture.stdout.length === 0) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT, "execute", "gentle-ai role vector produced no artifact bytes", { exitCode: 0, stderr: stderrText }); let artifact; - try { - artifact = JSON.parse(capture.stdout.toString("utf8")); - } catch (error) { - throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", `gentle-ai role vector returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { exitCode: 0, stderr: stderrText }); - } + try { artifact = JSON.parse(capture.stdout.toString("utf8")); } catch (error) { throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", `gentle-ai role vector returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { exitCode: 0, stderr: stderrText }); } const expectedRole = PROVIDER_ROLE_VECTOR_ROLE[request.kind]; - if (!isObj(artifact) || artifact.schema !== PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA || artifact.role !== expectedRole || artifact.captured !== true) { - throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector returned an artifact that does not match the expected typed shape", { exitCode: 0, stderr: stderrText }); - } + if (!isObj(artifact) || artifact.schema !== PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA || artifact.role !== expectedRole || artifact.captured !== true || !isStr(artifact.lineage_id) || !REQUEST_HASH_RE.test(artifact.target_identity)) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector returned an artifact that does not match the expected typed shape", { exitCode: 0, stderr: stderrText }); return { schema: artifact.schema, lineageId: artifact.lineage_id, targetIdentity: artifact.target_identity, role: artifact.role, captured: true }; } // Runs the matrix against the REAL relay. A case that cannot run honestly is @@ -311,7 +297,7 @@ export async function runMatrix(descriptor, options = {}) { // admits the raw verdict. Like the positive lens, they need a real // capable binary and an explicit arm; the runner never synthesizes a // provider result. - if (caseEntry.kind === "provider-role-refuter" || caseEntry.kind === "provider-role-validator") { + if (PROVIDER_ROLE_VECTOR_KINDS.includes(caseEntry.kind)) { if (!positiveArmed) { verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `${caseEntry.kind} case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary, a real pi, and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic role vector. The runner never synthesizes a provider verdict.`, command: POSITIVE_JOURNEY_COMMAND }); continue; @@ -324,7 +310,9 @@ export async function runMatrix(descriptor, options = {}) { verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", role: artifact.role, lineageId: artifact.lineageId, targetIdentity: artifact.targetIdentity, captured: artifact.captured }); } catch (error) { if (error instanceof ProviderRoleVectorError) { - if (error.kind === ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE || error.kind === ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED) { + if (error.kind === ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED) { + verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: `the declared gentle-ai binary refused relay-contract negotiation: ${error.message}`, command: POSITIVE_JOURNEY_COMMAND }); + } else if (error.kind === ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE) { verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: `the declared gentle-ai binary lacks the provider role capture surface: ${error.message}`, command: POSITIVE_JOURNEY_COMMAND }); } else { verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `role vector error kind=${error.kind} stage=${error.stage} mutationOutcome=${error.mutationOutcome}: ${error.message}`, stderr: error.stderr, timedOut: error.timedOut }); diff --git a/tests/maintainer/provider-relay.maintest.ts b/tests/maintainer/provider-relay.maintest.ts index de1f24487..c1e092c21 100644 --- a/tests/maintainer/provider-relay.maintest.ts +++ b/tests/maintainer/provider-relay.maintest.ts @@ -9,12 +9,12 @@ // them via env. The baseline is described generically; external evidence // establishes whether it is the immutable RC8 runtime. import assert from "node:assert/strict"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, isAbsolute, join, relative } from "node:path"; import test from "node:test"; import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts"; -import { ARM_POSITIVE_ENV, CASE_KINDS, DEFAULT_ROLE_VECTOR_TIMEOUT_MS, DESCRIPTOR_SCHEMA, DescriptorValidationError, POSITIVE_JOURNEY_COMMAND, PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, PROVIDER_ROLE_VECTOR_KINDS, ProviderRoleVectorError, ROLE_VECTOR_FAILURE, loadDescriptor, resolveDeclaredExecutable, runMatrix, runProviderRoleVector, validateDescriptor } from "../../scripts/maintainer/provider-relay-matrix.mjs"; +import { ARM_POSITIVE_ENV, CASE_KINDS, DEFAULT_ROLE_VECTOR_TIMEOUT_MS, DESCRIPTOR_SCHEMA, DescriptorValidationError, POSITIVE_JOURNEY_COMMAND, PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, PROVIDER_ROLE_VECTOR_KINDS, PROVIDER_ROLE_VECTOR_ROLE, PROVIDER_ROLE_VECTOR_VERB, ProviderRoleVectorError, ROLE_STREAM_MAX_BYTES, ROLE_VECTOR_FAILURE, loadDescriptor, resolveDeclaredExecutable, runMatrix, runProviderRoleVector, validateDescriptor } from "../../scripts/maintainer/provider-relay-matrix.mjs"; const BASELINE_ENV = "GENTLE_PI_MAINTAINER_BASELINE_BINARY"; const CAPABLE_ENV = "GENTLE_PI_MAINTAINER_CAPABLE_BINARY"; const REQUIRE_ENV = "GENTLE_PI_REQUIRE_MAINTAINER"; @@ -47,34 +47,13 @@ const SUBMISSION = Object.freeze({ values: Object.freeze([{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]), }); -// Provider-role vector fixtures (gentle-pi#311 P7). Synthetic stable values — -// never copied from a live probe. The refuter vector is binding tokens plus -// --agent=pi --execute=true (no submission, no validation_request). The -// validator vector adds --request-hash= and carries the minimal -// validation_request binding (schema + requestHash) extracted from the -// provider-embedded descriptor, so the matrix can assert the token matches. const ROLE_LINEAGE = "review-fixture-role-vector"; const ROLE_REVISION = `sha256:${"a".repeat(64)}`; const ROLE_TARGET = `sha256:${"b".repeat(64)}`; const ROLE_CONTEXT = `rctx1_${"c".repeat(64)}`; const ROLE_REQUEST_HASH = `sha256:${"9".repeat(64)}`; -const REFUTER_TOKENS = Object.freeze([ - `--lineage=${ROLE_LINEAGE}`, - `--expected-revision=${ROLE_REVISION}`, - `--target=${ROLE_TARGET}`, - `--repository-context=${ROLE_CONTEXT}`, - "--agent=pi", - "--execute=true", -]); -const VALIDATOR_TOKENS = Object.freeze([ - `--lineage=${ROLE_LINEAGE}`, - `--expected-revision=${ROLE_REVISION}`, - `--target=${ROLE_TARGET}`, - `--repository-context=${ROLE_CONTEXT}`, - `--request-hash=${ROLE_REQUEST_HASH}`, - "--agent=pi", - "--execute=true", -]); +const REFUTER_TOKENS = Object.freeze([`--lineage=${ROLE_LINEAGE}`, `--expected-revision=${ROLE_REVISION}`, `--target=${ROLE_TARGET}`, `--repository-context=${ROLE_CONTEXT}`, "--agent=pi", "--execute=true"]); +const VALIDATOR_TOKENS = Object.freeze([`--lineage=${ROLE_LINEAGE}`, `--expected-revision=${ROLE_REVISION}`, `--target=${ROLE_TARGET}`, `--repository-context=${ROLE_CONTEXT}`, `--request-hash=${ROLE_REQUEST_HASH}`, "--agent=pi", "--execute=true"]); const VALIDATION_REQUEST = Object.freeze({ schema: "gentle-ai.review-targeted-validation-request/v1", requestHash: ROLE_REQUEST_HASH }); const ROLE_ARTIFACT = Object.freeze({ schema: "gentle-ai.review-provider-role-capture/v1", @@ -145,24 +124,18 @@ test("loadDescriptor reads and validates a descriptor file", (t) => { assert.throws(() => loadDescriptor(join(dirname(path), "bad.json")), /not valid JSON/); }); -// --------------------------------------------------------------------------- -// Provider-role vector descriptor validation (gentle-pi#311 P7) — the two -// organic self-contained vectors. The descriptor carries provider-returned -// argument tokens verbatim; the matrix never reconstructs the role payload. -// Pure/deterministic; always runs. -// --------------------------------------------------------------------------- function roleDescriptor(kind: "provider-role-refuter" | "provider-role-validator", overrides: Record = {}) { const tokens = kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS; const entry: Record = { name: `${kind}-case`, kind, argumentTokens: [...tokens] }; if (kind === "provider-role-validator") entry.validationRequest = structuredClone(VALIDATION_REQUEST); return { schema: DESCRIPTOR_SCHEMA, gentleAiExecutable: PLACEHOLDER_BINARY, piExecutable: "pi", cases: [entry], ...overrides }; } -// A declared gentle-ai executable that EXISTS so resolveDeclaredExecutable -// succeeds and the role branch is reached. The stub roleRunner replaces the -// real spawn, so this file is never executed and no model launches. const ROLE_STUB_BINARY = sandboxPath("gentle-ai-role-stub"); writeFileSync(ROLE_STUB_BINARY, "stub"); test("role vector descriptors validate and return a normalized copy with exact provider tokens", () => { + assert.deepEqual([...PROVIDER_ROLE_VECTOR_KINDS], ["provider-role-refuter", "provider-role-validator"]); + assert.deepEqual(PROVIDER_ROLE_VECTOR_VERB, { "provider-role-refuter": "capture-refuter", "provider-role-validator": "capture-validation" }); + assert.deepEqual(PROVIDER_ROLE_VECTOR_ROLE, { "provider-role-refuter": "refuter", "provider-role-validator": "targeted-validator" }); const refuter = validateDescriptor(roleDescriptor("provider-role-refuter")).cases[0]!; assert.equal(refuter.kind, "provider-role-refuter"); assert.deepEqual(refuter.argumentTokens, [...REFUTER_TOKENS]); @@ -178,24 +151,16 @@ test("role vector descriptors reject missing --agent=pi / --execute=true and rej rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS].filter((t) => t !== "--execute=true") }] }), "--execute=true"); rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS, "--materialize=true"] }] }), "--materialize=true"); }); -test("targeted-validator descriptor preserves the provider request hash / validation request binding", () => { - // The --request-hash token must match validationRequest.requestHash exactly. +test("targeted-validator descriptor requires exactly one request hash matching validationRequest", () => { const mismatched = { ...structuredClone(VALIDATION_REQUEST), requestHash: `sha256:${"0".repeat(64)}` }; - rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS], validationRequest: mismatched }] }), "must include the provider-issued"); - // A validator without validationRequest is a contract violation. + rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS], validationRequest: mismatched }] }), "must include exactly one"); + rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS, `--request-hash=${ROLE_REQUEST_HASH}`], validationRequest: structuredClone(VALIDATION_REQUEST) }] }), "must include exactly one"); rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS] }] }), "validationRequest"); - // A refuter must not carry validationRequest (only the validator does). rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS], validationRequest: structuredClone(VALIDATION_REQUEST) }] }), "validationRequest"); - // A malformed requestHash is rejected. const badHash = { ...structuredClone(VALIDATION_REQUEST), requestHash: "not-a-sha256" }; rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS], validationRequest: badHash }] }), "sha256"); }); -// --------------------------------------------------------------------------- -// Provider-role execution proves the stub-injected boundary receives the exact -// kind, verb, tokens, and validation_request once, without launching a model. -// Deterministic; always runs. -// --------------------------------------------------------------------------- test("refuter vector: runMatrix executes exactly once through review.capture-refuter with exact provider tokens and returns the typed artifact", async () => { const calls: Array<{ kind: string; argumentTokens: readonly string[]; gentleAiExecutable: string; validationRequest?: unknown }> = []; const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { @@ -246,6 +211,12 @@ test("an unarmed role vector blocks loudly and never fakes green (no model launc assert.match(verdict!.reason, new RegExp(ARM_POSITIVE_ENV)); assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND); }); +test("handshake refusal reports relay-contract negotiation, distinct from a missing role surface", async () => { + for (const [kind, message] of [[ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED, "relay handshake refused"], [ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "missing role surface"]] as const) { + const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { armPositive: true, roleRunner: async () => { throw new ProviderRoleVectorError(kind, "execute", message); } }); + assert.equal(verdict!.verdict, "blocked"); assert.match(verdict!.reason, kind === ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED ? /relay-contract negotiation/ : /provider role capture surface/); + } +}); test("negative control: an unsupported role surface fails closed with zero role invocation and no mutation", async () => { const calls: unknown[] = []; const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { @@ -273,19 +244,55 @@ test("negative control: a role vector failure (not surface-unavailable) is repor assert.equal(verdict!.verdict, "fail"); assert.match(verdict!.reason, /role-failed/); }); -test("role vector outer timeout is longer than the provider deadline and fails explicitly without a blind retry", async (t) => { - const preload = sandboxPath("hang-role-child.cjs"); - writeFileSync(preload, "while (true) {}\n"); - const previousNodeOptions = process.env.NODE_OPTIONS; - process.env.NODE_OPTIONS = `--require=${JSON.stringify(preload)}`; - t.after(() => { if (previousNodeOptions === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = previousNodeOptions; }); - assert.ok(DEFAULT_ROLE_VECTOR_TIMEOUT_MS > 600_000, "the outer watchdog must not race the provider-owned 600s deadline"); - await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: process.execPath, timeoutMs: 50 }), (error) => { - assert.ok(error instanceof ProviderRoleVectorError); - assert.equal(error.kind, ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT); assert.equal(error.stage, "execute"); - assert.equal(error.timedOut, true); assert.equal(error.mutationOutcome, "unknown"); - assert.match(error.message, /re-query negotiated STATUS.*must not relaunch/i); - return true; +test("runProviderRoleVector treats a prelaunch abort as not-started with no mutation", async () => { + const controller = new AbortController(); controller.abort(); + await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: process.execPath, signal: controller.signal }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_ABORTED && error.stage === "launch" && error.mutationOutcome === "none"); +}); +function roleChild(t: test.TestContext, name: string, source: string) { + const path = sandboxPath(`${name} preload.cjs`), savedNodeOptions = process.env.NODE_OPTIONS; + writeFileSync(path, `const roleArgv = ["review", ...process.argv.slice(2)], Module = require("node:module"), resolveFilename = Module._resolveFilename; Module._resolveFilename = function (request, ...args) { return /[\\\\/]review$/.test(request) ? ${JSON.stringify(path)} : resolveFilename.call(this, request, ...args); }; ${source}`); + t.after(() => { if (savedNodeOptions === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = savedNodeOptions; rmSync(path, { force: true }); }); + return { executable: process.execPath, env: { NODE_OPTIONS: `--require=${JSON.stringify(path)}` } }; +} +test("runProviderRoleVector executes both real stub-child verbs and decodes snake_case artifacts", async (t) => { + for (const kind of PROVIDER_ROLE_VECTOR_KINDS) { + const log = sandboxPath(`${kind}.json`), child = roleChild(t, `${kind}.cjs`, `const fs = require("node:fs"), refuter = roleArgv[1] === "capture-refuter"; fs.writeFileSync(${JSON.stringify(sandboxPath("ROLE_LOG_PLACEHOLDER"))}.replace("ROLE_LOG_PLACEHOLDER", ${JSON.stringify(`${kind}.json`)}), JSON.stringify(roleArgv)); process.stdout.write(JSON.stringify({ schema: ${JSON.stringify(PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA)}, lineage_id: ${JSON.stringify(ROLE_LINEAGE)}, target_identity: ${JSON.stringify(ROLE_TARGET)}, role: refuter ? "refuter" : "targeted-validator", captured: true })); process.exit(0);`); + const artifact = await runProviderRoleVector({ kind, argumentTokens: kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS, gentleAiExecutable: child.executable, env: child.env }); + assert.deepEqual(JSON.parse(readFileSync(log, "utf8")), ["review", PROVIDER_ROLE_VECTOR_VERB[kind], ...(kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS)]); + assert.deepEqual(artifact, { schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId: ROLE_LINEAGE, targetIdentity: ROLE_TARGET, role: PROVIDER_ROLE_VECTOR_ROLE[kind], captured: true }); + } +}); +test("runProviderRoleVector rejects malformed artifact binding shape before stale binding comparison", async (t) => { + for (const malformed of [{ lineage_id: "", target_identity: ROLE_TARGET }, { lineage_id: ROLE_LINEAGE, target_identity: "not-a-sha256" }]) { + const child = roleChild(t, `malformed-${malformed.lineage_id || "lineage"}.cjs`, `process.stdout.write(JSON.stringify({ schema: ${JSON.stringify(PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA)}, role: "refuter", captured: true, ...${JSON.stringify(malformed)} })); process.exit(0);`); + await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_FAILED && /typed shape/.test(error.message)); + } +}); +test("runProviderRoleVector bounds each output stream before JSON or exit handling", async (t) => { + for (const stream of ["stdout", "stderr"] as const) { + const child = roleChild(t, `overflow-${stream}.cjs`, `process.${stream}.write("x".repeat(${ROLE_STREAM_MAX_BYTES + 1})); setInterval(() => {}, 1_000);`); + await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env, timeoutMs: 1_000 }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_OUTPUT_OVERFLOW && error.mutationOutcome === "unknown"); + } +}); +async function descendantPid(path: string) { + const deadline = Date.now() + 500; + while (Date.now() < deadline) { if (existsSync(path)) { const pid = Number(readFileSync(path, "utf8")); if (pid > 0) return pid; } await new Promise((resolve) => setTimeout(resolve, 10)); } + assert.fail("fixture did not report a positive descendant PID"); +} +async function assertReaped(pid: number) { + assert.ok(pid > 0, "only a positive descendant PID may be probed"); + for (let attempt = 0; attempt < 50; attempt += 1) try { process.kill(pid, 0); await new Promise((resolve) => setTimeout(resolve, 10)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return; throw error; } + assert.fail("role termination left a deterministic child-process descendant running"); +} +test("role watchdog and abort reap descendants on every platform", async (t) => { + for (const mode of ["watchdog", "abort"] as const) await t.test(mode, async (t) => { + const grandchild = sandboxPath(`role-grandchild-${mode}.pid`), child = roleChild(t, `hang-role-child-${mode}.cjs`, `const fs = require("node:fs"), { spawn } = require("node:child_process"), grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore", env: { ...process.env, NODE_OPTIONS: "" } }); fs.writeFileSync(${JSON.stringify(sandboxPath("ROLE_PID_PLACEHOLDER"))}.replace("ROLE_PID_PLACEHOLDER", ${JSON.stringify(`role-grandchild-${mode}.pid`)}), String(grandchild.pid)); setInterval(() => {}, 1000);`), controller = new AbortController(); + const run = runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env, timeoutMs: 1_000, signal: controller.signal }), pid = await descendantPid(grandchild); + t.after(() => { if (pid > 0) try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } }); + assert.ok(DEFAULT_ROLE_VECTOR_TIMEOUT_MS > 600_000, "the outer 900s watchdog FIRES after the provider-owned 600s deadline with setup/cancellation margin"); + if (mode === "abort") controller.abort(); + await assert.rejects(run, (error) => error instanceof ProviderRoleVectorError && error.kind === (mode === "abort" ? ROLE_VECTOR_FAILURE.ROLE_ABORTED : ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT) && error.mutationOutcome === "unknown"); + await assertReaped(pid); }); }); test("stale-target fail-closed: missing or duplicate required binding tokens are rejected before runner invocation", () => { From a1cfe016e56d3c340c2d57f7cad8ab6df99a276b Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Tue, 18 Aug 2026 19:34:23 -0300 Subject: [PATCH 4/4] fix(review): fail closed on ambiguous role outcomes --- scripts/maintainer/provider-relay-matrix.mjs | 56 +++++++++----------- tests/maintainer/provider-relay.maintest.ts | 45 +++++++++------- 2 files changed, 52 insertions(+), 49 deletions(-) diff --git a/scripts/maintainer/provider-relay-matrix.mjs b/scripts/maintainer/provider-relay-matrix.mjs index 9e8cb7297..4d34abc01 100644 --- a/scripts/maintainer/provider-relay-matrix.mjs +++ b/scripts/maintainer/provider-relay-matrix.mjs @@ -35,9 +35,7 @@ export const ROLE_VECTOR_FAILURE = Object.freeze({ }); const REQUEST_HASH_RE = /^sha256:[0-9a-f]{64}$/; const RCTX_RE = /^rctx1_[0-9a-f]{64}$/; -// Stale-target fail-closed: the provider-returned binding tokens are the -// sole authority for lineage and target identity. Each must appear exactly -// once; the matrix later compares the returned artifact against these values. +// Provider-returned lineage and target bindings are the sole authority and must be unique. const ROLE_BINDING_RE = Object.freeze({ "--lineage=": /^.+$/, "--expected-revision=": REQUEST_HASH_RE, "--target=": REQUEST_HASH_RE, "--repository-context=": RCTX_RE }); // The 900s watchdog FIRES after the provider-owned 600s deadline and leaves setup/cancellation margin. export const DEFAULT_ROLE_VECTOR_TIMEOUT_MS = 900_000; @@ -48,9 +46,7 @@ export class ProviderRoleVectorError extends Error { stderr; timedOut; mutationOutcome; - // "none" until the role invocation launches Go's role work; a launched - // role whose outcome could not be read is "unknown" and the caller - // re-queries negotiated STATUS, never through a blind retry. + // Unreadable outcomes after launch are unknown and require STATUS re-query. constructor(kind, stage, message, details) { super(message); this.name = "ProviderRoleVectorError"; @@ -143,23 +139,17 @@ export function validateDescriptor(value) { }), }; } -// Validates one provider-role vector case. The descriptor carries the -// provider-returned argument tokens verbatim plus, for the targeted validator, -// a MINIMAL binding (schema + requestHash) extracted from the embedded -// validation_request — never the full provider descriptor. The matrix asserts -// the `--request-hash=` token in the argument vector matches that -// requestHash, preserving the provider request hash / validation request -// binding without reconstructing the role payload. +// Validates provider-issued role tokens and the validator's minimal request-hash binding. function validateRoleCaseEntry(entry, index) { const label = `descriptor.cases[${index}]`; const allowedKeys = new Set(["name", "kind", "argumentTokens", ...(entry.kind === "provider-role-validator" ? ["validationRequest"] : [])]); exactKeys(entry, allowedKeys, label); if (!isStrArr(entry.argumentTokens)) fail(`${label}.argumentTokens must be a non-empty array of non-empty strings`); - // The provider-issued execute token pair is the self-contained vector - // signature; --materialize=true would make this a lens slot, a contract - // mismatch on a role vector. + // Each self-contained execute control must appear exactly once at its exact value. for (const required of ["--agent=pi", "--execute=true"]) { - if (!entry.argumentTokens.includes(required)) fail(`${label}.argumentTokens must include the provider-issued "${required}" token`); + const prefix = `${required.slice(0, required.indexOf("=") + 1)}`; + const matches = entry.argumentTokens.filter((token) => token.startsWith(prefix)); + if (matches.length !== 1 || matches[0] !== required) fail(`${label}.argumentTokens must include exactly one provider-issued "${required}" token`); } if (entry.argumentTokens.includes("--materialize=true")) { fail(`${label}.argumentTokens must not include --materialize=true; a role vector is self-contained, never a lens materialize slot`); @@ -242,13 +232,23 @@ export async function runProviderRoleVector(request) { const child = spawn(request.gentleAiExecutable, argv, { cwd: process.cwd(), env, stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true, ...(process.platform === "win32" ? {} : { detached: true }) }); launched = child.pid !== undefined; const stdout = [], stderr = []; let stdoutBytes = 0, stderrBytes = 0, terminationCause = null, treeTerminationFailed = false, settled = false; - const terminate = (cause) => { if (terminationCause === null) { terminationCause = cause; treeTerminationFailed = !terminateRoleProcessTree(child); } }, abort = () => terminate("abort"), cleanup = () => { clearTimeout(timer); request.signal?.removeEventListener("abort", abort); }; + const captured = (exitCode) => ({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode, terminationCause, treeTerminationFailed }); + const collect = (stream, chunks) => (chunk) => { if (terminationCause !== null) return; const bytes = chunk.length; if ((stream === "stdout" ? stdoutBytes : stderrBytes) + bytes > ROLE_STREAM_MAX_BYTES) { terminate(stream); return; } if (stream === "stdout") stdoutBytes += bytes; else stderrBytes += bytes; chunks.push(chunk); }; + const onStdout = collect("stdout", stdout), onStderr = collect("stderr", stderr); + const abort = () => terminate("abort"); + const cleanup = () => { clearTimeout(timer); request.signal?.removeEventListener("abort", abort); child.stdout.off("data", onStdout); child.stderr.off("data", onStderr); child.off("error", onError); child.off("close", onClose); }; + const settle = (result) => { if (settled) return; settled = true; cleanup(); resolve(result); }; + const onError = (error) => { if (terminationCause === null) { if (settled) return; settled = true; cleanup(); reject(error); } else settle(captured(null)); }; + const onClose = (code) => settle(captured(code)); + const terminate = (cause) => { + if (terminationCause !== null) return; + terminationCause = cause; + treeTerminationFailed = !(request.terminateProcessTree ?? terminateRoleProcessTree)(child); + if (treeTerminationFailed) { child.stdout.destroy(); child.stderr.destroy(); settle(captured(null)); } + }; + child.stdout.on("data", onStdout); child.stderr.on("data", onStderr); child.on("error", onError); child.on("close", onClose); const timer = setTimeout(() => terminate("timeout"), request.timeoutMs ?? DEFAULT_ROLE_VECTOR_TIMEOUT_MS); timer.unref(); if (request.signal?.aborted) abort(); else request.signal?.addEventListener("abort", abort, { once: true }); - const collect = (stream, chunks) => (chunk) => { if (terminationCause !== null) return; const bytes = chunk.length; if ((stream === "stdout" ? stdoutBytes : stderrBytes) + bytes > ROLE_STREAM_MAX_BYTES) { terminate(stream); return; } if (stream === "stdout") stdoutBytes += bytes; else stderrBytes += bytes; chunks.push(chunk); }; - child.stdout.on("data", collect("stdout", stdout)); child.stderr.on("data", collect("stderr", stderr)); - child.on("error", (error) => { if (settled) return; settled = true; cleanup(); if (terminationCause === null) reject(error); else resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: null, terminationCause, treeTerminationFailed }); }); - child.on("close", (code) => { if (settled) return; settled = true; cleanup(); resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, terminationCause, treeTerminationFailed }); }); }); } catch (error) { if (request.signal?.aborted && launched) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_ABORTED, "execute", "gentle-ai role vector was aborted after launch", { mutationOutcome: "unknown" }); @@ -265,7 +265,7 @@ export async function runProviderRoleVector(request) { if (refusal === "unknown-flag") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "execute", "the declared gentle-ai binary lacks the provider role capture surface", { exitCode: capture.exitCode, stderr: stderrText }); throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector failed", { exitCode: capture.exitCode, stderr: stderrText }); } - if (capture.stdout.length === 0) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT, "execute", "gentle-ai role vector produced no artifact bytes", { exitCode: 0, stderr: stderrText }); + if (capture.stdout.length === 0) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT, "execute", "gentle-ai role vector produced no artifact bytes; re-query negotiated STATUS before any retry", { exitCode: 0, stderr: stderrText, mutationOutcome: "unknown" }); let artifact; try { artifact = JSON.parse(capture.stdout.toString("utf8")); } catch (error) { throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", `gentle-ai role vector returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { exitCode: 0, stderr: stderrText }); } const expectedRole = PROVIDER_ROLE_VECTOR_ROLE[request.kind]; @@ -281,9 +281,7 @@ export async function runMatrix(descriptor, options = {}) { // inject a fake to assert the exact resolved path reaches the boundary // without executing a real subprocess (platform-neutral #324 evidence). const relay = options.relay ?? runReviewHostRelaySlot; - // Same seam for the role vectors: defaults to the real spawn so production - // CLI behavior is identical when unset; tests inject a fake to assert the - // exact kind/verb/tokens reach the boundary without launching a model. + // Test seam only; production defaults to the real role runner. const roleRunner = options.roleRunner ?? runProviderRoleVector; const verdicts = []; for (const caseEntry of descriptor.cases) { @@ -292,11 +290,7 @@ export async function runMatrix(descriptor, options = {}) { verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.gentleAiExecutable, "gentleAiExecutable") }); continue; } - // Provider-role vectors are self-contained --execute invocations: Go - // materializes the role prompt, spawns its own locked-down pi, and - // admits the raw verdict. Like the positive lens, they need a real - // capable binary and an explicit arm; the runner never synthesizes a - // provider result. + // Self-contained role vectors require explicit arming and a real provider result. if (PROVIDER_ROLE_VECTOR_KINDS.includes(caseEntry.kind)) { if (!positiveArmed) { verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `${caseEntry.kind} case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary, a real pi, and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic role vector. The runner never synthesizes a provider verdict.`, command: POSITIVE_JOURNEY_COMMAND }); diff --git a/tests/maintainer/provider-relay.maintest.ts b/tests/maintainer/provider-relay.maintest.ts index c1e092c21..f832ff2df 100644 --- a/tests/maintainer/provider-relay.maintest.ts +++ b/tests/maintainer/provider-relay.maintest.ts @@ -47,21 +47,14 @@ const SUBMISSION = Object.freeze({ values: Object.freeze([{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]), }); -const ROLE_LINEAGE = "review-fixture-role-vector"; -const ROLE_REVISION = `sha256:${"a".repeat(64)}`; +const ROLE_LINEAGE = "review-fixture-role-vector", ROLE_REVISION = `sha256:${"a".repeat(64)}`; const ROLE_TARGET = `sha256:${"b".repeat(64)}`; const ROLE_CONTEXT = `rctx1_${"c".repeat(64)}`; const ROLE_REQUEST_HASH = `sha256:${"9".repeat(64)}`; const REFUTER_TOKENS = Object.freeze([`--lineage=${ROLE_LINEAGE}`, `--expected-revision=${ROLE_REVISION}`, `--target=${ROLE_TARGET}`, `--repository-context=${ROLE_CONTEXT}`, "--agent=pi", "--execute=true"]); const VALIDATOR_TOKENS = Object.freeze([`--lineage=${ROLE_LINEAGE}`, `--expected-revision=${ROLE_REVISION}`, `--target=${ROLE_TARGET}`, `--repository-context=${ROLE_CONTEXT}`, `--request-hash=${ROLE_REQUEST_HASH}`, "--agent=pi", "--execute=true"]); const VALIDATION_REQUEST = Object.freeze({ schema: "gentle-ai.review-targeted-validation-request/v1", requestHash: ROLE_REQUEST_HASH }); -const ROLE_ARTIFACT = Object.freeze({ - schema: "gentle-ai.review-provider-role-capture/v1", - lineage_id: ROLE_LINEAGE, - target_identity: ROLE_TARGET, - role: "refuter", - captured: true, -}); +const ROLE_ARTIFACT = Object.freeze({ schema: "gentle-ai.review-provider-role-capture/v1", lineage_id: ROLE_LINEAGE, target_identity: ROLE_TARGET, role: "refuter", captured: true }); function descriptor(overrides = {}) { return { @@ -146,9 +139,14 @@ test("role vector descriptors validate and return a normalized copy with exact p assert.deepEqual(validator.argumentTokens, [...VALIDATOR_TOKENS]); assert.deepEqual(validator.validationRequest, VALIDATION_REQUEST); }); -test("role vector descriptors reject missing --agent=pi / --execute=true and reject --materialize=true (self-contained, never a lens slot)", () => { - rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS].filter((t) => t !== "--agent=pi") }] }), "--agent=pi"); - rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS].filter((t) => t !== "--execute=true") }] }), "--execute=true"); +test("role vector descriptors require exactly one --agent=pi and --execute=true, never a lens slot", () => { + for (const [name, argumentTokens, expected] of [ + ["missing agent", REFUTER_TOKENS.filter((t) => t !== "--agent=pi"), "--agent=pi"], ["duplicate agent", [...REFUTER_TOKENS, "--agent=pi"], "--agent=pi"], + ["conflicting agent", [...REFUTER_TOKENS, "--agent=other"], "--agent=pi"], ["missing execute", REFUTER_TOKENS.filter((t) => t !== "--execute=true"), "--execute=true"], + ["duplicate execute", [...REFUTER_TOKENS, "--execute=true"], "--execute=true"], ["conflicting execute", [...REFUTER_TOKENS, "--execute=false"], "--execute=true"], + ] as const) { + rejects(roleDescriptor("provider-role-refuter", { cases: [{ name, kind: "provider-role-refuter", argumentTokens: [...argumentTokens] }] }), expected); + } rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS, "--materialize=true"] }] }), "--materialize=true"); }); test("targeted-validator descriptor requires exactly one request hash matching validationRequest", () => { @@ -249,10 +247,8 @@ test("runProviderRoleVector treats a prelaunch abort as not-started with no muta await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: process.execPath, signal: controller.signal }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_ABORTED && error.stage === "launch" && error.mutationOutcome === "none"); }); function roleChild(t: test.TestContext, name: string, source: string) { - const path = sandboxPath(`${name} preload.cjs`), savedNodeOptions = process.env.NODE_OPTIONS; - writeFileSync(path, `const roleArgv = ["review", ...process.argv.slice(2)], Module = require("node:module"), resolveFilename = Module._resolveFilename; Module._resolveFilename = function (request, ...args) { return /[\\\\/]review$/.test(request) ? ${JSON.stringify(path)} : resolveFilename.call(this, request, ...args); }; ${source}`); - t.after(() => { if (savedNodeOptions === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = savedNodeOptions; rmSync(path, { force: true }); }); - return { executable: process.execPath, env: { NODE_OPTIONS: `--require=${JSON.stringify(path)}` } }; + const path = sandboxPath(`${name} preload.cjs`), savedNodeOptions = process.env.NODE_OPTIONS; writeFileSync(path, `const roleArgv = ["review", ...process.argv.slice(2)], Module = require("node:module"), resolveFilename = Module._resolveFilename; Module._resolveFilename = function (request, ...args) { return /[\\\\/]review$/.test(request) ? ${JSON.stringify(path)} : resolveFilename.call(this, request, ...args); }; ${source}`); + t.after(() => { if (savedNodeOptions === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = savedNodeOptions; rmSync(path, { force: true }); }); return { executable: process.execPath, env: { NODE_OPTIONS: `--require=${JSON.stringify(path)}` } }; } test("runProviderRoleVector executes both real stub-child verbs and decodes snake_case artifacts", async (t) => { for (const kind of PROVIDER_ROLE_VECTOR_KINDS) { @@ -268,6 +264,12 @@ test("runProviderRoleVector rejects malformed artifact binding shape before stal await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_FAILED && /typed shape/.test(error.message)); } }); +test("runProviderRoleVector classifies an empty successful artifact as unknown and requires STATUS re-query", async (t) => { + const child = roleChild(t, "empty-artifact.cjs", "process.exit(0);"); + await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env }), (caught) => { + assert.ok(caught instanceof ProviderRoleVectorError); assert.equal(caught.kind, ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT); assert.equal(caught.stage, "execute"); assert.equal(caught.mutationOutcome, "unknown"); assert.match(caught.message, /re-query negotiated STATUS before any retry/); return true; + }); +}); test("runProviderRoleVector bounds each output stream before JSON or exit handling", async (t) => { for (const stream of ["stdout", "stderr"] as const) { const child = roleChild(t, `overflow-${stream}.cjs`, `process.${stream}.write("x".repeat(${ROLE_STREAM_MAX_BYTES + 1})); setInterval(() => {}, 1_000);`); @@ -280,8 +282,7 @@ async function descendantPid(path: string) { assert.fail("fixture did not report a positive descendant PID"); } async function assertReaped(pid: number) { - assert.ok(pid > 0, "only a positive descendant PID may be probed"); - for (let attempt = 0; attempt < 50; attempt += 1) try { process.kill(pid, 0); await new Promise((resolve) => setTimeout(resolve, 10)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return; throw error; } + assert.ok(pid > 0, "only a positive descendant PID may be probed"); for (let attempt = 0; attempt < 50; attempt += 1) try { process.kill(pid, 0); await new Promise((resolve) => setTimeout(resolve, 10)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return; throw error; } assert.fail("role termination left a deterministic child-process descendant running"); } test("role watchdog and abort reap descendants on every platform", async (t) => { @@ -295,6 +296,14 @@ test("role watchdog and abort reap descendants on every platform", async (t) => await assertReaped(pid); }); }); +test("failed tree termination settles despite a surviving descendant retaining inherited pipes", async (t) => { + const grandchild = sandboxPath("role-pipe-holder.pid"), controller = new AbortController(); + const child = roleChild(t, "pipe-holder-role-child.cjs", `const fs = require("node:fs"), { spawn } = require("node:child_process"), grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit", env: { ...process.env, NODE_OPTIONS: "" } }); fs.writeFileSync(${JSON.stringify(sandboxPath("ROLE_PID_PLACEHOLDER"))}.replace("ROLE_PID_PLACEHOLDER", ${JSON.stringify("role-pipe-holder.pid")}), String(grandchild.pid)); setInterval(() => {}, 1000);`); + const run = runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env, signal: controller.signal, terminateProcessTree: (process) => { process.kill("SIGKILL"); return false; } }); + const pid = await descendantPid(grandchild); t.after(() => { try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } }); + const started = Date.now(); controller.abort(); await assert.rejects(run, (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_TERMINATION_FAILED && error.stage === "execute" && error.mutationOutcome === "unknown"); + assert.ok(Date.now() - started < 500, "failed tree termination must not wait for descendant-held pipes to close"); assert.doesNotThrow(() => process.kill(pid, 0), "the fixture descendant must survive while retaining inherited pipes"); +}); test("stale-target fail-closed: missing or duplicate required binding tokens are rejected before runner invocation", () => { const drop = (tokens: readonly string[], prefix: string) => tokens.filter((t) => !t.startsWith(prefix)); for (const prefix of ["--lineage=", "--target=", "--expected-revision=", "--repository-context="]) {